Skip to main content

ant_core/data/client/
adaptive.rs

1//! Adaptive concurrency controller for client data operations.
2//!
3//! Replaces hard-coded `quote_concurrency` / `store_concurrency` /
4//! download fan-out with a per-channel AIMD limiter that ramps up when
5//! the network is healthy and ramps down on stress signals (timeouts,
6//! errors, latency inflation). The goal is to give every machine and
7//! every connection profile a single client codebase that finds its
8//! own steady state without the user tweaking flags.
9//!
10//! ## Channels
11//!
12//! Three independent limiters share the same algorithm but track state
13//! separately, because their workloads have different cost profiles:
14//!
15//! - `quote`  — small DHT request/response messages, cheap per op
16//! - `store`  — multi-MB chunk PUTs to a close group, expensive per op
17//! - `fetch`  — multi-MB chunk GETs from peers, asymmetric to `store`
18//!
19//! ## Algorithms
20//!
21//! Quote and store use TCP-style AIMD with slow-start:
22//!
23//! - **Slow-start**: starting concurrency doubles after each healthy
24//!   window until first stress signal or until the configured ceiling.
25//! - **Steady state**: additive +1 per healthy window (>= success_target
26//!   success rate AND p95 latency within `latency_inflation_factor` of
27//!   the rolling baseline).
28//! - **Stress**: multiplicative decrease (current / 2, floor 1) on any
29//!   of: success rate < success_target, timeout rate > timeout_ceiling,
30//!   or p95 latency above `latency_inflation_factor * baseline`.
31//!
32//! Decisions evaluate over a sliding window of the last `window_ops`
33//! observed outcomes per channel. Below `min_window_ops` outcomes the
34//! controller holds steady — too few samples to act on.
35//!
36//! Fetch uses a throughput-seeking hill climber instead. It measures
37//! bytes/sec over epochs, probes nearby concurrency values, accepts
38//! higher caps only when goodput improves materially, and accepts lower
39//! caps when goodput is effectively unchanged. Stress signals still cut
40//! concurrency immediately.
41//!
42//! ## What this is not
43//!
44//! - Not a payment-batching controller. Wave / batch sizes are
45//!   orthogonal (gas-economics tradeoff, not throughput).
46//! - Not a persistent peer-quality scorer. Bootstrap cache scoring was
47//!   removed from saorsa-core; this controller only tunes client
48//!   concurrency.
49
50use futures_util::stream::{self, FuturesUnordered, StreamExt};
51use serde::{Deserialize, Serialize};
52use std::collections::VecDeque;
53#[cfg(feature = "native")]
54use std::path::{Path, PathBuf};
55#[cfg(feature = "native")]
56use std::sync::atomic::{AtomicU64, Ordering};
57use std::sync::{Arc, Mutex, PoisonError};
58use std::time::Duration;
59use tracing::debug;
60#[cfg(feature = "native")]
61use tracing::warn;
62use web_time::Instant;
63
64/// Process-monotonic counter for unique snapshot temp filenames.
65/// Combined with PID + nanosecond timestamp, makes collision
66/// effectively impossible across concurrent save_snapshot calls.
67#[cfg(feature = "native")]
68static SAVE_COUNTER: AtomicU64 = AtomicU64::new(0);
69
70/// Fetch starts at the residential-saturation floor validated in
71/// production. The hill climber will find higher caps on
72/// machines/networks that can actually use them.
73const FETCH_COLD_START_CONCURRENCY: usize = 4;
74
75/// Hill-climb probes grow/shrink by roughly 25% of the current best cap.
76const HILL_PROBE_STEP_DIVISOR: usize = 4;
77
78/// Minimum probe movement so low caps can still explore.
79const HILL_MIN_PROBE_STEP: usize = 1;
80
81/// Upward probes must improve measured goodput by at least 5%.
82const HILL_UP_PROBE_ACCEPT_RATIO: f64 = 1.05;
83
84/// Downward probes are accepted if goodput stays within 2% of the best.
85const HILL_DOWN_PROBE_ACCEPT_RATIO: f64 = 0.98;
86
87/// After rejecting a probe, wait a couple of epochs before trying again.
88const HILL_REJECT_COOLDOWN_EPOCHS: usize = 2;
89
90/// At a stable best cap, periodically probe the neighbor again so the
91/// controller can adapt when machine/network conditions change.
92const HILL_STABLE_PROBE_EPOCHS: usize = 3;
93
94/// Stress cuts fetch concurrency in half.
95const HILL_STRESS_DECREASE_DIVISOR: usize = 2;
96
97/// Fetch goodput epochs should cover complete concurrency waves. A
98/// fixed sample window can unfairly compare a full lower-cap wave with
99/// a partial higher-cap wave.
100const HILL_EPOCH_FULL_WAVES: usize = 2;
101
102// Slow links must not wait for 32 completed chunks before learning. A timed
103// epoch still needs the minimum evidence and two full waves at its current cap.
104const HILL_EPOCH_MAX_DURATION: Duration = Duration::from_secs(2);
105
106/// Lock helper matching the project pattern (see `cache::ChunkCache`):
107/// poisoned mutexes still yield the inner state rather than panicking.
108fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
109    m.lock().unwrap_or_else(PoisonError::into_inner)
110}
111
112/// Outcome of a single observed operation on one channel.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum Outcome {
115    /// Completed successfully.
116    Success,
117    /// Did not complete within the per-op timeout.
118    Timeout,
119    /// Failed with a network/transport error (refused, reset, unreachable).
120    NetworkError,
121    /// Failed with an application-level error not attributable to the
122    /// network (e.g. bad payment proof). Recorded but does not push the
123    /// controller down — it is not a capacity signal.
124    ApplicationError,
125}
126
127/// Lower bound on the `fetch` channel's adaptive cap.
128///
129/// AIMD will not shrink fetch concurrency below this even under
130/// sustained timeout pressure. Specific to fetch because residential
131/// downloads exhibit a noise floor of peer-side timeouts (NAT path
132/// issues, peers in the close group not storing the chunk) that look
133/// like client saturation to the controller, causing it to fully
134/// serialize and collapse throughput. Quote and store channels keep
135/// the global `min_concurrency` floor of 1.
136const FETCH_MIN_FLOOR: usize = 4;
137
138/// Per-channel concurrency ceilings. Each channel has its own cap so
139/// that constraining one (e.g. user pinned a low store concurrency for
140/// a slow uplink) never bleeds into another (download).
141#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
142pub struct ChannelMax {
143    pub quote: usize,
144    pub store: usize,
145    pub fetch: usize,
146}
147
148impl Default for ChannelMax {
149    fn default() -> Self {
150        // Generous ceilings that give the controller real headroom to
151        // grow on healthy connections. The cold-start values
152        // (`ChannelStart::default()`) are well below these so AIMD
153        // can actually do its job. Each ceiling is independent.
154        Self {
155            quote: 128,
156            store: 64,
157            fetch: 256,
158        }
159    }
160}
161
162/// Tunable knobs for the adaptive controller. Defaults are picked so
163/// that the controller behaves at least as well as the prior static
164/// defaults on a healthy network: starts at the previous static value
165/// and only deviates when signals demand it.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct AdaptiveConfig {
168    /// Master switch. When `false`, channels report `initial` forever
169    /// and ignore observations. Useful for benchmarks / debugging.
170    pub enabled: bool,
171    /// Floor concurrency per channel. Never go below this.
172    pub min_concurrency: usize,
173    /// Per-channel ceiling concurrency. See `ChannelMax`.
174    pub max: ChannelMax,
175    /// Sliding window size in number of recent ops considered for
176    /// adaptation decisions.
177    pub window_ops: usize,
178    /// Below this count of outcomes in the window, hold steady.
179    pub min_window_ops: usize,
180    /// Required success rate to consider the window healthy. Healthy
181    /// windows trigger increase; unhealthy windows trigger decrease.
182    pub success_target: f64,
183    /// Timeout rate above which the window counts as stressed even if
184    /// the success rate would otherwise pass.
185    pub timeout_ceiling: f64,
186    /// p95 latency above `latency_inflation_factor * baseline` is a
187    /// stress signal. Baseline is an EWMA of healthy-window p95s.
188    pub latency_inflation_factor: f64,
189    /// EWMA smoothing factor for the latency baseline. 0 = never
190    /// updates, 1 = baseline = last sample. 0.2 trades responsiveness
191    /// for stability. Validated to `[0.0, 1.0]`; `NaN`/non-finite
192    /// values are sanitized to the default at controller construction.
193    pub latency_ewma_alpha: f64,
194}
195
196impl AdaptiveConfig {
197    /// Sanitize the config: clamp `latency_ewma_alpha` to `[0,1]`
198    /// (rejecting NaN/Inf which would otherwise panic in
199    /// `Duration::from_secs_f64`), enforce `min_concurrency >= 1`,
200    /// enforce per-channel max >= min_concurrency, enforce
201    /// `min_window_ops <= window_ops`. Idempotent.
202    pub fn sanitize(&mut self) {
203        if !self.latency_ewma_alpha.is_finite() {
204            self.latency_ewma_alpha = 0.2;
205        }
206        self.latency_ewma_alpha = self.latency_ewma_alpha.clamp(0.0, 1.0);
207        if !self.success_target.is_finite() {
208            self.success_target = 0.95;
209        }
210        self.success_target = self.success_target.clamp(0.0, 1.0);
211        if !self.timeout_ceiling.is_finite() {
212            self.timeout_ceiling = 0.10;
213        }
214        self.timeout_ceiling = self.timeout_ceiling.clamp(0.0, 1.0);
215        if !self.latency_inflation_factor.is_finite() || self.latency_inflation_factor <= 0.0 {
216            self.latency_inflation_factor = 4.0;
217        }
218        self.min_concurrency = self.min_concurrency.max(1);
219        self.window_ops = self.window_ops.max(1);
220        self.min_window_ops = self.min_window_ops.max(1).min(self.window_ops);
221        self.max.quote = self.max.quote.max(self.min_concurrency);
222        self.max.store = self.max.store.max(self.min_concurrency);
223        self.max.fetch = self.max.fetch.max(self.min_concurrency);
224    }
225}
226
227impl Default for AdaptiveConfig {
228    fn default() -> Self {
229        Self {
230            enabled: true,
231            min_concurrency: 1,
232            max: ChannelMax::default(),
233            window_ops: 32,
234            min_window_ops: 8,
235            success_target: 0.95,
236            timeout_ceiling: 0.10,
237            // p95 doubling is the normal signal on a per-chunk fetch with
238            // close-group fallback (one slow peer in a chunk's close group
239            // adds ~10s on top of a sub-second median); 2.0 mis-classified
240            // that as stress and halved the fetch cap mid-download. 4.0
241            // means p95 has to quadruple before we treat the network as
242            // degraded.
243            latency_inflation_factor: 4.0,
244            latency_ewma_alpha: 0.2,
245        }
246    }
247}
248
249/// Suggested starting concurrency per channel for a brand-new client
250/// with no persisted state:
251///
252/// - quote was statically 32 — start at 32.
253/// - store was statically 8 — start at 8.
254/// - fetch starts at 4, the residential-saturation floor validated
255///   after the old 64-wide cold burst saturated home links before
256///   any adaptive observations could land. The throughput hill
257///   climber then lets measured goodput justify growth on faster
258///   links.
259#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
260pub struct ChannelStart {
261    pub quote: usize,
262    pub store: usize,
263    pub fetch: usize,
264}
265
266impl Default for ChannelStart {
267    fn default() -> Self {
268        Self {
269            quote: 32,
270            store: 8,
271            fetch: FETCH_COLD_START_CONCURRENCY,
272        }
273    }
274}
275
276/// One observed sample retained in the sliding window.
277#[derive(Debug, Clone, Copy)]
278struct Sample {
279    outcome: Outcome,
280    latency: Duration,
281}
282
283/// Limiter adaptation strategy. Kept out of `LimiterConfig` so external
284/// config literals and persisted JSON do not grow a migration surface.
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286enum LimiterAlgorithm {
287    Aimd,
288    ThroughputHillClimb,
289}
290
291/// Direction of an active hill-climb probe.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293enum ProbeDirection {
294    Up,
295    Down,
296}
297
298/// Epoch-local stats for the throughput hill climber.
299#[derive(Debug)]
300struct HillClimbState {
301    epoch_started: Option<Instant>,
302    epoch_samples: usize,
303    epoch_successes: usize,
304    epoch_timeouts: usize,
305    epoch_net_errors: usize,
306    epoch_bytes: u64,
307    epoch_latencies: Vec<Duration>,
308    best_goodput_per_sec: Option<f64>,
309    best_latency_p95: Option<Duration>,
310    best_concurrency: usize,
311    stable_epochs: usize,
312    cooldown_epochs: usize,
313    next_probe: ProbeDirection,
314    active_probe: Option<ProbeDirection>,
315}
316
317impl HillClimbState {
318    fn new(start: usize, epoch_capacity: usize) -> Self {
319        Self {
320            epoch_started: None,
321            epoch_samples: 0,
322            epoch_successes: 0,
323            epoch_timeouts: 0,
324            epoch_net_errors: 0,
325            epoch_bytes: 0,
326            epoch_latencies: Vec::with_capacity(epoch_capacity),
327            best_goodput_per_sec: None,
328            best_latency_p95: None,
329            best_concurrency: start,
330            stable_epochs: 0,
331            cooldown_epochs: 0,
332            next_probe: ProbeDirection::Up,
333            active_probe: None,
334        }
335    }
336
337    fn reset_epoch(&mut self) {
338        self.epoch_started = None;
339        self.epoch_samples = 0;
340        self.epoch_successes = 0;
341        self.epoch_timeouts = 0;
342        self.epoch_net_errors = 0;
343        self.epoch_bytes = 0;
344        self.epoch_latencies.clear();
345    }
346
347    fn capacity_total(&self) -> usize {
348        self.epoch_successes + self.epoch_timeouts + self.epoch_net_errors
349    }
350}
351
352/// Per-limiter configuration. Carries the shared adaptive parameters
353/// plus the channel-specific `max_concurrency`. Held behind an `Arc`
354/// so cloning a `Limiter` is a refcount bump rather than a struct copy
355/// (avoids allocating `AdaptiveConfig`-worth of bytes per chunk in
356/// hot loops).
357#[derive(Debug, Clone)]
358pub struct LimiterConfig {
359    pub enabled: bool,
360    pub min_concurrency: usize,
361    pub max_concurrency: usize,
362    pub window_ops: usize,
363    pub min_window_ops: usize,
364    pub success_target: f64,
365    pub timeout_ceiling: f64,
366    pub latency_inflation_factor: f64,
367    pub latency_ewma_alpha: f64,
368    /// While `current < slow_start_ramp_threshold`, a Decrease halves
369    /// the cap but does NOT permanently exit slow-start — the next
370    /// healthy window can double the cap back up. Above the threshold,
371    /// a Decrease exits slow-start and the controller transitions to
372    /// classic AIMD (+1 per healthy window).
373    ///
374    /// 0 (the default) reproduces the original behaviour: any Decrease
375    /// at any cap permanently exits slow-start. The fetch channel sets
376    /// this to its `max_concurrency` so download concurrency keeps
377    /// doubling toward the ceiling instead of crawling +1 per window —
378    /// additive growth simply cannot reach a useful cap on a
379    /// fast-but-lossy link before the file finishes. See
380    /// `AdaptiveController::new`.
381    pub slow_start_ramp_threshold: usize,
382    /// When `false`, the p95-latency-vs-baseline comparison never
383    /// triggers a Decrease (it still updates the baseline). The fetch
384    /// channel disables it because `chunk_get`'s observed latency
385    /// includes the internal retry sleep and slow retry-sweep for the
386    /// chunks that needed one, so a window with a couple of retry-path
387    /// chunks has a wildly inflated p95 that is retry variance, not
388    /// congestion. Genuine fetch congestion still surfaces as a rising
389    /// `Ok(None)` (Timeout) rate, which the timeout_ceiling check
390    /// catches.
391    pub latency_decrease_enabled: bool,
392    /// When `true`, a Decrease does NOT reset `samples_since_increase`, so
393    /// evidence already accrued toward the next Increase survives a transient
394    /// Decrease instead of being zeroed.
395    ///
396    /// The default (`false`) reproduces the original asymmetric gate: an
397    /// Increase needs a full fresh `window_ops` of samples, a Decrease needs
398    /// only `min_window_ops`, AND a Decrease resets the increase counter. On a
399    /// channel where decreases fire faster than a full increase window can
400    /// accrue, that combination permanently starves growth — the store cap sat
401    /// pinned at its cold-start floor of 8 for a whole 530-chunk upload
402    /// (V2-554). The store channel sets this `true` so a few-percent shortfall
403    /// rate can no longer deny the cap its next doubling: the accrued increase
404    /// credit persists across the transient Decrease, and the next genuinely
405    /// healthy window (`evaluate` returns Increase) can act on it. Sustained
406    /// unhealthiness still holds the cap down because `evaluate` keeps
407    /// returning Decrease — this only stops isolated dips from zeroing progress.
408    pub retain_increase_credit_on_decrease: bool,
409}
410
411impl LimiterConfig {
412    fn from_adaptive(cfg: &AdaptiveConfig, max_for_channel: usize) -> Self {
413        Self {
414            enabled: cfg.enabled,
415            min_concurrency: cfg.min_concurrency,
416            max_concurrency: max_for_channel.max(cfg.min_concurrency),
417            window_ops: cfg.window_ops,
418            min_window_ops: cfg.min_window_ops,
419            success_target: cfg.success_target,
420            timeout_ceiling: cfg.timeout_ceiling,
421            latency_inflation_factor: cfg.latency_inflation_factor,
422            latency_ewma_alpha: cfg.latency_ewma_alpha,
423            // Defaults preserve the original AIMD behaviour; the fetch and
424            // store channels override these in `AdaptiveController::new`.
425            slow_start_ramp_threshold: 0,
426            latency_decrease_enabled: true,
427            retain_increase_credit_on_decrease: false,
428        }
429    }
430
431    /// Sanitize a directly-constructed `LimiterConfig`. External
432    /// callers (or tests) that build a `LimiterConfig` literal with
433    /// hostile values (`NaN`, sub-floor mins, inverted bounds) are
434    /// protected — `Limiter::new` calls this on every construction
435    /// so the controller never holds NaN or out-of-range floats.
436    fn sanitize(&mut self) {
437        if !self.latency_ewma_alpha.is_finite() {
438            self.latency_ewma_alpha = 0.2;
439        }
440        self.latency_ewma_alpha = self.latency_ewma_alpha.clamp(0.0, 1.0);
441        if !self.success_target.is_finite() {
442            self.success_target = 0.95;
443        }
444        self.success_target = self.success_target.clamp(0.0, 1.0);
445        if !self.timeout_ceiling.is_finite() {
446            self.timeout_ceiling = 0.10;
447        }
448        self.timeout_ceiling = self.timeout_ceiling.clamp(0.0, 1.0);
449        if !self.latency_inflation_factor.is_finite() || self.latency_inflation_factor <= 0.0 {
450            self.latency_inflation_factor = 4.0;
451        }
452        self.min_concurrency = self.min_concurrency.max(1);
453        self.window_ops = self.window_ops.max(1);
454        self.min_window_ops = self.min_window_ops.max(1).min(self.window_ops);
455        self.max_concurrency = self.max_concurrency.max(self.min_concurrency);
456    }
457}
458
459/// Per-channel adaptive limiter.
460///
461/// Cheap to clone — both fields are `Arc`. Pass clones into hot loops;
462/// do not hold the lock across `.await` points (call sites observe
463/// with short critical sections only).
464#[derive(Debug, Clone)]
465pub struct Limiter {
466    inner: Arc<Mutex<LimiterInner>>,
467    config: Arc<LimiterConfig>,
468    algorithm: LimiterAlgorithm,
469}
470
471#[derive(Debug)]
472struct LimiterInner {
473    observation_epoch: u64,
474    /// Current concurrency cap returned by `current()`.
475    current: usize,
476    /// Sliding window of recent outcomes.
477    window: VecDeque<Sample>,
478    /// Samples observed since the last increase. Increases require a
479    /// fresh window's worth of evidence to avoid ramping on every
480    /// individual healthy sample.
481    samples_since_increase: usize,
482    /// Samples observed since the last decrease. Decreases require
483    /// `min_window_ops` of fresh evidence to avoid pile-driving the
484    /// cap to floor on a single bad burst when many in-flight ops all
485    /// observe stress nearly simultaneously.
486    samples_since_decrease: usize,
487    /// EWMA of p95 latency from past healthy windows. `None` until
488    /// the first healthy window completes.
489    latency_baseline: Option<Duration>,
490    /// `true` once we have observed a stress signal at least once.
491    /// Slow-start mode ends permanently after first stress.
492    left_slow_start: bool,
493    /// Fetch-only throughput optimizer state. Present for every limiter
494    /// to keep `Limiter` cheap to clone and avoid an enum around the
495    /// whole inner struct.
496    hill: HillClimbState,
497}
498
499impl Limiter {
500    /// Create a new limiter starting at `start`, clamped into
501    /// `[min_concurrency, max_concurrency]`. Sanitizes the config to
502    /// guard against directly-constructed `LimiterConfig` literals
503    /// with hostile float values (`NaN`, etc).
504    #[must_use]
505    pub fn new(start: usize, config: LimiterConfig) -> Self {
506        Self::new_with_algorithm(start, config, LimiterAlgorithm::Aimd)
507    }
508
509    fn new_with_algorithm(
510        start: usize,
511        config: LimiterConfig,
512        algorithm: LimiterAlgorithm,
513    ) -> Self {
514        let mut config = config;
515        config.sanitize();
516        let clamped = start.clamp(config.min_concurrency, config.max_concurrency.max(1));
517        let window_cap = config.window_ops;
518        Self {
519            inner: Arc::new(Mutex::new(LimiterInner {
520                observation_epoch: 0,
521                current: clamped,
522                window: VecDeque::with_capacity(window_cap),
523                samples_since_increase: 0,
524                samples_since_decrease: 0,
525                latency_baseline: None,
526                left_slow_start: false,
527                hill: HillClimbState::new(clamped, window_cap),
528            })),
529            config: Arc::new(config),
530            algorithm,
531        }
532    }
533
534    /// Snapshot current concurrency cap. Hot-path call: the value may
535    /// change between this call and the next, but consumers
536    /// (`buffer_unordered(n)`) capture it once per pipeline build.
537    #[must_use]
538    pub fn current(&self) -> usize {
539        lock(&self.inner).current
540    }
541
542    /// Record one observed operation. Updates the sliding window and
543    /// re-evaluates the cap if the window is full enough.
544    pub fn observe(&self, outcome: Outcome, latency: Duration) {
545        self.observe_with_bytes(outcome, latency, 0);
546    }
547
548    /// Record one observed operation with a payload byte count. Bytes
549    /// are used by the fetch hill climber; AIMD channels ignore them.
550    pub fn observe_with_bytes(&self, outcome: Outcome, latency: Duration, bytes: u64) {
551        let observed_at = Instant::now();
552        let operation_started = observed_at.checked_sub(latency).unwrap_or(observed_at);
553        self.observe_with_timing(outcome, latency, bytes, operation_started);
554    }
555
556    pub(crate) fn observation_epoch(&self) -> u64 {
557        lock(&self.inner).observation_epoch
558    }
559
560    pub(crate) fn observe_fetch_in_epoch(
561        &self,
562        outcome: Outcome,
563        latency: Duration,
564        bytes: u64,
565        epoch: u64,
566    ) {
567        let now = Instant::now();
568        self.observe_with_timing_in_epoch(
569            outcome,
570            latency,
571            bytes,
572            now.checked_sub(latency).unwrap_or(now),
573            Some(epoch),
574        );
575    }
576
577    fn observe_with_timing(
578        &self,
579        outcome: Outcome,
580        latency: Duration,
581        bytes: u64,
582        operation_started: Instant,
583    ) {
584        self.observe_with_timing_in_epoch(outcome, latency, bytes, operation_started, None);
585    }
586
587    fn observe_with_timing_in_epoch(
588        &self,
589        outcome: Outcome,
590        latency: Duration,
591        bytes: u64,
592        operation_started: Instant,
593        expected_epoch: Option<u64>,
594    ) {
595        if !self.config.enabled {
596            return;
597        }
598        let mut g = lock(&self.inner);
599        // Work launched at a different concurrency cannot train this probe.
600        // Cancellation remains unobserved, and epochs change only with the cap.
601        if self.algorithm == LimiterAlgorithm::ThroughputHillClimb
602            && expected_epoch.is_some_and(|epoch| epoch != g.observation_epoch)
603        {
604            return;
605        }
606        if g.window.len() == self.config.window_ops {
607            g.window.pop_front();
608        }
609        g.window.push_back(Sample { outcome, latency });
610        if self.algorithm == LimiterAlgorithm::ThroughputHillClimb {
611            let previous_cap = g.current;
612            observe_hill_climb(
613                &mut g,
614                outcome,
615                latency,
616                bytes,
617                operation_started,
618                &self.config,
619            );
620            if g.current != previous_cap {
621                g.observation_epoch = g.observation_epoch.wrapping_add(1);
622            }
623            return;
624        }
625        g.samples_since_increase = g.samples_since_increase.saturating_add(1);
626        g.samples_since_decrease = g.samples_since_decrease.saturating_add(1);
627        if g.window.len() < self.config.min_window_ops {
628            return;
629        }
630        let decision = evaluate(&g.window, &self.config, g.latency_baseline);
631        apply_decision(&mut g, decision, &self.config);
632    }
633
634    /// Replace the current cap with `start`, clamped. Used for warm
635    /// loads from persisted state. Does not clear the sliding window —
636    /// fresh observations remain authoritative for adaptation
637    /// decisions.
638    ///
639    /// Slow-start state after a warm load depends on the channel's
640    /// `slow_start_ramp_threshold`:
641    ///
642    /// - Default (threshold 0, i.e. quote/store): mark slow-start as
643    ///   already-left so a single healthy window doesn't *double* a
644    ///   learned warm value — an over-aggressive jump. Subsequent
645    ///   increases are +1 per healthy window.
646    /// - Protected (threshold > clamped, i.e. fetch below the ceiling):
647    ///   keep slow-start armed. This is critical for the CLI usage
648    ///   pattern where every `ant file download` is a fresh process
649    ///   that warm-starts from the snapshot: if warm_start always
650    ///   exited slow-start, the fetch cap could only ever grow
651    ///   additively from the persisted value, which cannot climb back
652    ///   to the ceiling against an intermittent Decrease trickle (the
653    ///   exact pin-at-~20 behaviour observed on a fast-but-lossy VPS).
654    ///   Keeping slow-start armed lets the cap double back toward the
655    ///   capacity the connection can actually sustain.
656    pub fn warm_start(&self, start: usize) {
657        let clamped = start.clamp(
658            self.config.min_concurrency,
659            self.config.max_concurrency.max(1),
660        );
661        let mut g = lock(&self.inner);
662        g.current = clamped;
663        g.observation_epoch = g.observation_epoch.wrapping_add(1);
664        g.left_slow_start = clamped >= self.config.slow_start_ramp_threshold;
665        g.hill = HillClimbState::new(clamped, self.config.window_ops);
666    }
667
668    /// Snapshot of the current cap for persistence. Cheap, lock-only.
669    #[must_use]
670    pub fn snapshot(&self) -> usize {
671        let g = lock(&self.inner);
672        if self.algorithm == LimiterAlgorithm::ThroughputHillClimb {
673            g.hill.best_concurrency
674        } else {
675            g.current
676        }
677    }
678}
679
680#[derive(Debug, Clone, Copy)]
681struct HillEpochStats {
682    goodput_per_sec: f64,
683    latency_p95: Option<Duration>,
684}
685
686/// Outcome of evaluating one window.
687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
688enum Decision {
689    /// Healthy window — increase concurrency.
690    Increase,
691    /// Stressed window — decrease concurrency.
692    Decrease,
693    /// Inconclusive — hold steady (e.g. mixed signals, baseline not yet set).
694    Hold,
695}
696
697fn evaluate(
698    window: &VecDeque<Sample>,
699    cfg: &LimiterConfig,
700    baseline: Option<Duration>,
701) -> Decision {
702    // Capacity-relevant denominator: ApplicationError outcomes are
703    // explicitly NOT capacity signals (per `Outcome` docs) and are
704    // excluded from rate calculations. A wave of `AlreadyStored`
705    // errors must not punish concurrency.
706    let mut successes = 0usize;
707    let mut timeouts = 0usize;
708    let mut net_errors = 0usize;
709    let mut latencies: Vec<Duration> = Vec::with_capacity(window.len());
710    for s in window {
711        match s.outcome {
712            Outcome::Success => {
713                successes += 1;
714                latencies.push(s.latency);
715            }
716            Outcome::Timeout => timeouts += 1,
717            Outcome::NetworkError => net_errors += 1,
718            Outcome::ApplicationError => {}
719        }
720    }
721    let capacity_total = successes + timeouts + net_errors;
722    if capacity_total < cfg.min_window_ops {
723        // Not enough capacity-relevant evidence to act. Hold.
724        return Decision::Hold;
725    }
726    let total_f = capacity_total as f64;
727    let success_rate = successes as f64 / total_f;
728    let timeout_rate = timeouts as f64 / total_f;
729
730    if success_rate < cfg.success_target || timeout_rate > cfg.timeout_ceiling {
731        return Decision::Decrease;
732    }
733
734    if let Some(p95) = p95_of(&mut latencies) {
735        if cfg.latency_decrease_enabled {
736            if let Some(base) = baseline {
737                let limit = base.mul_f64(cfg.latency_inflation_factor);
738                if p95 > limit {
739                    return Decision::Decrease;
740                }
741            }
742        }
743        Decision::Increase
744    } else {
745        Decision::Hold
746    }
747}
748
749fn apply_decision(inner: &mut LimiterInner, decision: Decision, cfg: &LimiterConfig) {
750    match decision {
751        Decision::Increase => {
752            // Gate increases on accumulating a fresh window's worth of
753            // evidence since the last bump.
754            if inner.samples_since_increase < cfg.window_ops {
755                return;
756            }
757            let p95 = window_p95(&inner.window);
758            inner.latency_baseline = Some(match inner.latency_baseline {
759                None => p95,
760                Some(prev) => ewma(prev, p95, cfg.latency_ewma_alpha),
761            });
762            let next = if inner.left_slow_start {
763                inner.current.saturating_add(1)
764            } else {
765                inner.current.saturating_mul(2)
766            };
767            let next = next.min(cfg.max_concurrency).max(cfg.min_concurrency);
768            if next != inner.current {
769                debug!(
770                    from = inner.current,
771                    to = next,
772                    slow_start = !inner.left_slow_start,
773                    "adaptive: increase",
774                );
775            }
776            inner.current = next;
777            inner.samples_since_increase = 0;
778            inner.samples_since_decrease = 0;
779        }
780        Decision::Decrease => {
781            // Gate decreases on `min_window_ops` of fresh evidence
782            // since the last decrease so a burst of concurrent
783            // observations from in-flight ops can't pile-drive the
784            // cap from N to 1 in a few back-to-back ticks.
785            if inner.samples_since_decrease < cfg.min_window_ops {
786                return;
787            }
788            // Below the ramp threshold we still halve (responsiveness is
789            // preserved) but keep slow-start armed, so the next healthy
790            // window can double the cap back up rather than crawling +1.
791            // Above the threshold a Decrease is the signal to settle into
792            // classic AIMD. With threshold=0 (quote/store) this is the
793            // original behaviour: any Decrease exits slow-start.
794            if inner.current >= cfg.slow_start_ramp_threshold {
795                inner.left_slow_start = true;
796            }
797            let next = (inner.current / 2).max(cfg.min_concurrency);
798            if next != inner.current {
799                debug!(from = inner.current, to = next, "adaptive: decrease");
800            }
801            inner.current = next;
802            // Retaining the increase credit (store channel, V2-554) keeps the
803            // evidence accrued toward the next Increase alive across a transient
804            // Decrease so a few-percent shortfall rate can't permanently starve
805            // growth. The default zeroes it, requiring a full fresh window.
806            if !cfg.retain_increase_credit_on_decrease {
807                inner.samples_since_increase = 0;
808            }
809            inner.samples_since_decrease = 0;
810        }
811        Decision::Hold => {}
812    }
813}
814
815/// p95 of a mutable slice of Durations. Sorts in place. Returns
816/// `None` for an empty slice. Index choice: `ceil(len * 0.95) - 1`,
817/// floored at 0, capped at `len - 1`.
818fn p95_of(latencies: &mut [Duration]) -> Option<Duration> {
819    if latencies.is_empty() {
820        return None;
821    }
822    latencies.sort_unstable();
823    let idx = ((latencies.len() as f64) * 0.95).ceil() as usize;
824    let idx = idx.saturating_sub(1).min(latencies.len() - 1);
825    latencies.get(idx).copied()
826}
827
828fn window_p95(window: &VecDeque<Sample>) -> Duration {
829    let mut latencies: Vec<Duration> = window
830        .iter()
831        .filter(|s| matches!(s.outcome, Outcome::Success))
832        .map(|s| s.latency)
833        .collect();
834    p95_of(&mut latencies).unwrap_or(Duration::ZERO)
835}
836
837fn ewma(prev: Duration, sample: Duration, alpha: f64) -> Duration {
838    let alpha = if alpha.is_finite() {
839        alpha.clamp(0.0, 1.0)
840    } else {
841        return prev;
842    };
843    let prev_ms = prev.as_secs_f64() * 1000.0;
844    let sample_ms = sample.as_secs_f64() * 1000.0;
845    let new_ms = (1.0 - alpha) * prev_ms + alpha * sample_ms;
846    if !new_ms.is_finite() || new_ms < 0.0 {
847        return prev;
848    }
849    Duration::from_secs_f64(new_ms / 1000.0)
850}
851
852fn observe_hill_climb(
853    inner: &mut LimiterInner,
854    outcome: Outcome,
855    latency: Duration,
856    bytes: u64,
857    operation_started: Instant,
858    cfg: &LimiterConfig,
859) {
860    match inner.hill.epoch_started {
861        Some(epoch_started) if epoch_started <= operation_started => {}
862        _ => inner.hill.epoch_started = Some(operation_started),
863    }
864    inner.hill.epoch_samples = inner.hill.epoch_samples.saturating_add(1);
865    match outcome {
866        Outcome::Success => {
867            inner.hill.epoch_successes = inner.hill.epoch_successes.saturating_add(1);
868            inner.hill.epoch_bytes = inner.hill.epoch_bytes.saturating_add(bytes);
869            inner.hill.epoch_latencies.push(latency);
870        }
871        Outcome::Timeout => {
872            inner.hill.epoch_timeouts = inner.hill.epoch_timeouts.saturating_add(1);
873        }
874        Outcome::NetworkError => {
875            inner.hill.epoch_net_errors = inner.hill.epoch_net_errors.saturating_add(1);
876        }
877        Outcome::ApplicationError => {}
878    }
879
880    if hill_epoch_stressed(&inner.hill, cfg) {
881        apply_hill_stress(inner, cfg);
882        return;
883    }
884
885    let minimum = cfg
886        .min_window_ops
887        .max(inner.current.saturating_mul(HILL_EPOCH_FULL_WAVES));
888    let timed_epoch = inner.hill.epoch_samples >= minimum
889        && inner
890            .hill
891            .epoch_started
892            .is_some_and(|started| started.elapsed() >= HILL_EPOCH_MAX_DURATION);
893    if !timed_epoch && inner.hill.epoch_samples < hill_epoch_target_samples(inner.current, cfg) {
894        return;
895    }
896
897    if let Some(stats) = hill_epoch_stats(&inner.hill, cfg) {
898        apply_hill_epoch(inner, stats, cfg);
899    }
900    inner.hill.reset_epoch();
901}
902
903fn hill_epoch_target_samples(current: usize, cfg: &LimiterConfig) -> usize {
904    cfg.window_ops
905        .max(current.saturating_mul(HILL_EPOCH_FULL_WAVES))
906        .max(cfg.min_window_ops)
907}
908
909fn hill_epoch_stressed(hill: &HillClimbState, cfg: &LimiterConfig) -> bool {
910    let capacity_total = hill.capacity_total();
911    if capacity_total < cfg.min_window_ops {
912        return false;
913    }
914    let total_f = capacity_total as f64;
915    let success_rate = hill.epoch_successes as f64 / total_f;
916    let timeout_rate = hill.epoch_timeouts as f64 / total_f;
917    success_rate < cfg.success_target || timeout_rate > cfg.timeout_ceiling
918}
919
920fn hill_epoch_stats(hill: &HillClimbState, cfg: &LimiterConfig) -> Option<HillEpochStats> {
921    let capacity_total = hill.capacity_total();
922    if capacity_total < cfg.min_window_ops || hill.epoch_successes == 0 {
923        return None;
924    }
925    let mut latencies = hill.epoch_latencies.clone();
926    let latency_p95 = p95_of(&mut latencies);
927    let max_latency = latencies.iter().copied().max().unwrap_or(Duration::ZERO);
928    let wall_elapsed = hill.epoch_started.map_or(Duration::ZERO, |s| s.elapsed());
929    let elapsed = wall_elapsed.max(max_latency);
930    let elapsed_secs = elapsed.as_secs_f64();
931    if !elapsed_secs.is_finite() || elapsed_secs <= 0.0 {
932        return None;
933    }
934
935    // Unit fallback keeps direct unit tests that call `observe(Success, ..)`
936    // meaningful; real download paths report bytes.
937    let units = if hill.epoch_bytes > 0 {
938        hill.epoch_bytes as f64
939    } else {
940        hill.epoch_successes as f64
941    };
942    Some(HillEpochStats {
943        goodput_per_sec: units / elapsed_secs,
944        latency_p95,
945    })
946}
947
948fn apply_hill_stress(inner: &mut LimiterInner, cfg: &LimiterConfig) {
949    let next = (inner.current / HILL_STRESS_DECREASE_DIVISOR)
950        .max(cfg.min_concurrency)
951        .min(cfg.max_concurrency);
952    if next != inner.current {
953        debug!(
954            from = inner.current,
955            to = next,
956            "adaptive: fetch hill stress decrease"
957        );
958    }
959    inner.current = next;
960    inner.hill.best_concurrency = next;
961    inner.hill.best_goodput_per_sec = None;
962    inner.hill.best_latency_p95 = None;
963    inner.hill.stable_epochs = 0;
964    inner.hill.cooldown_epochs = HILL_REJECT_COOLDOWN_EPOCHS;
965    inner.hill.active_probe = None;
966    inner.hill.next_probe = ProbeDirection::Up;
967    inner.hill.reset_epoch();
968}
969
970fn apply_hill_epoch(inner: &mut LimiterInner, stats: HillEpochStats, cfg: &LimiterConfig) {
971    let Some(best_goodput) = inner.hill.best_goodput_per_sec else {
972        inner.hill.best_goodput_per_sec = Some(stats.goodput_per_sec);
973        inner.hill.best_latency_p95 = stats.latency_p95;
974        inner.hill.best_concurrency = inner.current;
975        probe_hill_neighbor(inner, ProbeDirection::Up, cfg);
976        return;
977    };
978
979    match inner.hill.active_probe {
980        Some(ProbeDirection::Up) => {
981            let improved = stats.goodput_per_sec >= best_goodput * HILL_UP_PROBE_ACCEPT_RATIO;
982            if improved
983                && hill_latency_acceptable(stats.latency_p95, inner.hill.best_latency_p95, cfg)
984            {
985                accept_hill_probe(inner, stats, cfg);
986                probe_hill_neighbor(inner, ProbeDirection::Up, cfg);
987            } else {
988                reject_hill_probe(inner);
989            }
990        }
991        Some(ProbeDirection::Down) => {
992            let retained = stats.goodput_per_sec >= best_goodput * HILL_DOWN_PROBE_ACCEPT_RATIO;
993            if retained
994                && hill_latency_acceptable(stats.latency_p95, inner.hill.best_latency_p95, cfg)
995            {
996                accept_hill_probe(inner, stats, cfg);
997                inner.hill.next_probe = ProbeDirection::Up;
998            } else {
999                reject_hill_probe(inner);
1000            }
1001        }
1002        None => {
1003            refresh_hill_best(inner, stats, cfg);
1004            if inner.hill.cooldown_epochs > 0 {
1005                inner.hill.cooldown_epochs -= 1;
1006                return;
1007            }
1008            inner.hill.stable_epochs = inner.hill.stable_epochs.saturating_add(1);
1009            if inner.hill.stable_epochs >= HILL_STABLE_PROBE_EPOCHS {
1010                let direction = inner.hill.next_probe;
1011                inner.hill.next_probe = match direction {
1012                    ProbeDirection::Up => ProbeDirection::Down,
1013                    ProbeDirection::Down => ProbeDirection::Up,
1014                };
1015                probe_hill_neighbor(inner, direction, cfg);
1016            }
1017        }
1018    }
1019}
1020
1021fn refresh_hill_best(inner: &mut LimiterInner, stats: HillEpochStats, cfg: &LimiterConfig) {
1022    inner.hill.best_goodput_per_sec = Some(match inner.hill.best_goodput_per_sec {
1023        Some(prev) => ewma_f64(prev, stats.goodput_per_sec, cfg.latency_ewma_alpha),
1024        None => stats.goodput_per_sec,
1025    });
1026    if let Some(latency_p95) = stats.latency_p95 {
1027        inner.hill.best_latency_p95 = Some(match inner.hill.best_latency_p95 {
1028            Some(prev) => ewma(prev, latency_p95, cfg.latency_ewma_alpha),
1029            None => latency_p95,
1030        });
1031    }
1032}
1033
1034fn hill_latency_acceptable(
1035    candidate: Option<Duration>,
1036    best: Option<Duration>,
1037    cfg: &LimiterConfig,
1038) -> bool {
1039    match (candidate, best) {
1040        (Some(candidate), Some(best)) => candidate <= best.mul_f64(cfg.latency_inflation_factor),
1041        _ => true,
1042    }
1043}
1044
1045fn ewma_f64(prev: f64, sample: f64, alpha: f64) -> f64 {
1046    let alpha = if alpha.is_finite() {
1047        alpha.clamp(0.0, 1.0)
1048    } else {
1049        return prev;
1050    };
1051    let next = (1.0 - alpha) * prev + alpha * sample;
1052    if next.is_finite() && next >= 0.0 {
1053        next
1054    } else {
1055        prev
1056    }
1057}
1058
1059fn accept_hill_probe(inner: &mut LimiterInner, stats: HillEpochStats, cfg: &LimiterConfig) {
1060    debug!(
1061        concurrency = inner.current,
1062        goodput_per_sec = stats.goodput_per_sec,
1063        "adaptive: fetch hill accepted probe"
1064    );
1065    inner.hill.best_concurrency = inner.current;
1066    inner.hill.best_goodput_per_sec = Some(stats.goodput_per_sec);
1067    inner.hill.best_latency_p95 = stats.latency_p95;
1068    inner.hill.active_probe = None;
1069    inner.hill.cooldown_epochs = 0;
1070    inner.hill.stable_epochs = 0;
1071    inner.current = inner
1072        .hill
1073        .best_concurrency
1074        .clamp(cfg.min_concurrency, cfg.max_concurrency);
1075}
1076
1077fn reject_hill_probe(inner: &mut LimiterInner) {
1078    let from = inner.current;
1079    let to = inner.hill.best_concurrency;
1080    let rejected_direction = inner.hill.active_probe;
1081    if from != to {
1082        debug!(from, to, "adaptive: fetch hill rejected probe");
1083    }
1084    inner.current = to;
1085    inner.hill.active_probe = None;
1086    if let Some(direction) = rejected_direction {
1087        inner.hill.next_probe = match direction {
1088            ProbeDirection::Up => ProbeDirection::Down,
1089            ProbeDirection::Down => ProbeDirection::Up,
1090        };
1091    }
1092    inner.hill.cooldown_epochs = HILL_REJECT_COOLDOWN_EPOCHS;
1093    inner.hill.stable_epochs = 0;
1094}
1095
1096fn probe_hill_neighbor(inner: &mut LimiterInner, direction: ProbeDirection, cfg: &LimiterConfig) {
1097    let best = inner.hill.best_concurrency;
1098    let step = (best / HILL_PROBE_STEP_DIVISOR).max(HILL_MIN_PROBE_STEP);
1099    let candidate = match direction {
1100        ProbeDirection::Up => best.saturating_add(step).min(cfg.max_concurrency),
1101        ProbeDirection::Down => best.saturating_sub(step).max(cfg.min_concurrency),
1102    };
1103    if candidate == best {
1104        inner.current = best;
1105        inner.hill.active_probe = None;
1106        inner.hill.stable_epochs = 0;
1107        return;
1108    }
1109    debug!(
1110        from = best,
1111        to = candidate,
1112        ?direction,
1113        "adaptive: fetch hill probing"
1114    );
1115    inner.current = candidate;
1116    inner.hill.active_probe = Some(direction);
1117    inner.hill.stable_epochs = 0;
1118}
1119
1120/// Bundle of per-channel limiters owned by the `Client`.
1121#[derive(Debug, Clone)]
1122pub struct AdaptiveController {
1123    pub quote: Limiter,
1124    pub store: Limiter,
1125    pub fetch: Limiter,
1126    /// `pub(crate)` so external callers cannot mutate this
1127    /// post-construction. Each `Limiter` snapshots its own
1128    /// `Arc<LimiterConfig>` at construction time, so external
1129    /// mutation here would silently desync `warm_start`'s
1130    /// `enabled` check from the limiters' frozen copies. Read via
1131    /// `config()`.
1132    pub(crate) config: AdaptiveConfig,
1133    /// Per-instance cold-start values. `warm_start` floors snapshot
1134    /// values against THIS, not the global `ChannelStart::default()`,
1135    /// so a controller built with custom (e.g. low) starts stays
1136    /// faithful to its construction parameters. Constructed-once,
1137    /// never mutated.
1138    cold_start: ChannelStart,
1139}
1140
1141impl AdaptiveController {
1142    /// Create a controller with cold-start values per channel.
1143    /// Sanitizes the config (NaN guards, floor/ceiling enforcement)
1144    /// before constructing limiters. The supplied `start` is captured
1145    /// as the per-instance cold-start floor for `warm_start`.
1146    #[must_use]
1147    pub fn new(start: ChannelStart, config: AdaptiveConfig) -> Self {
1148        let mut config = config;
1149        config.sanitize();
1150        let quote_cfg = LimiterConfig::from_adaptive(&config, config.max.quote);
1151        let mut store_cfg = LimiterConfig::from_adaptive(&config, config.max.store);
1152        // Store-channel growth/decision tuning (V2-468). The store limiter
1153        // starts at 8 (correct — deliberately low for low-bandwidth uplinks)
1154        // but on the merkle upload path its health signals are polluted by two
1155        // things that are NOT local-capacity signals, so it never ramps and
1156        // gets crushed to a +1-per-window crawl. Both are the structural twin
1157        // of the fetch-channel overrides below (verification variance instead
1158        // of retry variance); the cold-start floor is deliberately untouched.
1159        //
1160        // - Disable the p95-latency Decrease. Node-side PUT latency is
1161        //   dominated by the ~28s synchronous merkle closeness lookup, giving a
1162        //   client-observed p95/median of ~3-6x that straddles
1163        //   `latency_inflation_factor` (4.0) and trips Decrease even though
1164        //   nothing about it is local congestion. Genuine store congestion
1165        //   still surfaces via the timeout-rate ceiling.
1166        // - Never exit slow-start. With the default threshold 0, any single
1167        //   Decrease at any cap permanently drops the store cap to additive
1168        //   +1-per-healthy-window growth, which cannot reach a useful cap
1169        //   before a file finishes (843 chunks stuck at effective ~5-9 in the
1170        //   PROD-UL-01 incident). `usize::MAX` keeps slow-start armed at every
1171        //   cap, so a transient Decrease still halves but the next healthy
1172        //   window doubles it back instead of condemning the rest of the file
1173        //   to a crawl. See the fetch override and `LimiterConfig` field docs.
1174        store_cfg.latency_decrease_enabled = false;
1175        store_cfg.slow_start_ramp_threshold = usize::MAX;
1176        // Break the asymmetric gate that pinned the store cap at its cold-start
1177        // floor (V2-554). Two coupled changes, both scoped to store:
1178        //
1179        // 1. Retain the increase credit across a Decrease. The default gate
1180        //    needs a full fresh `window_ops` of samples to earn an Increase but
1181        //    only `min_window_ops` to fire a Decrease, AND a Decrease zeroes the
1182        //    increase counter — so an isolated dip discards accrued growth.
1183        //    Retaining it lets the next healthy window act on that evidence.
1184        //
1185        // 2. Relax `success_target` from the global 0.95 to 0.88. Retaining the
1186        //    credit is not enough on its own: a Decrease still fires every
1187        //    `min_window_ops` (8) samples while eligible but an Increase only
1188        //    every `window_ops` (32), a 4:1 firing ratio that lets a sustained
1189        //    few-percent shortfall outpace growth and crush the cap regardless.
1190        //    At 0.95 just 2 shortfalls in a 32-op window (0.9375) trip a
1191        //    Decrease — normal per-chunk close-group noise. At 0.88 a Decrease
1192        //    needs ~4 shortfalls in 32 (~12%), so a few-percent shortfall reads
1193        //    as a healthy window and the cap can ramp. Genuine congestion (a
1194        //    larger shortfall rate, or the unchanged `timeout_ceiling`) still
1195        //    cuts the cap. Quote keeps the classic 0.95 gate.
1196        store_cfg.retain_increase_credit_on_decrease = true;
1197        store_cfg.success_target = 0.88;
1198        let mut fetch_cfg = LimiterConfig::from_adaptive(&config, config.max.fetch);
1199        // Lift the fetch channel's floor above the global
1200        // `min_concurrency`. Reasoning is specific to download: on
1201        // residential links, residual peer-side timeouts (NAT path
1202        // issues, peers in the close group that don't store the chunk,
1203        // peers under temporary load) continuously push the
1204        // controller's timeout_rate above ceiling. A global floor of 1
1205        // means the controller fully serializes chunk fetches on that
1206        // noise floor and gets stuck — observed on PROD-LOCAL-DL-03
1207        // where the download stayed stable but throughput collapsed to
1208        // ~330 KB/s on a multi-MB/s link.
1209        //
1210        // 4 is the smallest floor that keeps the download from fully
1211        // serializing; it also matches the validated cold-start floor.
1212        // Floor `quote` and `store` separately if a corresponding
1213        // pathology is identified for them; today's evidence is
1214        // download-only.
1215        fetch_cfg.min_concurrency = fetch_cfg.min_concurrency.max(FETCH_MIN_FLOOR);
1216        // Re-establish max >= min after the bump in case the channel
1217        // ceiling was somehow lower than the new floor.
1218        fetch_cfg.max_concurrency = fetch_cfg.max_concurrency.max(fetch_cfg.min_concurrency);
1219        // Download-specific growth/decision tuning (see the field docs
1220        // on `LimiterConfig`):
1221        //
1222        // - Never exit slow-start. Classic AIMD additive growth (+1 per
1223        //   healthy window) cannot reach a useful cap from a low base
1224        //   before a multi-GB file finishes — a fast-but-lossy
1225        //   connection (e.g. a VPS with a steady ~4% close-group-
1226        //   exhaustion trickle) was observed stuck at cap ~13-24 across
1227        //   36 files because every transient Decrease permanently
1228        //   dropped it to additive growth. `usize::MAX` keeps slow-start
1229        //   armed at every cap including the ceiling, so a Decrease
1230        //   (e.g. 256 -> 128) still halves but the next healthy window
1231        //   doubles it back. The cap therefore tracks the connection's
1232        //   real capacity instead of crawling, and a single transient
1233        //   Decrease near the ceiling can't re-pin the link to additive
1234        //   recovery. (A threshold == max_concurrency would NOT achieve
1235        //   this: `current >= threshold` is true at the ceiling, so a
1236        //   Decrease there would exit slow-start.)
1237        // - Disable the p95-latency Decrease. chunk_get's observed
1238        //   latency includes the internal retry sleep + slow retry
1239        //   sweep for chunks that needed one, so a window with a couple
1240        //   of retry-path chunks shows a hugely inflated p95 that is
1241        //   retry variance, not congestion. Genuine fetch congestion
1242        //   still drives Decrease via the Ok(None) -> Timeout rate.
1243        fetch_cfg.slow_start_ramp_threshold = usize::MAX;
1244        fetch_cfg.latency_decrease_enabled = false;
1245        Self {
1246            quote: Limiter::new(start.quote, quote_cfg),
1247            store: Limiter::new(start.store, store_cfg),
1248            fetch: Limiter::new_with_algorithm(
1249                start.fetch,
1250                fetch_cfg,
1251                LimiterAlgorithm::ThroughputHillClimb,
1252            ),
1253            config,
1254            cold_start: start,
1255        }
1256    }
1257
1258    /// Snapshot current per-channel caps for persistence.
1259    #[must_use]
1260    pub fn snapshot(&self) -> ChannelStart {
1261        ChannelStart {
1262            quote: self.quote.snapshot(),
1263            store: self.store.snapshot(),
1264            fetch: self.fetch.snapshot(),
1265        }
1266    }
1267
1268    /// Read-only access to the controller's adaptive config. Made
1269    /// read-only deliberately: each `Limiter` snapshots its own
1270    /// `Arc<LimiterConfig>` at construction, so post-hoc mutation
1271    /// would silently desync `warm_start`'s `enabled` check from
1272    /// the limiters' frozen copies.
1273    #[must_use]
1274    pub fn config(&self) -> &AdaptiveConfig {
1275        &self.config
1276    }
1277
1278    /// Apply a previously-saved snapshot as the warm-start cap.
1279    ///
1280    /// The effective warm value per channel is
1281    /// `max(snapshot, self.cold_start)` — flooring at the
1282    /// per-instance cold-start (NOT the global default) so:
1283    /// 1. A prior bad run that pinned cap=1 doesn't pessimize this
1284    ///    run forever.
1285    /// 2. A controller built with custom (e.g. low) cold starts for
1286    ///    benchmarking is not silently jumped above its construction
1287    ///    parameters.
1288    ///
1289    /// Does not clear sliding windows. When `enabled = false`, this
1290    /// is a no-op — fixed-concurrency mode means fixed-concurrency.
1291    pub fn warm_start(&self, snapshot: ChannelStart) {
1292        if !self.config.enabled {
1293            return;
1294        }
1295        self.quote
1296            .warm_start(snapshot.quote.max(self.cold_start.quote));
1297        self.store
1298            .warm_start(snapshot.store.max(self.cold_start.store));
1299        self.fetch
1300            .warm_start(snapshot.fetch.max(self.cold_start.fetch));
1301    }
1302}
1303
1304impl Default for AdaptiveController {
1305    fn default() -> Self {
1306        Self::new(ChannelStart::default(), AdaptiveConfig::default())
1307    }
1308}
1309
1310/// Cancel-on-drop guard: if the wrapping future is dropped before
1311/// completion, record no outcome. We don't synthesize a Cancelled
1312/// signal because (a) dropped work was never observed by the network
1313/// and (b) injecting fake outcomes would skew the sliding window
1314/// after a fail-fast burst. The intentional behavior is "silent on
1315/// cancel, observe on completion" — callers that need to keep
1316/// fail-fast batches drained for full signal use `rebucketed`.
1317struct ObserveGuard<'a> {
1318    epoch: u64,
1319    limiter: &'a Limiter,
1320    started: Instant,
1321    outcome: Option<(Outcome, Duration, u64)>,
1322}
1323
1324impl<'a> ObserveGuard<'a> {
1325    fn new(limiter: &'a Limiter) -> Self {
1326        Self {
1327            epoch: limiter.observation_epoch(),
1328            limiter,
1329            started: Instant::now(),
1330            outcome: None,
1331        }
1332    }
1333    fn finish(&mut self, outcome: Outcome) {
1334        self.finish_with_bytes(outcome, 0);
1335    }
1336
1337    fn finish_with_bytes(&mut self, outcome: Outcome, bytes: u64) {
1338        self.outcome = Some((outcome, self.started.elapsed(), bytes));
1339    }
1340}
1341
1342impl Drop for ObserveGuard<'_> {
1343    fn drop(&mut self) {
1344        if let Some((outcome, latency, bytes)) = self.outcome.take() {
1345            self.limiter.observe_with_timing_in_epoch(
1346                outcome,
1347                latency,
1348                bytes,
1349                self.started,
1350                Some(self.epoch),
1351            );
1352        }
1353    }
1354}
1355
1356/// Helper for instrumented call sites: time an async op, classify the
1357/// result, and report to a `Limiter`. Returns the original result.
1358///
1359/// ## Cancellation safety
1360///
1361/// Uses an internal `ObserveGuard` so the recorded outcome is
1362/// committed via `Drop` after the inner future returns. If the
1363/// wrapper future is itself dropped before `op().await` resolves
1364/// (caller cancellation, `buffer_unordered` fail-fast), no outcome
1365/// is recorded — this is intentional, see the guard's docs.
1366///
1367/// ```ignore
1368/// let res = observe_op(&controller.store, || async { do_put().await }, classify_put_err).await;
1369/// ```
1370pub async fn observe_op<T, E, F, Fut, C>(limiter: &Limiter, op: F, classify: C) -> Result<T, E>
1371where
1372    F: FnOnce() -> Fut,
1373    Fut: std::future::Future<Output = Result<T, E>>,
1374    C: FnOnce(&E) -> Outcome,
1375{
1376    let mut guard = ObserveGuard::new(limiter);
1377    let result = op().await;
1378    let outcome = match &result {
1379        Ok(_) => Outcome::Success,
1380        Err(e) => classify(e),
1381    };
1382    guard.finish(outcome);
1383    drop(guard); // commit observation explicitly so it lands before return
1384    result
1385}
1386
1387/// Byte-aware variant of [`observe_op`] for fetch paths. The success
1388/// byte extractor is called only for `Ok` results; errors still carry
1389/// zero bytes and are classified by the provided function.
1390pub async fn observe_op_with_success_bytes<T, E, F, Fut, C, B>(
1391    limiter: &Limiter,
1392    op: F,
1393    classify: C,
1394    success_bytes: B,
1395) -> Result<T, E>
1396where
1397    F: FnOnce() -> Fut,
1398    Fut: std::future::Future<Output = Result<T, E>>,
1399    C: FnOnce(&E) -> Outcome,
1400    B: FnOnce(&T) -> u64,
1401{
1402    let mut guard = ObserveGuard::new(limiter);
1403    let result = op().await;
1404    match &result {
1405        Ok(value) => guard.finish_with_bytes(Outcome::Success, success_bytes(value)),
1406        Err(e) => guard.finish_with_bytes(classify(e), 0),
1407    }
1408    drop(guard);
1409    result
1410}
1411
1412/// Process an iterator of items with a rolling scheduler whose cap
1413/// is re-read from the limiter as each slot frees. Replaces the
1414/// "snapshot the cap once at pipeline build" behavior of plain
1415/// `buffer_unordered(N)` so a long pipeline (e.g. 10 GB download =
1416/// ~2500 chunks) sees adaptive growth/decay mid-flight.
1417///
1418/// Output is unordered (first-completion). For an ordered result
1419/// (e.g. `data_download` feeds chunks in DataMap order to
1420/// self_encryption decrypt), wrap items with their index and sort
1421/// after collection — see `rebucketed_ordered`.
1422///
1423/// On error: in-flight work drains to completion (so observed
1424/// outcomes still feed the controller) but no new launches happen.
1425/// The first error is preserved; later errors are discarded.
1426pub async fn rebucketed_unordered<I, T, E, F, Fut>(
1427    limiter: &Limiter,
1428    items: I,
1429    mut op: F,
1430) -> Result<Vec<T>, E>
1431where
1432    I: IntoIterator,
1433    F: FnMut(I::Item) -> Fut,
1434    Fut: std::future::Future<Output = Result<T, E>>,
1435{
1436    let mut iter = items.into_iter().peekable();
1437    let mut in_flight: FuturesUnordered<Fut> = FuturesUnordered::new();
1438    let mut results = Vec::new();
1439    let mut pending_err: Option<E> = None;
1440    loop {
1441        // Refill: re-read the cap and launch up to `cap - in_flight.len()`
1442        // new items, but only if we are not already in error-stop.
1443        if pending_err.is_none() {
1444            let cap = limiter.current().max(1);
1445            while in_flight.len() < cap {
1446                match iter.next() {
1447                    Some(item) => in_flight.push(op(item)),
1448                    None => break,
1449                }
1450            }
1451        }
1452        if in_flight.is_empty() {
1453            break;
1454        }
1455        match in_flight.next().await {
1456            Some(Ok(v)) => results.push(v),
1457            Some(Err(e)) => {
1458                if pending_err.is_none() {
1459                    pending_err = Some(e);
1460                }
1461            }
1462            None => break,
1463        }
1464    }
1465    match pending_err {
1466        Some(e) => Err(e),
1467        None => Ok(results),
1468    }
1469}
1470
1471/// Ordered variant: items are tagged with a usize index by the
1472/// caller (typically by `iter.enumerate()`); after rolling
1473/// completion, results are sorted by index so output preserves
1474/// input order. Use this for callers that pass to APIs which
1475/// consume positionally (e.g. self_encryption's
1476/// `get_root_data_map_parallel` zips `Vec<(idx, Bytes)>` with input
1477/// hashes positionally and discards the idx — without a final sort
1478/// the bytes pair with the wrong hashes).
1479///
1480/// `op` is `FnMut(Item) -> Fut` where `Item` carries whatever
1481/// payload the caller needs; the closure must return
1482/// `Result<(usize, U), E>` so the wrapper can sort by the index.
1483pub async fn rebucketed_ordered<I, U, E, F, Fut>(
1484    limiter: &Limiter,
1485    items: I,
1486    op: F,
1487) -> Result<Vec<U>, E>
1488where
1489    I: IntoIterator,
1490    F: FnMut(I::Item) -> Fut,
1491    Fut: std::future::Future<Output = Result<(usize, U), E>>,
1492{
1493    let mut indexed = rebucketed_unordered(limiter, items, op).await?;
1494    indexed.sort_by_key(|(idx, _)| *idx);
1495    Ok(indexed.into_iter().map(|(_, v)| v).collect())
1496}
1497
1498/// Backward-compatible wrapper. `ordered = false` -> rolling
1499/// unordered. `ordered = true` -> the OLD batch-fence ordered path
1500/// (kept for tests that explicitly assert batch-fence semantics).
1501/// New call sites should use `rebucketed_unordered` or
1502/// `rebucketed_ordered` directly.
1503pub async fn rebucketed<I, T, E, F, Fut>(
1504    limiter: &Limiter,
1505    items: I,
1506    ordered: bool,
1507    mut op: F,
1508) -> Result<Vec<T>, E>
1509where
1510    I: IntoIterator,
1511    F: FnMut(I::Item) -> Fut,
1512    Fut: std::future::Future<Output = Result<T, E>>,
1513{
1514    if !ordered {
1515        return rebucketed_unordered(limiter, items, op).await;
1516    }
1517    let mut iter = items.into_iter();
1518    let mut results = Vec::new();
1519    let mut pending_err: Option<E> = None;
1520    loop {
1521        if pending_err.is_some() {
1522            break;
1523        }
1524        let cap = limiter.current().max(1);
1525        let mut batch = Vec::with_capacity(cap);
1526        for item in iter.by_ref().take(cap) {
1527            batch.push(op(item));
1528        }
1529        if batch.is_empty() {
1530            break;
1531        }
1532        let mut s = stream::iter(batch).buffered(cap);
1533        while let Some(r) = s.next().await {
1534            match r {
1535                Ok(v) => results.push(v),
1536                Err(e) => {
1537                    if pending_err.is_none() {
1538                        pending_err = Some(e);
1539                    }
1540                }
1541            }
1542        }
1543    }
1544    match pending_err {
1545        Some(e) => Err(e),
1546        None => Ok(results),
1547    }
1548}
1549
1550/// On-disk shape for the persisted adaptive state. Versioned so we
1551/// can evolve the controller without crashing on stale files — an
1552/// unknown future schema version simply causes a silent fallback to
1553/// cold defaults.
1554#[cfg(feature = "native")]
1555#[derive(Debug, Clone, Serialize, Deserialize)]
1556struct PersistedState {
1557    schema: u32,
1558    channels: ChannelStart,
1559}
1560
1561#[cfg(feature = "native")]
1562const PERSIST_SCHEMA: u32 = 2;
1563#[cfg(feature = "native")]
1564const PERSIST_SCHEMA_AIMD_FETCH: u32 = 1;
1565#[cfg(feature = "native")]
1566const PERSIST_FILENAME: &str = "client_adaptive.json";
1567
1568/// Default persistence path: `<data_dir>/client_adaptive.json`. Falls
1569/// back to `None` if the platform data dir is not resolvable; in that
1570/// case the controller still works, it just won't persist.
1571#[must_use]
1572#[cfg(feature = "native")]
1573pub fn default_persist_path() -> Option<PathBuf> {
1574    crate::config::data_dir()
1575        .ok()
1576        .map(|d| d.join(PERSIST_FILENAME))
1577}
1578
1579/// Load a persisted snapshot from disk, returning `None` if the file
1580/// does not exist, is unreadable, contains malformed JSON, or has a
1581/// schema version this build does not understand. Persistence is best
1582/// effort — never propagate errors that would block the user's
1583/// operation.
1584#[must_use]
1585#[cfg(feature = "native")]
1586pub fn load_snapshot(path: &Path) -> Option<ChannelStart> {
1587    let bytes = std::fs::read(path).ok()?;
1588    let state: PersistedState = match serde_json::from_slice(&bytes) {
1589        Ok(s) => s,
1590        Err(e) => {
1591            warn!(path = %path.display(), error = %e, "adaptive: corrupt snapshot, ignoring");
1592            return None;
1593        }
1594    };
1595    match state.schema {
1596        PERSIST_SCHEMA => Some(state.channels),
1597        PERSIST_SCHEMA_AIMD_FETCH => {
1598            debug!(
1599                path = %path.display(),
1600                "adaptive: migrating schema-1 snapshot, preserving quote/store and resetting fetch",
1601            );
1602            Some(ChannelStart {
1603                fetch: FETCH_COLD_START_CONCURRENCY,
1604                ..state.channels
1605            })
1606        }
1607        schema => {
1608            debug!(
1609                path = %path.display(),
1610                schema,
1611                expected = PERSIST_SCHEMA,
1612                "adaptive: snapshot schema mismatch, ignoring",
1613            );
1614            None
1615        }
1616    }
1617}
1618
1619/// Save a snapshot to disk atomically (write to `<path>.tmp`, then
1620/// rename). Best effort — failures are logged at warn and discarded.
1621#[cfg(feature = "native")]
1622pub fn save_snapshot(path: &Path, channels: ChannelStart) {
1623    let state = PersistedState {
1624        schema: PERSIST_SCHEMA,
1625        channels,
1626    };
1627    let bytes = match serde_json::to_vec_pretty(&state) {
1628        Ok(b) => b,
1629        Err(e) => {
1630            warn!(error = %e, "adaptive: snapshot serialize failed");
1631            return;
1632        }
1633    };
1634    if let Some(parent) = path.parent() {
1635        if let Err(e) = std::fs::create_dir_all(parent) {
1636            warn!(path = %parent.display(), error = %e, "adaptive: snapshot mkdir failed");
1637            return;
1638        }
1639    }
1640    // Unique-per-save temp filename: PID + monotonic counter +
1641    // nanosecond timestamp guarantees no collision between concurrent
1642    // CLI invocations OR concurrent save_snapshot calls within one
1643    // process (e.g. multiple Client instances sharing the same data
1644    // dir). POSIX rename is atomic on the destination, so the rename
1645    // target overlap is fine — last writer wins.
1646    let nanos = std::time::SystemTime::now()
1647        .duration_since(std::time::UNIX_EPOCH)
1648        .map(|d| d.subsec_nanos())
1649        .unwrap_or(0);
1650    let counter = SAVE_COUNTER.fetch_add(1, Ordering::Relaxed);
1651    let tmp = path.with_extension(format!(
1652        "json.tmp.{}.{}.{}",
1653        std::process::id(),
1654        counter,
1655        nanos
1656    ));
1657    if let Err(e) = std::fs::write(&tmp, &bytes) {
1658        warn!(path = %tmp.display(), error = %e, "adaptive: snapshot write failed");
1659        return;
1660    }
1661    if let Err(e) = std::fs::rename(&tmp, path) {
1662        warn!(
1663            from = %tmp.display(),
1664            to = %path.display(),
1665            error = %e,
1666            "adaptive: snapshot rename failed",
1667        );
1668        // Try to clean up the temp on rename failure so we don't
1669        // leave junk in the data dir. Best effort.
1670        let _ = std::fs::remove_file(&tmp);
1671    }
1672}
1673
1674/// Save with a wall-clock deadline. Spawns the synchronous
1675/// `save_snapshot` on a detached thread and waits up to `timeout`
1676/// for it to finish. If the thread is still running past the
1677/// deadline (e.g. because the data dir is on a hung NFS mount),
1678/// returns without joining — the OS will clean up the thread when
1679/// the process exits.
1680///
1681/// Used by `Client::drop` so a stalled filesystem cannot block
1682/// process shutdown indefinitely.
1683#[cfg(feature = "native")]
1684pub fn save_snapshot_with_timeout(path: PathBuf, channels: ChannelStart, timeout: Duration) {
1685    let handle = std::thread::spawn(move || {
1686        save_snapshot(&path, channels);
1687    });
1688    // Park briefly waiting for the thread, polling its status. We
1689    // use a short polling interval rather than `join()` because
1690    // join() blocks indefinitely.
1691    let started = Instant::now();
1692    let poll = Duration::from_millis(5);
1693    while started.elapsed() < timeout {
1694        if handle.is_finished() {
1695            let _ = handle.join();
1696            return;
1697        }
1698        std::thread::sleep(poll);
1699    }
1700    // Deadline elapsed. Detach the thread; it will continue to run
1701    // in the background until process exit (its work is best-effort
1702    // anyway). Log so operators can see the slow filesystem.
1703    warn!(
1704        timeout_ms = timeout.as_millis() as u64,
1705        "adaptive: snapshot save timed out (data dir slow?); detaching writer thread"
1706    );
1707    drop(handle);
1708}
1709
1710#[cfg(test)]
1711#[allow(clippy::unwrap_used)]
1712mod tests {
1713    use super::*;
1714
1715    const HILL_TEST_START_CAP: usize = 16;
1716    const HILL_TEST_UP_PROBE_CAP: usize = 20;
1717    const HILL_TEST_NEXT_UP_PROBE_CAP: usize = 25;
1718    const HILL_TEST_DOWN_PROBE_CAP: usize = 12;
1719    const HILL_TEST_CHUNK_BYTES: u64 = 1_000;
1720    const HILL_TEST_BASE_LATENCY_MS: u64 = 100;
1721    const HILL_TEST_REJECT_LATENCY_MS: u64 = 130;
1722    const HILL_TEST_RETAINED_DOWN_LATENCY_MS: u64 = 75;
1723    const HILL_TEST_ASYNC_LATENCY_MS: u64 = 10;
1724
1725    fn cfg_for_tests() -> LimiterConfig {
1726        LimiterConfig {
1727            enabled: true,
1728            min_concurrency: 1,
1729            max_concurrency: 64,
1730            window_ops: 10,
1731            min_window_ops: 5,
1732            success_target: 0.9,
1733            timeout_ceiling: 0.2,
1734            latency_inflation_factor: 2.0,
1735            latency_ewma_alpha: 0.5,
1736            slow_start_ramp_threshold: 0,
1737            latency_decrease_enabled: true,
1738            retain_increase_credit_on_decrease: false,
1739        }
1740    }
1741
1742    fn hill_cfg_for_tests() -> LimiterConfig {
1743        LimiterConfig {
1744            window_ops: 4,
1745            min_window_ops: 2,
1746            max_concurrency: 64,
1747            success_target: 0.9,
1748            timeout_ceiling: 0.2,
1749            ..cfg_for_tests()
1750        }
1751    }
1752
1753    fn fetch_hill_for_tests(start: usize, cfg: LimiterConfig) -> Limiter {
1754        Limiter::new_with_algorithm(start, cfg, LimiterAlgorithm::ThroughputHillClimb)
1755    }
1756
1757    fn observe_hill_success_epoch_with_latency(
1758        limiter: &Limiter,
1759        cfg: &LimiterConfig,
1760        bytes: u64,
1761        latency: Duration,
1762    ) {
1763        let samples = hill_epoch_target_samples(limiter.current(), cfg);
1764        for _ in 0..samples {
1765            limiter.observe_with_bytes(Outcome::Success, latency, bytes);
1766        }
1767    }
1768
1769    fn observe_hill_success_epoch(limiter: &Limiter, cfg: &LimiterConfig, bytes: u64) {
1770        observe_hill_success_epoch_with_latency(
1771            limiter,
1772            cfg,
1773            bytes,
1774            Duration::from_millis(HILL_TEST_BASE_LATENCY_MS),
1775        );
1776    }
1777
1778    /// Build an `AdaptiveConfig` for tests that need to construct a
1779    /// full `AdaptiveController`. Mirrors `cfg_for_tests()` defaults
1780    /// where they overlap, plus per-channel max derived from the same
1781    /// `max_concurrency` value.
1782    fn adaptive_cfg_for_tests() -> AdaptiveConfig {
1783        let l = cfg_for_tests();
1784        AdaptiveConfig {
1785            enabled: l.enabled,
1786            min_concurrency: l.min_concurrency,
1787            max: ChannelMax {
1788                quote: l.max_concurrency,
1789                store: l.max_concurrency,
1790                fetch: l.max_concurrency,
1791            },
1792            window_ops: l.window_ops,
1793            min_window_ops: l.min_window_ops,
1794            success_target: l.success_target,
1795            timeout_ceiling: l.timeout_ceiling,
1796            latency_inflation_factor: l.latency_inflation_factor,
1797            latency_ewma_alpha: l.latency_ewma_alpha,
1798        }
1799    }
1800
1801    #[test]
1802    fn timed_fetch_epochs_require_evidence_and_ignore_previous_caps() {
1803        let limiter = AdaptiveController::default().fetch;
1804        let initial = limiter.observation_epoch();
1805        for _ in 0..7 {
1806            limiter.observe_fetch_in_epoch(
1807                Outcome::Success,
1808                Duration::from_secs(3),
1809                1024 * 1024,
1810                initial,
1811            );
1812        }
1813        assert_eq!(limiter.current(), 4);
1814        limiter.observe_fetch_in_epoch(
1815            Outcome::Success,
1816            Duration::from_secs(3),
1817            1024 * 1024,
1818            initial,
1819        );
1820        assert_eq!(
1821            limiter.current(),
1822            5,
1823            "a slow epoch must learn before 32 completions"
1824        );
1825        let next = limiter.observation_epoch();
1826        assert_ne!(next, initial);
1827        limiter.observe_fetch_in_epoch(Outcome::Timeout, Duration::from_secs(10), 0, initial);
1828        assert_eq!(
1829            lock(&limiter.inner).hill.epoch_samples,
1830            0,
1831            "old work cannot train the new probe"
1832        );
1833        limiter.observe_fetch_in_epoch(Outcome::Success, Duration::from_secs(3), 1024 * 1024, next);
1834        assert_eq!(lock(&limiter.inner).hill.epoch_samples, 1);
1835    }
1836
1837    #[test]
1838    fn fast_fetch_epochs_keep_the_full_sample_window() {
1839        let limiter = AdaptiveController::default().fetch;
1840        for _ in 0..8 {
1841            limiter.observe_with_bytes(Outcome::Success, Duration::from_millis(1), 1024);
1842        }
1843        assert_eq!(limiter.current(), 4);
1844    }
1845
1846    #[test]
1847    fn warm_start_keeps_slow_start_armed_below_protected_threshold() {
1848        // Regression guard for the CLI multi-file pattern: each
1849        // `ant file download` is a fresh process that warm-starts from
1850        // the persisted snapshot. If warm_start exited slow-start, the
1851        // fetch cap could only grow additively from the warm value and
1852        // could never climb back to the ceiling against an intermittent
1853        // Decrease trickle. A protected limiter (threshold == max) that
1854        // warm-starts BELOW the ceiling must keep slow-start armed so it
1855        // doubles back up.
1856        let cfg = LimiterConfig {
1857            max_concurrency: 256,
1858            slow_start_ramp_threshold: 256,
1859            latency_decrease_enabled: false,
1860            ..cfg_for_tests()
1861        };
1862        let l = Limiter::new(64, cfg.clone());
1863        l.warm_start(20);
1864        assert_eq!(l.current(), 20);
1865        // A single healthy window should DOUBLE (slow-start armed),
1866        // proving warm_start did not exit slow-start.
1867        for _ in 0..cfg.window_ops {
1868            l.observe(Outcome::Success, Duration::from_millis(10));
1869        }
1870        assert_eq!(
1871            l.current(),
1872            40,
1873            "protected channel must double after warm_start, not crawl +1",
1874        );
1875
1876        // Default channel (threshold 0): warm_start exits slow-start,
1877        // so the same window only adds 1.
1878        let default_cfg = LimiterConfig {
1879            max_concurrency: 256,
1880            ..cfg_for_tests()
1881        };
1882        let d = Limiter::new(64, default_cfg.clone());
1883        d.warm_start(20);
1884        for _ in 0..default_cfg.window_ops {
1885            d.observe(Outcome::Success, Duration::from_millis(10));
1886        }
1887        assert_eq!(
1888            d.current(),
1889            21,
1890            "default channel must stay additive after warm_start",
1891        );
1892    }
1893
1894    #[test]
1895    fn slow_start_stays_armed_at_ceiling_with_max_threshold() {
1896        // Regression for the "lost protection at the ceiling" bug.
1897        // threshold == usize::MAX (the fetch setting) keeps slow-start
1898        // armed even when a Decrease fires at the ceiling, so the cap
1899        // doubles back. threshold == max_concurrency (the buggy
1900        // setting) would exit slow-start there — `current >= threshold`
1901        // is true at the ceiling — and recover only additively. After
1902        // identical stress-at-ceiling + recovery, the MAX-threshold
1903        // limiter must end strictly higher.
1904        let base = LimiterConfig {
1905            max_concurrency: 256,
1906            latency_decrease_enabled: false,
1907            ..cfg_for_tests()
1908        };
1909        let fixed = Limiter::new(
1910            256,
1911            LimiterConfig {
1912                slow_start_ramp_threshold: usize::MAX,
1913                ..base.clone()
1914            },
1915        );
1916        let buggy = Limiter::new(
1917            256,
1918            LimiterConfig {
1919                slow_start_ramp_threshold: 256,
1920                ..base.clone()
1921            },
1922        );
1923        for l in [&fixed, &buggy] {
1924            for _ in 0..base.window_ops {
1925                l.observe(Outcome::Timeout, Duration::from_millis(10));
1926            }
1927            for _ in 0..(base.window_ops * 10) {
1928                l.observe(Outcome::Success, Duration::from_millis(10));
1929            }
1930        }
1931        assert!(
1932            fixed.current() > buggy.current(),
1933            "MAX-threshold limiter ({}) must out-recover the ceiling-threshold one ({})",
1934            fixed.current(),
1935            buggy.current(),
1936        );
1937    }
1938
1939    #[test]
1940    fn protected_slow_start_recovers_faster_than_additive() {
1941        // After identical stress + recovery, a limiter with slow-start
1942        // protected to the ceiling (fetch behaviour) must end at a
1943        // higher cap than one that exits slow-start on first Decrease
1944        // (quote/store behaviour): doubling outpaces +1-per-window.
1945        let base = LimiterConfig {
1946            max_concurrency: 256,
1947            latency_decrease_enabled: false,
1948            ..cfg_for_tests()
1949        };
1950        let protected = Limiter::new(
1951            64,
1952            LimiterConfig {
1953                slow_start_ramp_threshold: 256,
1954                ..base.clone()
1955            },
1956        );
1957        let unprotected = Limiter::new(
1958            64,
1959            LimiterConfig {
1960                slow_start_ramp_threshold: 0,
1961                ..base.clone()
1962            },
1963        );
1964
1965        // Identical stress: a window of timeouts forces decreases on both.
1966        for l in [&protected, &unprotected] {
1967            for _ in 0..base.window_ops {
1968                l.observe(Outcome::Timeout, Duration::from_millis(10));
1969            }
1970        }
1971        // Identical recovery: a long stretch of healthy windows. The
1972        // protected limiter doubles each window; the unprotected one
1973        // only adds 1.
1974        for l in [&protected, &unprotected] {
1975            for _ in 0..(base.window_ops * 10) {
1976                l.observe(Outcome::Success, Duration::from_millis(10));
1977            }
1978        }
1979        assert!(
1980            protected.current() > unprotected.current(),
1981            "protected slow-start ({}) should recover faster than additive ({})",
1982            protected.current(),
1983            unprotected.current(),
1984        );
1985    }
1986
1987    #[test]
1988    fn latency_decrease_disabled_ignores_p95_inflation() {
1989        // With latency_decrease_enabled=false, a window of successes
1990        // whose p95 latency is far above the baseline must NOT trigger
1991        // a Decrease — only success/timeout rate can. (Fetch disables
1992        // this because chunk_get's observed latency is polluted by
1993        // retry-path variance.)
1994        let cfg = LimiterConfig {
1995            max_concurrency: 256,
1996            slow_start_ramp_threshold: 256,
1997            latency_decrease_enabled: false,
1998            ..cfg_for_tests()
1999        };
2000        let l = Limiter::new(16, cfg.clone());
2001        // Establish a fast baseline.
2002        for _ in 0..cfg.window_ops {
2003            l.observe(Outcome::Success, Duration::from_millis(5));
2004        }
2005        let after_baseline = l.current();
2006        // Now a window of successes with 100x the latency. With the
2007        // latency check disabled this is still a healthy window, so the
2008        // cap must not drop.
2009        for _ in 0..cfg.window_ops {
2010            l.observe(Outcome::Success, Duration::from_millis(500));
2011        }
2012        assert!(
2013            l.current() >= after_baseline,
2014            "latency inflation must not shrink the cap when the check is disabled: {} < {}",
2015            l.current(),
2016            after_baseline,
2017        );
2018    }
2019
2020    #[test]
2021    fn controller_sets_fetch_channel_download_tuning() {
2022        // AdaptiveController::new must apply the slow-start /
2023        // latency-decrease tuning to fetch AND store (V2-468), leaving
2024        // quote on classic AIMD.
2025        let c = AdaptiveController::new(ChannelStart::default(), AdaptiveConfig::default());
2026        assert!(
2027            !c.fetch.config.latency_decrease_enabled,
2028            "fetch latency-decrease must be disabled",
2029        );
2030        assert_eq!(
2031            c.fetch.config.slow_start_ramp_threshold,
2032            usize::MAX,
2033            "fetch slow-start must never exit (armed at every cap incl. ceiling)",
2034        );
2035        assert!(
2036            c.quote.config.latency_decrease_enabled,
2037            "quote must keep the latency-decrease check",
2038        );
2039        assert_eq!(
2040            c.quote.config.slow_start_ramp_threshold, 0,
2041            "quote must keep classic AIMD slow-start exit",
2042        );
2043        assert!(
2044            !c.quote.config.retain_increase_credit_on_decrease,
2045            "quote must keep the classic gate (Decrease resets the increase counter)",
2046        );
2047        assert!(
2048            c.store.config.retain_increase_credit_on_decrease,
2049            "store must retain increase credit across a Decrease (V2-554)",
2050        );
2051        assert!(
2052            (c.store.config.success_target - 0.88).abs() < f64::EPSILON,
2053            "store must relax success_target to 0.88 so a few-percent shortfall still ramps (V2-554), got {}",
2054            c.store.config.success_target,
2055        );
2056        assert!(
2057            (c.quote.config.success_target - c.config().success_target).abs() < f64::EPSILON,
2058            "quote must keep the global success_target",
2059        );
2060        // Store now mirrors fetch on these two knobs: node-side merkle
2061        // verification latency is not local congestion, and a transient
2062        // Decrease must not condemn the cap to a +1-per-window crawl.
2063        assert!(
2064            !c.store.config.latency_decrease_enabled,
2065            "store latency-decrease must be disabled (verification variance is not congestion)",
2066        );
2067        assert_eq!(
2068            c.store.config.slow_start_ramp_threshold,
2069            usize::MAX,
2070            "store slow-start must never exit so a transient Decrease re-doubles",
2071        );
2072        // The store floor must stay at the cold-start value — V2-468 does
2073        // NOT change the floor, only the polluted ramp/decrease signals.
2074        assert_eq!(
2075            c.store.current(),
2076            ChannelStart::default().store,
2077            "store cold-start floor must remain unchanged at 8",
2078        );
2079    }
2080
2081    #[test]
2082    fn store_channel_ramps_and_recovers_under_v2_468_tuning() {
2083        // End-to-end on the real `controller.store` limiter: with the
2084        // V2-468 tuning, (a) verification-latency p95 inflation alone must
2085        // not shrink the cap, (b) a genuine timeout burst still cuts it,
2086        // and (c) the cap re-doubles on the next healthy window instead of
2087        // crawling +1 (slow-start stays armed).
2088        let mut adaptive = adaptive_cfg_for_tests();
2089        // Give the store channel real headroom to ramp.
2090        adaptive.max.store = 256;
2091        let c = AdaptiveController::new(
2092            ChannelStart {
2093                quote: 8,
2094                store: 8,
2095                fetch: 8,
2096            },
2097            adaptive,
2098        );
2099        let store = &c.store;
2100        let win = c.config().window_ops;
2101
2102        // (a) Establish a fast baseline, then a window of slow successes
2103        // (the ~28s verification tail). The cap must not drop.
2104        for _ in 0..win {
2105            store.observe(Outcome::Success, Duration::from_millis(5));
2106        }
2107        let after_baseline = store.current();
2108        assert!(after_baseline >= 8, "store should ramp on healthy windows");
2109        for _ in 0..win {
2110            store.observe(Outcome::Success, Duration::from_secs(30));
2111        }
2112        assert!(
2113            store.current() >= after_baseline,
2114            "verification-latency p95 must not shrink store cap: {} < {}",
2115            store.current(),
2116            after_baseline,
2117        );
2118
2119        // (b) A genuine local-congestion timeout burst must still cut it.
2120        let before_stress = store.current();
2121        for _ in 0..win {
2122            store.observe(Outcome::Timeout, Duration::from_millis(50));
2123        }
2124        let after_stress = store.current();
2125        assert!(
2126            after_stress < before_stress,
2127            "timeout-rate breach must still cut the store cap: {after_stress} !< {before_stress}",
2128        );
2129
2130        // (c) Slow-start stays armed, so healthy windows re-DOUBLE the cap
2131        // back to where it was instead of crawling +1 per window. Over this
2132        // many windows additive +1 recovery could not climb back to
2133        // `before_stress` from the stressed floor — only multiplicative
2134        // doubling can — so reaching it proves the crawl pathology is gone.
2135        for _ in 0..(win * 8) {
2136            store.observe(Outcome::Success, Duration::from_millis(5));
2137        }
2138        assert!(
2139            store.current() >= before_stress,
2140            "store must re-double back to {before_stress} after a transient Decrease, got {}",
2141            store.current(),
2142        );
2143    }
2144
2145    #[test]
2146    fn store_application_rejections_do_not_move_cap() {
2147        // The merkle incident's 397 remote app-rejections (now classified
2148        // ApplicationError via `Error::RemotePut`) must not push the store
2149        // cap down — they are not capacity signals.
2150        let mut adaptive = adaptive_cfg_for_tests();
2151        adaptive.max.store = 256;
2152        let c = AdaptiveController::new(
2153            ChannelStart {
2154                quote: 8,
2155                store: 8,
2156                fetch: 8,
2157            },
2158            adaptive,
2159        );
2160        let store = &c.store;
2161        let start = store.current();
2162        for _ in 0..(c.config().window_ops * 5) {
2163            store.observe(Outcome::ApplicationError, Duration::from_secs(30));
2164        }
2165        assert_eq!(
2166            store.current(),
2167            start,
2168            "remote app-rejections must not move the store cap",
2169        );
2170    }
2171
2172    #[test]
2173    fn store_gate_rebalance_ramps_where_classic_gate_pins() {
2174        // V2-554 gate rebalance, end-to-end. Two limiters driven by the SAME
2175        // borderline sequence — a steady ~5% shortfall (1 error per 20
2176        // successes), the normal per-chunk close-group noise floor:
2177        //
2178        // - `fixed`: the store tuning (success_target 0.88 + retain increase
2179        //   credit). A ~5% shortfall reads as a healthy window, so it ramps.
2180        // - `classic`: the pre-V2-554 store gate (success_target 0.95 + reset
2181        //   on Decrease). Just 2 shortfalls in a 32-op window (0.9375) trip a
2182        //   Decrease that fires 4x faster than an Increase can be earned and
2183        //   zeroes the increase credit, so the cap stays pinned at the floor.
2184        let build = |success_target: f64, retain: bool| {
2185            let mut cfg = cfg_for_tests();
2186            cfg.min_concurrency = 1;
2187            cfg.max_concurrency = 256;
2188            cfg.window_ops = 32;
2189            cfg.min_window_ops = 8;
2190            cfg.success_target = success_target;
2191            cfg.timeout_ceiling = 0.10;
2192            // Both stay slow-start-armed with no latency-decrease so the only
2193            // difference under test is the gate (target + retain).
2194            cfg.slow_start_ramp_threshold = usize::MAX;
2195            cfg.latency_decrease_enabled = false;
2196            cfg.retain_increase_credit_on_decrease = retain;
2197            Limiter::new(8, cfg)
2198        };
2199        let fixed = build(0.88, true);
2200        let classic = build(0.95, false);
2201
2202        for _ in 0..80 {
2203            for _ in 0..20 {
2204                fixed.observe(Outcome::Success, Duration::from_millis(5));
2205                classic.observe(Outcome::Success, Duration::from_millis(5));
2206            }
2207            fixed.observe(Outcome::NetworkError, Duration::from_millis(5));
2208            classic.observe(Outcome::NetworkError, Duration::from_millis(5));
2209        }
2210
2211        // The rebalanced gate ramps well off the cold-start floor of 8 under
2212        // the noise floor that pinned the classic gate.
2213        assert!(
2214            fixed.current() >= 32,
2215            "rebalanced store gate must ramp off the floor under ~5% shortfall, got {}",
2216            fixed.current(),
2217        );
2218        // The classic gate stays crushed at/near the floor on the same input —
2219        // this is the V2-554 pathology.
2220        assert!(
2221            classic.current() <= 8,
2222            "classic gate should stay pinned near the floor on the same input, got {}",
2223            classic.current(),
2224        );
2225        assert!(
2226            fixed.current() > classic.current(),
2227            "rebalanced gate {} must out-grow the classic gate {}",
2228            fixed.current(),
2229            classic.current(),
2230        );
2231    }
2232
2233    #[test]
2234    fn cold_start_clamps_into_bounds() {
2235        let cfg = cfg_for_tests();
2236        let l = Limiter::new(1000, cfg.clone());
2237        assert_eq!(l.current(), cfg.max_concurrency);
2238        let l = Limiter::new(0, cfg.clone());
2239        assert_eq!(l.current(), cfg.min_concurrency);
2240    }
2241
2242    #[test]
2243    fn slow_start_doubles_then_caps() {
2244        let cfg = cfg_for_tests();
2245        let l = Limiter::new(2, cfg.clone());
2246        // Feed a full healthy window — concurrency doubles.
2247        for _ in 0..cfg.window_ops {
2248            l.observe(Outcome::Success, Duration::from_millis(50));
2249        }
2250        assert_eq!(l.current(), 4);
2251        for _ in 0..cfg.window_ops {
2252            l.observe(Outcome::Success, Duration::from_millis(50));
2253        }
2254        assert_eq!(l.current(), 8);
2255    }
2256
2257    #[test]
2258    fn first_failure_exits_slow_start() {
2259        let cfg = cfg_for_tests();
2260        let l = Limiter::new(4, cfg.clone());
2261        // 6 successes + 4 timeouts in a window of 10. Decisions fire
2262        // per-sample once the window has min_window_ops entries, so
2263        // the four timeouts each drive Decrease. That floors the cap.
2264        for _ in 0..6 {
2265            l.observe(Outcome::Success, Duration::from_millis(50));
2266        }
2267        for _ in 0..4 {
2268            l.observe(Outcome::Timeout, Duration::from_millis(50));
2269        }
2270        let after_stress = l.current();
2271        assert!(
2272            after_stress < 4,
2273            "stress should reduce concurrency from 4, got {after_stress}",
2274        );
2275        // After exiting slow-start, recovery is +1 per fresh window,
2276        // not doubling. The first `window_ops` successes flush prior
2277        // timeouts out of the sliding window. Decreases now also need
2278        // `min_window_ops` of fresh evidence before re-firing, and
2279        // increases need `window_ops` of fresh evidence. Feed enough
2280        // successes to clear the window AND accumulate evidence for
2281        // multiple increases.
2282        for _ in 0..(cfg.window_ops * 5) {
2283            l.observe(Outcome::Success, Duration::from_millis(50));
2284        }
2285        assert!(
2286            l.current() > after_stress,
2287            "expected recovery above {after_stress}, got {}",
2288            l.current(),
2289        );
2290    }
2291
2292    #[test]
2293    fn floor_holds_at_one() {
2294        let cfg = cfg_for_tests();
2295        let l = Limiter::new(2, cfg);
2296        for _ in 0..30 {
2297            l.observe(Outcome::Timeout, Duration::from_millis(50));
2298        }
2299        assert_eq!(l.current(), 1);
2300    }
2301
2302    #[test]
2303    fn application_errors_do_not_punish() {
2304        let cfg = cfg_for_tests();
2305        let l = Limiter::new(4, cfg.clone());
2306        // ApplicationError is NOT a capacity signal (per `Outcome`
2307        // docs and the reviewer's M1 finding). A wave of e.g.
2308        // `AlreadyStored` errors must not lower concurrency, because
2309        // they say nothing about the network's ability to take more
2310        // load. Specifically: the controller should HOLD at 4 because
2311        // there are zero capacity-relevant samples to act on.
2312        for _ in 0..cfg.window_ops * 5 {
2313            l.observe(Outcome::ApplicationError, Duration::from_millis(50));
2314        }
2315        assert_eq!(
2316            l.current(),
2317            4,
2318            "ApplicationError must not move the cap; got {}",
2319            l.current()
2320        );
2321    }
2322
2323    #[test]
2324    fn latency_inflation_triggers_decrease() {
2325        let cfg = LimiterConfig {
2326            window_ops: 20,
2327            min_window_ops: 5,
2328            ..cfg_for_tests()
2329        };
2330        let l = Limiter::new(4, cfg.clone());
2331        // Establish a baseline with many fast successes.
2332        for _ in 0..cfg.window_ops {
2333            l.observe(Outcome::Success, Duration::from_millis(50));
2334        }
2335        let after_baseline = l.current();
2336        // Now flood with slow successes — same outcome, 5x latency.
2337        for _ in 0..cfg.window_ops {
2338            l.observe(Outcome::Success, Duration::from_millis(500));
2339        }
2340        // Latency inflation > 2x baseline must drop concurrency.
2341        assert!(
2342            l.current() < after_baseline,
2343            "expected decrease from {after_baseline}, got {}",
2344            l.current(),
2345        );
2346    }
2347
2348    #[test]
2349    fn warm_start_overrides_current() {
2350        let cfg = cfg_for_tests();
2351        let l = Limiter::new(2, cfg);
2352        l.warm_start(20);
2353        assert_eq!(l.current(), 20);
2354    }
2355
2356    #[test]
2357    fn warm_start_clamps() {
2358        let cfg = cfg_for_tests();
2359        let l = Limiter::new(2, cfg.clone());
2360        l.warm_start(1_000_000);
2361        assert_eq!(l.current(), cfg.max_concurrency);
2362    }
2363
2364    #[test]
2365    fn disabled_controller_holds_steady() {
2366        let cfg = LimiterConfig {
2367            enabled: false,
2368            ..cfg_for_tests()
2369        };
2370        let l = Limiter::new(8, cfg);
2371        for _ in 0..50 {
2372            l.observe(Outcome::Timeout, Duration::from_millis(50));
2373        }
2374        assert_eq!(l.current(), 8);
2375    }
2376
2377    #[test]
2378    fn controller_snapshot_round_trips() {
2379        // The test cfg has max=64 for every channel (cfg_for_tests's
2380        // max_concurrency=64 -> ChannelMax::{quote: 64, store: 64, fetch: 64}).
2381        // Pick start values <= 64 so they survive cap clamping at
2382        // construction. Pick values >= cold-defaults (32/8/4) so they
2383        // also survive the warm-start floor.
2384        let c = AdaptiveController::new(
2385            ChannelStart {
2386                quote: 64,
2387                store: 16,
2388                fetch: 64,
2389            },
2390            adaptive_cfg_for_tests(),
2391        );
2392        let snap = c.snapshot();
2393        assert_eq!(snap.quote, 64);
2394        assert_eq!(snap.store, 16);
2395        assert_eq!(snap.fetch, 64);
2396
2397        let c2 = AdaptiveController::default();
2398        c2.warm_start(snap);
2399        assert_eq!(c2.quote.current(), 64);
2400        assert_eq!(c2.store.current(), 16);
2401        assert_eq!(c2.fetch.current(), 64);
2402    }
2403
2404    #[tokio::test]
2405    async fn observe_op_records_success() {
2406        let cfg = cfg_for_tests();
2407        let l = Limiter::new(4, cfg.clone());
2408        for _ in 0..cfg.window_ops {
2409            let _: Result<(), &str> =
2410                observe_op(&l, || async { Ok(()) }, |_e: &&str| Outcome::NetworkError).await;
2411        }
2412        // Healthy window from cold start doubles 4 -> 8.
2413        assert_eq!(l.current(), 8);
2414    }
2415
2416    #[test]
2417    fn snapshot_round_trips_through_disk() {
2418        let dir = tempfile::tempdir().unwrap();
2419        let path = dir.path().join("client_adaptive.json");
2420        let snap = ChannelStart {
2421            quote: 24,
2422            store: 6,
2423            fetch: 12,
2424        };
2425        save_snapshot(&path, snap);
2426        let loaded = load_snapshot(&path).unwrap();
2427        assert_eq!(loaded.quote, 24);
2428        assert_eq!(loaded.store, 6);
2429        assert_eq!(loaded.fetch, 12);
2430    }
2431
2432    #[test]
2433    fn load_missing_returns_none() {
2434        let dir = tempfile::tempdir().unwrap();
2435        let path = dir.path().join("does_not_exist.json");
2436        assert!(load_snapshot(&path).is_none());
2437    }
2438
2439    #[test]
2440    fn load_corrupt_returns_none() {
2441        let dir = tempfile::tempdir().unwrap();
2442        let path = dir.path().join("bad.json");
2443        std::fs::write(&path, b"not valid json{{{").unwrap();
2444        assert!(load_snapshot(&path).is_none());
2445    }
2446
2447    #[test]
2448    fn load_wrong_schema_returns_none() {
2449        let dir = tempfile::tempdir().unwrap();
2450        let path = dir.path().join("future.json");
2451        // Schema 999 is from a future build — current build must not
2452        // crash and must not act on it.
2453        let payload = r#"{"schema":999,"channels":{"quote":1,"store":1,"fetch":1}}"#;
2454        std::fs::write(&path, payload).unwrap();
2455        assert!(load_snapshot(&path).is_none());
2456    }
2457
2458    #[test]
2459    fn load_schema_one_preserves_quote_store_and_resets_fetch() {
2460        const LEGACY_QUOTE_CAP: usize = 48;
2461        const LEGACY_STORE_CAP: usize = 24;
2462        const LEGACY_FETCH_CAP: usize = 96;
2463
2464        let dir = tempfile::tempdir().unwrap();
2465        let path = dir.path().join("legacy.json");
2466        let payload = format!(
2467            r#"{{"schema":{},"channels":{{"quote":{},"store":{},"fetch":{}}}}}"#,
2468            PERSIST_SCHEMA_AIMD_FETCH, LEGACY_QUOTE_CAP, LEGACY_STORE_CAP, LEGACY_FETCH_CAP,
2469        );
2470        std::fs::write(&path, payload).unwrap();
2471
2472        let loaded = load_snapshot(&path).unwrap();
2473
2474        assert_eq!(loaded.quote, LEGACY_QUOTE_CAP);
2475        assert_eq!(loaded.store, LEGACY_STORE_CAP);
2476        assert_eq!(loaded.fetch, FETCH_COLD_START_CONCURRENCY);
2477    }
2478
2479    #[tokio::test]
2480    async fn observe_op_records_classified_error() {
2481        let cfg = cfg_for_tests();
2482        let l = Limiter::new(4, cfg.clone());
2483        for _ in 0..cfg.window_ops {
2484            let _: Result<(), &str> =
2485                observe_op(&l, || async { Err("boom") }, |_e: &&str| Outcome::Timeout).await;
2486        }
2487        assert!(l.current() < 4);
2488    }
2489
2490    // ----- Adversarial / regression-guard tests below ---------------------
2491    //
2492    // These exist primarily to prove the controller never silently regresses
2493    // upload/download throughput and never panics under hostile workloads.
2494
2495    /// Cold-start defaults for quote/store must preserve the prior static
2496    /// knobs. Fetch intentionally starts at the validated residential floor
2497    /// because the throughput hill climber now has to prove that higher
2498    /// fan-out improves goodput.
2499    #[test]
2500    fn no_regression_cold_start_at_least_static_defaults() {
2501        let s = ChannelStart::default();
2502        assert!(
2503            s.quote >= 32,
2504            "quote cold-start regressed: got {}, prior static was 32",
2505            s.quote,
2506        );
2507        assert!(
2508            s.store >= 8,
2509            "store cold-start regressed: got {}, prior static was 8",
2510            s.store,
2511        );
2512        assert_eq!(
2513            s.fetch, FETCH_COLD_START_CONCURRENCY,
2514            "fetch cold-start changed unexpectedly: got {}, expected {}",
2515            s.fetch, FETCH_COLD_START_CONCURRENCY,
2516        );
2517    }
2518
2519    /// The production `AdaptiveController::default()` (NOT the test cfg)
2520    /// must come up reporting the cold-start values immediately, with no
2521    /// observations recorded.
2522    #[test]
2523    fn controller_default_config_is_sane() {
2524        let c = AdaptiveController::default();
2525        let starts = ChannelStart::default();
2526        assert_eq!(c.quote.current(), starts.quote);
2527        assert_eq!(c.store.current(), starts.store);
2528        assert_eq!(c.fetch.current(), starts.fetch);
2529        // No observations made yet — internal windows must be empty.
2530        assert_eq!(lock(&c.quote.inner).window.len(), 0);
2531        assert_eq!(lock(&c.store.inner).window.len(), 0);
2532        assert_eq!(lock(&c.fetch.inner).window.len(), 0);
2533    }
2534
2535    /// Mixed signals (every other op fails) must not pin the controller
2536    /// at the floor for the whole run. The cap should oscillate or settle
2537    /// somewhere above the floor — collapse to 1 forever would be a bug.
2538    #[test]
2539    fn alternating_success_failure_collapses_to_floor() {
2540        // 50% timeout rate is far above `timeout_ceiling` (0.2 in test
2541        // config), so the window is always stressed. The controller
2542        // MUST collapse to the floor, and once there must NEVER go
2543        // below it. Assert both invariants explicitly: floor reached
2544        // and floor held.
2545        let cfg = cfg_for_tests();
2546        let l = Limiter::new(8, cfg.clone());
2547        let mut min_observed = usize::MAX;
2548        let mut max_observed = 0usize;
2549        let mut floor_visits = 0usize;
2550        for i in 0..1000 {
2551            let outcome = if i % 2 == 0 {
2552                Outcome::Success
2553            } else {
2554                Outcome::Timeout
2555            };
2556            l.observe(outcome, Duration::from_millis(50));
2557            let cur = l.current();
2558            assert!(
2559                cur >= cfg.min_concurrency,
2560                "cap underflowed floor at iter {i}: got {cur}",
2561            );
2562            min_observed = min_observed.min(cur);
2563            max_observed = max_observed.max(cur);
2564            if cur == cfg.min_concurrency {
2565                floor_visits += 1;
2566            }
2567        }
2568        assert_eq!(
2569            min_observed, cfg.min_concurrency,
2570            "cap never reached the floor under 50% timeout rate"
2571        );
2572        assert!(
2573            max_observed >= 8,
2574            "cap never visited the start value: max_observed={max_observed}"
2575        );
2576        // Should spend MOST of the run at the floor — a single
2577        // healthy window is not enough to climb back from a 50% loss
2578        // environment.
2579        assert!(
2580            floor_visits > 500,
2581            "cap spent only {floor_visits}/1000 ticks at floor; expected mostly at floor"
2582        );
2583        assert_eq!(
2584            l.current(),
2585            cfg.min_concurrency,
2586            "controller did not settle at floor after 1000 alternations"
2587        );
2588    }
2589
2590    /// From the floor, a long stream of healthy successes must walk the
2591    /// cap all the way back up to `max_concurrency`. Otherwise transient
2592    /// stress on a slow link would permanently penalize throughput.
2593    #[test]
2594    fn pure_success_stream_recovers_to_max() {
2595        let cfg = cfg_for_tests();
2596        let l = Limiter::new(cfg.min_concurrency, cfg.clone());
2597        for _ in 0..10_000 {
2598            l.observe(Outcome::Success, Duration::from_millis(5));
2599        }
2600        assert_eq!(
2601            l.current(),
2602            cfg.max_concurrency,
2603            "expected recovery to max ({}), got {}",
2604            cfg.max_concurrency,
2605            l.current(),
2606        );
2607    }
2608
2609    /// Heavy stress drives the cap to the floor; subsequent recovery
2610    /// must climb meaningfully higher than the floor with enough healthy
2611    /// evidence. No "permanent floor" failure mode allowed.
2612    #[test]
2613    fn stress_then_heal_drives_floor_then_recovery() {
2614        let cfg = cfg_for_tests();
2615        let l = Limiter::new(8, cfg.clone());
2616        for _ in 0..100 {
2617            l.observe(Outcome::Timeout, Duration::from_millis(50));
2618        }
2619        let after_stress = l.current();
2620        assert_eq!(
2621            after_stress, cfg.min_concurrency,
2622            "stress should drive cap to floor, got {after_stress}",
2623        );
2624        for _ in 0..1_000 {
2625            l.observe(Outcome::Success, Duration::from_millis(10));
2626        }
2627        let after_heal = l.current();
2628        assert!(
2629            after_heal >= cfg.min_concurrency.saturating_add(4),
2630            "expected substantial recovery from floor, got {after_heal}",
2631        );
2632    }
2633
2634    /// The latency baseline must track actual workload latency. If it
2635    /// stayed pinned at `Duration::ZERO`, every healthy sample would
2636    /// look like infinite inflation and inflate the decrease rate.
2637    #[test]
2638    fn baseline_does_not_grow_unbounded_under_slow_links() {
2639        let cfg = cfg_for_tests();
2640        let l = Limiter::new(2, cfg.clone());
2641        for _ in 0..(cfg.window_ops * 10) {
2642            l.observe(Outcome::Success, Duration::from_millis(500));
2643        }
2644        let baseline = lock(&l.inner).latency_baseline;
2645        let base = baseline.expect("baseline should be set after many healthy windows");
2646        assert!(
2647            base > Duration::ZERO,
2648            "baseline must not stay at ZERO, got {base:?}",
2649        );
2650        // Within 2x of the actual latency: 250ms..=1000ms.
2651        let lo = Duration::from_millis(250);
2652        let hi = Duration::from_millis(1000);
2653        assert!(
2654            base >= lo && base <= hi,
2655            "baseline drifted out of [{lo:?}, {hi:?}]: {base:?}",
2656        );
2657    }
2658
2659    /// Until the first healthy window completes, the latency baseline
2660    /// stays `None` (so no false-inflation alarms). Decreases during the
2661    /// stress phase are driven purely by success/timeout rate, not by
2662    /// inflated p95 vs a phantom zero baseline.
2663    #[test]
2664    fn baseline_initialized_only_after_first_healthy_window() {
2665        let cfg = cfg_for_tests();
2666        let l = Limiter::new(8, cfg.clone());
2667        for _ in 0..50 {
2668            l.observe(Outcome::Timeout, Duration::from_millis(50));
2669        }
2670        // Without any healthy window, baseline must still be None.
2671        assert!(
2672            lock(&l.inner).latency_baseline.is_none(),
2673            "baseline must be None before any healthy window",
2674        );
2675        // Now drain healthy windows.
2676        for _ in 0..(cfg.window_ops * 5) {
2677            l.observe(Outcome::Success, Duration::from_millis(20));
2678        }
2679        let baseline = lock(&l.inner).latency_baseline;
2680        assert!(
2681            baseline.is_some(),
2682            "baseline must be Some after healthy windows",
2683        );
2684        let base = baseline.unwrap_or_default();
2685        assert!(
2686            base > Duration::ZERO,
2687            "baseline must reflect real latency, got {base:?}",
2688        );
2689    }
2690
2691    /// A torrent of timeouts must not underflow the cap. Sample at
2692    /// several depths to catch any wraparound.
2693    #[test]
2694    fn min_concurrency_floor_holds_under_torrent_of_errors() {
2695        let cfg = cfg_for_tests();
2696        let l = Limiter::new(8, cfg.clone());
2697        for i in 0..50_000 {
2698            l.observe(Outcome::Timeout, Duration::from_millis(50));
2699            if i == 100 || i == 1_000 || i == 49_999 {
2700                let cur = l.current();
2701                assert_eq!(
2702                    cur, cfg.min_concurrency,
2703                    "floor breached at iter {i}: got {cur}",
2704                );
2705            }
2706        }
2707    }
2708
2709    /// Mirror: a torrent of successes must not exceed `max_concurrency`.
2710    #[test]
2711    fn max_concurrency_ceiling_holds_under_torrent_of_successes() {
2712        let cfg = cfg_for_tests();
2713        let start = cfg
2714            .max_concurrency
2715            .saturating_sub(1)
2716            .max(cfg.min_concurrency);
2717        let l = Limiter::new(start, cfg.clone());
2718        for i in 0..50_000 {
2719            l.observe(Outcome::Success, Duration::from_millis(5));
2720            if i == 100 || i == 1_000 || i == 49_999 {
2721                let cur = l.current();
2722                assert!(
2723                    cur <= cfg.max_concurrency,
2724                    "ceiling breached at iter {i}: got {cur} > {}",
2725                    cfg.max_concurrency,
2726                );
2727            }
2728        }
2729        assert_eq!(l.current(), cfg.max_concurrency);
2730    }
2731
2732    /// Slow-start doubles the cap; with `max_concurrency = usize::MAX/2`
2733    /// a naive `*2` would overflow. The controller must use saturating
2734    /// arithmetic and never panic. Also asserts the cap actually
2735    /// REACHED max — proving that "no panic" wasn't achieved by
2736    /// the cap getting stuck somewhere instead of growing.
2737    #[test]
2738    fn saturating_arithmetic_handles_extreme_config() {
2739        let cfg = LimiterConfig {
2740            max_concurrency: usize::MAX / 2,
2741            ..cfg_for_tests()
2742        };
2743        let start = usize::MAX / 4;
2744        let l = Limiter::new(start, cfg.clone());
2745        for _ in 0..(cfg.window_ops * 10) {
2746            l.observe(Outcome::Success, Duration::from_millis(1));
2747        }
2748        // First-iteration doubles start (which is max/4) to max/2 = ceiling.
2749        // The cap MUST have grown to the ceiling; if saturating math
2750        // were broken (panic) we'd never get here, but we'd also fail
2751        // if the cap got stuck at the start value.
2752        assert_eq!(
2753            l.current(),
2754            cfg.max_concurrency,
2755            "saturating math survived but cap did not grow to ceiling"
2756        );
2757    }
2758
2759    /// FIFO eviction: prove that a window of pure-timeout collapses
2760    /// the cap, and once enough successes flush ALL timeouts out of
2761    /// the window, the cap can rise. The earlier version of this test
2762    /// used an OR clause that made the assertion satisfiable trivially;
2763    /// this version asserts the strict invariant: after eviction, cap
2764    /// must be STRICTLY GREATER than the post-stress cap.
2765    #[test]
2766    fn window_eviction_is_fifo() {
2767        let cfg = LimiterConfig {
2768            window_ops: 10,
2769            min_window_ops: 5,
2770            success_target: 0.9,
2771            timeout_ceiling: 0.1,
2772            ..cfg_for_tests()
2773        };
2774        let l = Limiter::new(8, cfg.clone());
2775        // Fill the window with timeouts. With decrease-gating
2776        // (samples_since_decrease >= min_window_ops between halvings),
2777        // window_ops=10 + min_window_ops=5 timeouts allow at most
2778        // ~2 halvings: 8 -> 4 -> 2. Cap must DROP from 8.
2779        for _ in 0..cfg.window_ops {
2780            l.observe(Outcome::Timeout, Duration::from_millis(50));
2781        }
2782        let after_stress = l.current();
2783        assert!(
2784            after_stress < 8,
2785            "expected cap to drop from 8 after pure-timeout window, got {after_stress}"
2786        );
2787        // Push enough successes to fully evict the timeouts AND
2788        // accumulate at least one full window of fresh evidence for
2789        // an Increase. window_ops to evict + window_ops to gate first
2790        // +1 = 2 * window_ops minimum; use 3x for safety margin.
2791        for _ in 0..(cfg.window_ops * 3) {
2792            l.observe(Outcome::Success, Duration::from_millis(20));
2793        }
2794        let after_recovery = l.current();
2795        // Strict greater-than: FIFO MUST flush the timeouts so a
2796        // fresh-window Increase can fire.
2797        assert!(
2798            after_recovery > after_stress,
2799            "FIFO eviction broken: cap stayed at {after_stress} after recovery successes (expected > {after_stress}, got {after_recovery})"
2800        );
2801    }
2802
2803    /// With `enabled = false`, the controller is a no-op. Hot paths
2804    /// must see exactly `initial` at every check, no exceptions.
2805    #[test]
2806    fn disabled_controller_returns_initial_value_invariantly() {
2807        let cfg = LimiterConfig {
2808            enabled: false,
2809            ..cfg_for_tests()
2810        };
2811        let initial = 8;
2812        let l = Limiter::new(initial, cfg);
2813        for i in 0..1_000 {
2814            let outcome = match i % 4 {
2815                0 => Outcome::Success,
2816                1 => Outcome::Timeout,
2817                2 => Outcome::NetworkError,
2818                _ => Outcome::ApplicationError,
2819            };
2820            l.observe(outcome, Duration::from_millis(50));
2821            assert_eq!(
2822                l.current(),
2823                initial,
2824                "disabled controller moved at iter {i}",
2825            );
2826        }
2827    }
2828
2829    /// 100 tasks concurrently observing 100 successes each. The cap
2830    /// must remain a valid in-bounds value, no panic, no deadlock.
2831    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2832    async fn concurrent_observations_do_not_corrupt_window() {
2833        let cfg = cfg_for_tests();
2834        let l = Limiter::new(4, cfg.clone());
2835        let mut handles = Vec::with_capacity(100);
2836        for _ in 0..100 {
2837            let l_clone = l.clone();
2838            handles.push(tokio::spawn(async move {
2839                for _ in 0..100 {
2840                    l_clone.observe(Outcome::Success, Duration::from_millis(5));
2841                }
2842            }));
2843        }
2844        for h in handles {
2845            h.await.unwrap();
2846        }
2847        let cur = l.current();
2848        assert!(
2849            cur >= cfg.min_concurrency && cur <= cfg.max_concurrency,
2850            "cap out of bounds after concurrent observations: {cur}",
2851        );
2852    }
2853
2854    /// Persisted higher values from a prior run must beat low cold-start
2855    /// defaults. Otherwise warm-start would silently pessimize throughput.
2856    /// (Values BELOW cold-start are floored — see
2857    /// `warm_start_floors_at_cold_defaults`.)
2858    #[test]
2859    fn persisted_snapshot_warm_starts_above_cold_floor() {
2860        let dir = tempfile::tempdir().unwrap();
2861        let path = dir.path().join("client_adaptive.json");
2862        // All snapshot values ABOVE the production cold-start defaults
2863        // so the warm_start floor doesn't kick in.
2864        let saved = ChannelStart {
2865            quote: 64,
2866            store: 32,
2867            fetch: 128,
2868        };
2869        save_snapshot(&path, saved);
2870        let loaded = load_snapshot(&path).unwrap();
2871
2872        // Build a controller with intentionally low cold-start values
2873        // — these get overridden by warm_start.
2874        let low = ChannelStart {
2875            quote: 2,
2876            store: 2,
2877            fetch: 2,
2878        };
2879        let c = AdaptiveController::new(low, AdaptiveConfig::default());
2880        c.warm_start(loaded);
2881        assert_eq!(c.quote.current(), 64);
2882        assert_eq!(c.store.current(), 32);
2883        assert_eq!(c.fetch.current(), 128);
2884    }
2885
2886    /// Two threads racing on `save_snapshot` must never produce a
2887    /// half-written file. Atomic-rename guarantees we either see the
2888    /// old content or the new content, never a torn write.
2889    #[test]
2890    fn save_load_round_trip_with_concurrent_writes() {
2891        use std::thread;
2892        let dir = tempfile::tempdir().unwrap();
2893        let path = dir.path().join("client_adaptive.json");
2894        let path_a = path.clone();
2895        let path_b = path.clone();
2896        let snap_a = ChannelStart {
2897            quote: 10,
2898            store: 10,
2899            fetch: 10,
2900        };
2901        let snap_b = ChannelStart {
2902            quote: 99,
2903            store: 99,
2904            fetch: 99,
2905        };
2906        let h_a = thread::spawn(move || {
2907            for _ in 0..50 {
2908                save_snapshot(&path_a, snap_a);
2909            }
2910        });
2911        let h_b = thread::spawn(move || {
2912            for _ in 0..50 {
2913                save_snapshot(&path_b, snap_b);
2914            }
2915        });
2916        h_a.join().unwrap();
2917        h_b.join().unwrap();
2918        let loaded = load_snapshot(&path).expect("file must be a valid snapshot, not torn");
2919        let valid = (loaded.quote == snap_a.quote
2920            && loaded.store == snap_a.store
2921            && loaded.fetch == snap_a.fetch)
2922            || (loaded.quote == snap_b.quote
2923                && loaded.store == snap_b.store
2924                && loaded.fetch == snap_b.fetch);
2925        assert!(valid, "loaded snapshot is neither A nor B: {loaded:?}",);
2926    }
2927
2928    /// `save_snapshot` to an unwritable / impossible path must be a
2929    /// quiet no-op: best-effort, no panic, no error propagation.
2930    #[test]
2931    fn save_snapshot_to_unwritable_dir_does_not_panic() {
2932        // A path routed through an existing regular file: create_dir_all
2933        // fails on every platform because a parent component is a file.
2934        // (A path under "/" is NOT impossible everywhere — on Windows it
2935        // resolves to a creatable C:\ directory and littered the drive
2936        // root — V2-1043.)
2937        let blocker = tempfile::NamedTempFile::new().unwrap();
2938        let path = blocker.path().join("sub").join("client_adaptive.json");
2939        let snap = ChannelStart {
2940            quote: 1,
2941            store: 1,
2942            fetch: 1,
2943        };
2944        // No panic = pass. Function returns unit, errors are logged.
2945        save_snapshot(&path, snap);
2946        // File should not exist.
2947        assert!(!path.exists());
2948    }
2949
2950    /// A truncated/partial JSON file must not crash the loader; it must
2951    /// return None so the controller falls back to cold defaults.
2952    #[test]
2953    fn load_snapshot_from_truncated_file_returns_none() {
2954        let dir = tempfile::tempdir().unwrap();
2955        let path = dir.path().join("truncated.json");
2956        std::fs::write(&path, br#"{"schema":1,"channels":{"quote":"#).unwrap();
2957        assert!(load_snapshot(&path).is_none());
2958    }
2959
2960    /// Microbench: 100k observe+current pairs must complete in well
2961    /// under 100ms. Catches any accidental quadratic behaviour or
2962    /// massive lock contention introduced by future changes.
2963    #[test]
2964    fn controller_perf_overhead_is_bounded() {
2965        let cfg = cfg_for_tests();
2966        let l = Limiter::new(8, cfg);
2967        let started = Instant::now();
2968        for _ in 0..100_000 {
2969            let _ = l.current();
2970            l.observe(Outcome::Success, Duration::from_micros(1));
2971        }
2972        let elapsed = started.elapsed();
2973        // 1µs per pair on a modern machine is generous; allow 500ms to
2974        // tolerate slow CI runners while still catching real regressions.
2975        assert!(
2976            elapsed < Duration::from_millis(500),
2977            "100k observe+current pairs took {elapsed:?}, expected <500ms",
2978        );
2979    }
2980
2981    // ---- Regression tests for adversarial-review findings ----
2982
2983    /// M10 fix: hand-edited or future-schema configs may plant `NaN`
2984    /// or out-of-range values into the float fields. Constructing a
2985    /// controller and feeding observations must not panic.
2986    /// `Duration::from_secs_f64(NaN)` panics per std docs, so without
2987    /// `sanitize()` and the EWMA NaN guard this would crash.
2988    #[test]
2989    fn nan_and_out_of_range_config_does_not_panic() {
2990        let cfg = AdaptiveConfig {
2991            enabled: true,
2992            min_concurrency: 0, // sub-floor; sanitize raises to 1
2993            max: ChannelMax {
2994                quote: 0, // sub-min; sanitize raises to min
2995                store: 0,
2996                fetch: 0,
2997            },
2998            window_ops: 10,
2999            min_window_ops: 50, // > window_ops; sanitize clamps
3000            success_target: f64::NAN,
3001            timeout_ceiling: f64::INFINITY,
3002            latency_inflation_factor: f64::NEG_INFINITY,
3003            latency_ewma_alpha: f64::NAN,
3004        };
3005        let c = AdaptiveController::new(ChannelStart::default(), cfg);
3006        // Verify sanitize() ACTUALLY corrected the values (not just
3007        // that no panic occurred). Reading c.config back proves the
3008        // sanitization landed.
3009        let post = &c.config;
3010        assert_eq!(
3011            post.min_concurrency, 1,
3012            "sanitize did not raise min_concurrency from 0"
3013        );
3014        assert!(
3015            post.success_target.is_finite() && (0.0..=1.0).contains(&post.success_target),
3016            "sanitize did not clamp success_target from NaN: {}",
3017            post.success_target
3018        );
3019        assert!(
3020            post.timeout_ceiling.is_finite() && (0.0..=1.0).contains(&post.timeout_ceiling),
3021            "sanitize did not clamp timeout_ceiling from Inf: {}",
3022            post.timeout_ceiling
3023        );
3024        assert!(
3025            post.latency_inflation_factor.is_finite() && post.latency_inflation_factor > 0.0,
3026            "sanitize did not fix latency_inflation_factor from -Inf: {}",
3027            post.latency_inflation_factor
3028        );
3029        assert!(
3030            post.latency_ewma_alpha.is_finite() && (0.0..=1.0).contains(&post.latency_ewma_alpha),
3031            "sanitize did not fix latency_ewma_alpha from NaN: {}",
3032            post.latency_ewma_alpha
3033        );
3034        assert!(
3035            post.min_window_ops <= post.window_ops,
3036            "sanitize did not clamp min_window_ops <= window_ops: min={} window={}",
3037            post.min_window_ops,
3038            post.window_ops
3039        );
3040        assert!(
3041            post.max.quote >= post.min_concurrency,
3042            "max.quote below min_concurrency"
3043        );
3044        // Now exercise the runtime under hostile latencies — must
3045        // not panic.
3046        for _ in 0..200 {
3047            c.store
3048                .observe(Outcome::Success, Duration::from_secs(99_999));
3049            c.store.observe(Outcome::Timeout, Duration::ZERO);
3050        }
3051        let cur = c.store.current();
3052        assert!(cur >= 1, "cap below floor: {cur}");
3053    }
3054
3055    /// M3+M6 fix: a burst of N concurrent in-flight chunks all
3056    /// observing stress at almost the same time used to pile-drive
3057    /// the cap from N to 1 in N back-to-back ticks. After the fix,
3058    /// decreases require `min_window_ops` of FRESH evidence between
3059    /// successive Decreases, so a single transient burst can drop
3060    /// the cap by at most one halving.
3061    #[test]
3062    fn transient_burst_does_not_pile_drive_to_floor() {
3063        let cfg = LimiterConfig {
3064            window_ops: 32,
3065            min_window_ops: 8,
3066            success_target: 0.95,
3067            timeout_ceiling: 0.10,
3068            ..cfg_for_tests()
3069        };
3070        let l = Limiter::new(32, cfg);
3071        // Simulate 8 concurrent ops all completing as Timeout in a
3072        // back-to-back burst (the kind of event that previously
3073        // floor-slammed the cap).
3074        for _ in 0..8 {
3075            l.observe(Outcome::Timeout, Duration::from_millis(10));
3076        }
3077        // After one burst, cap should have decreased AT MOST once
3078        // (32 -> 16). Pile-driving would land at 1 or 2.
3079        let after_burst = l.current();
3080        assert!(
3081            after_burst >= 16,
3082            "transient burst pile-drove cap from 32 to {after_burst}; expected >= 16",
3083        );
3084    }
3085
3086    /// M2 fix: classifier must map transport-related errors to
3087    /// `NetworkError`, not `ApplicationError`. Test EACH transport
3088    /// variant separately so a regression in any one variant is
3089    /// caught by name.
3090    #[tokio::test]
3091    async fn transport_errors_classify_as_capacity_signal() {
3092        use crate::data::client::classify_error;
3093        use crate::data::error::Error;
3094        let make_cfg = || LimiterConfig {
3095            window_ops: 16,
3096            min_window_ops: 5,
3097            success_target: 0.5,
3098            timeout_ceiling: 0.5,
3099            ..cfg_for_tests()
3100        };
3101        // Cases: (variant_name, error_factory)
3102        type ErrFactory = Box<dyn Fn() -> Error>;
3103        let cases: Vec<(&str, ErrFactory)> = vec![
3104            ("Network", Box::new(|| Error::Network("net".to_string()))),
3105            (
3106                "InsufficientPeers",
3107                Box::new(|| Error::InsufficientPeers("ip".to_string())),
3108            ),
3109            ("Io", Box::new(|| Error::Io(std::io::Error::other("io")))),
3110            ("Protocol", Box::new(|| Error::Protocol("p".to_string()))),
3111            ("Storage", Box::new(|| Error::Storage("s".to_string()))),
3112            (
3113                "PartialUpload",
3114                Box::new(|| Error::PartialUpload {
3115                    stored: vec![],
3116                    stored_count: 0,
3117                    failed: vec![],
3118                    failed_count: 0,
3119                    total_chunks: 0,
3120                    spend: Box::new(crate::data::error::PartialUploadSpend {
3121                        storage_cost_atto: "0".to_string(),
3122                        gas_cost_wei: 0,
3123                    }),
3124                    reason: "r".to_string(),
3125                }),
3126            ),
3127        ];
3128        for (name, mk) in &cases {
3129            let l = Limiter::new(8, make_cfg());
3130            for _ in 0..16 {
3131                let _: std::result::Result<(), Error> =
3132                    observe_op(&l, || async { Err(mk()) }, classify_error).await;
3133            }
3134            // Each variant alone must drive the cap STRICTLY below
3135            // the start (8 -> 4 via one halving). If a variant maps
3136            // to ApplicationError, cap stays at 8.
3137            let cur = l.current();
3138            assert!(
3139                cur < 8,
3140                "{name} not classified as capacity signal: cap stayed at {cur}",
3141            );
3142        }
3143    }
3144
3145    /// C4 fix: per-channel max ceilings. Confirm that a `LimiterConfig`
3146    /// with a constrained `max_concurrency` does not bleed into other
3147    /// channels. The ceilings are independent.
3148    #[test]
3149    fn per_channel_ceilings_are_independent() {
3150        let cfg = AdaptiveConfig {
3151            max: ChannelMax {
3152                quote: 4,    // tightly capped
3153                store: 8,    // moderate
3154                fetch: 1024, // very high
3155            },
3156            ..AdaptiveConfig::default()
3157        };
3158        let c = AdaptiveController::new(
3159            ChannelStart {
3160                quote: 4,
3161                store: 8,
3162                fetch: 64,
3163            },
3164            cfg,
3165        );
3166        // Feed 1000 successes to each channel; each must respect its
3167        // own ceiling and never one another's.
3168        for _ in 0..1000 {
3169            c.quote.observe(Outcome::Success, Duration::from_micros(10));
3170            c.store.observe(Outcome::Success, Duration::from_micros(10));
3171            c.fetch.observe(Outcome::Success, Duration::from_micros(10));
3172        }
3173        assert_eq!(c.quote.current(), 4, "quote should cap at 4");
3174        assert_eq!(c.store.current(), 8, "store should cap at 8");
3175        // Fetch uses the hill climber now, so it should not blindly jump to
3176        // its max on success-only samples. It still must prove the fetch
3177        // ceiling is independent by climbing above the quote/store caps.
3178        assert!(
3179            c.fetch.current() > 8 && c.fetch.current() <= 1024,
3180            "fetch did not use its independent ceiling; got {}",
3181            c.fetch.current()
3182        );
3183    }
3184
3185    #[test]
3186    fn fetch_hill_rejects_upward_probe_without_goodput_gain() {
3187        let cfg = hill_cfg_for_tests();
3188        let l = fetch_hill_for_tests(HILL_TEST_START_CAP, cfg.clone());
3189
3190        observe_hill_success_epoch(&l, &cfg, HILL_TEST_CHUNK_BYTES);
3191        assert_eq!(
3192            l.current(),
3193            HILL_TEST_UP_PROBE_CAP,
3194            "first healthy epoch should probe upward"
3195        );
3196
3197        observe_hill_success_epoch_with_latency(
3198            &l,
3199            &cfg,
3200            HILL_TEST_CHUNK_BYTES,
3201            Duration::from_millis(HILL_TEST_REJECT_LATENCY_MS),
3202        );
3203        assert_eq!(
3204            l.current(),
3205            HILL_TEST_START_CAP,
3206            "slower higher-cap wave should reject the upward probe"
3207        );
3208        assert_eq!(l.snapshot(), HILL_TEST_START_CAP);
3209    }
3210
3211    #[test]
3212    fn fetch_hill_accepts_upward_probe_with_goodput_gain() {
3213        let cfg = hill_cfg_for_tests();
3214        let l = fetch_hill_for_tests(HILL_TEST_START_CAP, cfg.clone());
3215
3216        observe_hill_success_epoch(&l, &cfg, HILL_TEST_CHUNK_BYTES);
3217        assert_eq!(l.current(), HILL_TEST_UP_PROBE_CAP);
3218
3219        observe_hill_success_epoch(&l, &cfg, HILL_TEST_CHUNK_BYTES);
3220        assert_eq!(
3221            l.snapshot(),
3222            HILL_TEST_UP_PROBE_CAP,
3223            "same-size chunks at same latency should promote the higher cap"
3224        );
3225        assert_eq!(
3226            l.current(),
3227            HILL_TEST_NEXT_UP_PROBE_CAP,
3228            "after accepting an upward probe, hill climber should probe higher"
3229        );
3230    }
3231
3232    #[test]
3233    fn fetch_hill_accepts_lower_probe_when_goodput_is_retained() {
3234        let cfg = hill_cfg_for_tests();
3235        let l = fetch_hill_for_tests(HILL_TEST_START_CAP, cfg.clone());
3236
3237        observe_hill_success_epoch(&l, &cfg, HILL_TEST_CHUNK_BYTES);
3238        observe_hill_success_epoch_with_latency(
3239            &l,
3240            &cfg,
3241            HILL_TEST_CHUNK_BYTES,
3242            Duration::from_millis(HILL_TEST_REJECT_LATENCY_MS),
3243        );
3244        assert_eq!(l.current(), HILL_TEST_START_CAP);
3245
3246        for _ in 0..(HILL_REJECT_COOLDOWN_EPOCHS + HILL_STABLE_PROBE_EPOCHS) {
3247            observe_hill_success_epoch(&l, &cfg, HILL_TEST_CHUNK_BYTES);
3248        }
3249        assert_eq!(
3250            l.current(),
3251            HILL_TEST_DOWN_PROBE_CAP,
3252            "stable best should eventually probe a lower cap"
3253        );
3254
3255        observe_hill_success_epoch_with_latency(
3256            &l,
3257            &cfg,
3258            HILL_TEST_CHUNK_BYTES,
3259            Duration::from_millis(HILL_TEST_RETAINED_DOWN_LATENCY_MS),
3260        );
3261        assert_eq!(
3262            l.snapshot(),
3263            HILL_TEST_DOWN_PROBE_CAP,
3264            "retained goodput at lower concurrency should become the new best"
3265        );
3266    }
3267
3268    #[tokio::test]
3269    async fn fetch_hill_records_constant_size_timed_ops_without_stress() {
3270        let cfg = hill_cfg_for_tests();
3271        let l = fetch_hill_for_tests(HILL_TEST_START_CAP, cfg.clone());
3272        let total_ops = hill_epoch_target_samples(HILL_TEST_START_CAP, &cfg)
3273            + hill_epoch_target_samples(HILL_TEST_UP_PROBE_CAP, &cfg);
3274        let limiter_for_ops = l.clone();
3275
3276        let result: std::result::Result<Vec<()>, ()> =
3277            rebucketed_unordered(&l, 0..total_ops, move |_| {
3278                let limiter = limiter_for_ops.clone();
3279                async move {
3280                    observe_op_with_success_bytes(
3281                        &limiter,
3282                        || async {
3283                            crate::runtime::sleep(Duration::from_millis(
3284                                HILL_TEST_ASYNC_LATENCY_MS,
3285                            ))
3286                            .await;
3287                            Ok::<(), ()>(())
3288                        },
3289                        |_| Outcome::NetworkError,
3290                        |_| HILL_TEST_CHUNK_BYTES,
3291                    )
3292                    .await
3293                }
3294            })
3295            .await;
3296        result.unwrap();
3297
3298        // The timed wrapper records real wall-clock latency. Loaded runners can make the
3299        // wider probe miss the deterministic gain covered by
3300        // `fetch_hill_accepts_upward_probe_with_goodput_gain`, so this test constrains
3301        // the async observation path to a non-stress outcome.
3302        let snapshot = l.snapshot();
3303        assert!(
3304            matches!(snapshot, HILL_TEST_START_CAP | HILL_TEST_UP_PROBE_CAP),
3305            "timed successes should finish at the existing or accepted best cap, got {snapshot}"
3306        );
3307        let current = l.current();
3308        assert!(
3309            matches!(current, HILL_TEST_START_CAP | HILL_TEST_NEXT_UP_PROBE_CAP),
3310            "timed successes should leave the controller unstressed, got {current}"
3311        );
3312    }
3313
3314    #[test]
3315    fn fetch_hill_stress_cuts_before_full_epoch() {
3316        let cfg = LimiterConfig {
3317            window_ops: 8,
3318            min_window_ops: 4,
3319            ..hill_cfg_for_tests()
3320        };
3321        let l = fetch_hill_for_tests(16, cfg.clone());
3322
3323        for _ in 0..cfg.min_window_ops {
3324            l.observe(Outcome::Timeout, Duration::from_millis(10));
3325        }
3326
3327        assert_eq!(
3328            l.current(),
3329            8,
3330            "fetch hill climber should halve on early stress"
3331        );
3332    }
3333
3334    /// Quote/store cold-starts preserve prior static defaults. Fetch starts
3335    /// at the new conservative hill-climb default to avoid download
3336    /// overshoot on fresh installs.
3337    #[test]
3338    fn cold_start_at_least_prior_static_defaults() {
3339        let cs = ChannelStart::default();
3340        assert!(cs.quote >= 32, "quote cold-start regressed: {}", cs.quote);
3341        assert!(cs.store >= 8, "store cold-start regressed: {}", cs.store);
3342        assert_eq!(
3343            cs.fetch, FETCH_COLD_START_CONCURRENCY,
3344            "fetch cold-start changed unexpectedly"
3345        );
3346    }
3347
3348    /// Reviewer N-M5 guard: with the new gated-decrease semantics
3349    /// (decreases require `min_window_ops` of fresh evidence), the
3350    /// controller must STILL reach the floor under sustained stress
3351    /// within a bounded number of observations. Otherwise we've made
3352    /// the controller too sluggish to react to a real network
3353    /// outage.
3354    ///
3355    /// From start = 64 with `min_window_ops = 8`, reaching floor 1
3356    /// takes log2(64) = 6 halvings, each gated on 8 fresh samples,
3357    /// so the upper bound is roughly `6 * 8 + min_window_ops = ~56`
3358    /// observations. We allow 200 to absorb the warm-up window and
3359    /// any per-sample evaluation slack.
3360    #[test]
3361    fn sustained_stress_reaches_floor_within_bounded_ops() {
3362        let cfg = LimiterConfig {
3363            window_ops: 32,
3364            min_window_ops: 8,
3365            success_target: 0.95,
3366            timeout_ceiling: 0.10,
3367            max_concurrency: 64,
3368            ..cfg_for_tests()
3369        };
3370        let l = Limiter::new(64, cfg);
3371        let mut ops = 0usize;
3372        while l.current() > 1 && ops < 200 {
3373            l.observe(Outcome::Timeout, Duration::from_millis(10));
3374            ops += 1;
3375        }
3376        assert_eq!(
3377            l.current(),
3378            1,
3379            "controller did not reach floor within 200 observations under \
3380             sustained timeout stress; took {ops} ops, ended at cap {}",
3381            l.current()
3382        );
3383    }
3384
3385    /// The default `AdaptiveController` (production defaults) starts
3386    /// each channel at the documented cold-start value, with each
3387    /// per-channel max strictly above the start (so the controller
3388    /// has room to grow).
3389    #[test]
3390    fn default_controller_has_growth_headroom() {
3391        let c = AdaptiveController::default();
3392        let cs = ChannelStart::default();
3393        let max = ChannelMax::default();
3394        assert_eq!(c.quote.current(), cs.quote);
3395        assert_eq!(c.store.current(), cs.store);
3396        assert_eq!(c.fetch.current(), cs.fetch);
3397        assert!(
3398            max.quote > cs.quote,
3399            "no growth headroom for quote: max={} start={}",
3400            max.quote,
3401            cs.quote
3402        );
3403        assert!(
3404            max.store > cs.store,
3405            "no growth headroom for store: max={} start={}",
3406            max.store,
3407            cs.store
3408        );
3409        assert!(
3410            max.fetch > cs.fetch,
3411            "no growth headroom for fetch: max={} start={}",
3412            max.fetch,
3413            cs.fetch
3414        );
3415    }
3416
3417    // ---- Codex review (round 3) regression tests ----
3418
3419    /// Codex CRITICAL: warm_start was blindly restoring caps below the
3420    /// cold-start floor. A prior bad run that drove store=1 would
3421    /// pessimize every subsequent run forever. The fix floors warm
3422    /// values at `ChannelStart::default()` per channel.
3423    #[test]
3424    fn warm_start_floors_at_cold_defaults() {
3425        let c = AdaptiveController::default();
3426        let cold = ChannelStart::default();
3427        // Snapshot from a "bad prior run" — every channel pinned to 1.
3428        let bad_snap = ChannelStart {
3429            quote: 1,
3430            store: 1,
3431            fetch: 1,
3432        };
3433        c.warm_start(bad_snap);
3434        // After warm_start, each channel should be AT LEAST the
3435        // cold-start value, not the persisted 1.
3436        assert_eq!(
3437            c.quote.current(),
3438            cold.quote,
3439            "quote warm_start did not floor at cold default"
3440        );
3441        assert_eq!(
3442            c.store.current(),
3443            cold.store,
3444            "store warm_start did not floor at cold default"
3445        );
3446        assert_eq!(
3447            c.fetch.current(),
3448            cold.fetch,
3449            "fetch warm_start did not floor at cold default"
3450        );
3451    }
3452
3453    /// Warm values ABOVE the cold-start floor must still be honored —
3454    /// the floor is a one-sided lower bound, not a clamp.
3455    #[test]
3456    fn warm_start_honors_values_above_cold_floor() {
3457        let c = AdaptiveController::default();
3458        let cold = ChannelStart::default();
3459        let snap = ChannelStart {
3460            quote: cold.quote * 2,
3461            store: cold.store * 4,
3462            fetch: cold.fetch * 2,
3463        };
3464        c.warm_start(snap);
3465        assert_eq!(c.quote.current(), snap.quote);
3466        assert_eq!(c.store.current(), snap.store);
3467        assert_eq!(c.fetch.current(), snap.fetch);
3468    }
3469
3470    /// Codex MAJOR: long pipelines used to capture the cap once via
3471    /// `buffer_unordered(N)`. `rebucketed` re-reads the cap at each
3472    /// batch boundary so adaptive growth/decay actually takes effect
3473    /// mid-stream. Test: fire 200 items at start cap=4, then halfway
3474    /// through bump the cap manually via warm_start to 16, and assert
3475    /// the LATER batches see the new cap.
3476    #[tokio::test]
3477    async fn rebucketed_picks_up_cap_changes_mid_stream() {
3478        use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
3479        use std::sync::Arc as StdArc;
3480        let cfg = LimiterConfig {
3481            min_concurrency: 1,
3482            max_concurrency: 32,
3483            ..cfg_for_tests()
3484        };
3485        let l = Limiter::new(4, cfg);
3486        let max_seen = StdArc::new(AtomicUsize::new(0));
3487        let in_flight = StdArc::new(AtomicUsize::new(0));
3488        let processed = StdArc::new(AtomicUsize::new(0));
3489        let l_for_bump = l.clone();
3490        let processed_for_bump = processed.clone();
3491        // Spawn a watcher that bumps the cap once enough items have
3492        // started to "warm up".
3493        let bump_handle = tokio::spawn(async move {
3494            loop {
3495                crate::runtime::sleep(Duration::from_millis(2)).await;
3496                if processed_for_bump.load(AtomicOrdering::Relaxed) >= 16 {
3497                    l_for_bump.warm_start(16);
3498                    return;
3499                }
3500            }
3501        });
3502        let _: Vec<()> = rebucketed(&l, 0..200usize, false, |_i| {
3503            let max_seen = max_seen.clone();
3504            let in_flight = in_flight.clone();
3505            let processed = processed.clone();
3506            async move {
3507                let cur = in_flight.fetch_add(1, AtomicOrdering::Relaxed) + 1;
3508                max_seen.fetch_max(cur, AtomicOrdering::Relaxed);
3509                crate::runtime::sleep(Duration::from_millis(1)).await;
3510                in_flight.fetch_sub(1, AtomicOrdering::Relaxed);
3511                processed.fetch_add(1, AtomicOrdering::Relaxed);
3512                Ok::<(), &'static str>(())
3513            }
3514        })
3515        .await
3516        .unwrap();
3517        bump_handle.await.unwrap();
3518        // The cap was bumped to 16 mid-stream. If rebucketing actually
3519        // picks up cap changes, max_seen should reach above the
3520        // initial 4.
3521        let peak = max_seen.load(AtomicOrdering::Relaxed);
3522        assert!(
3523            peak > 4,
3524            "rebucketed did not pick up the mid-stream cap bump (peak in-flight = {peak})"
3525        );
3526    }
3527
3528    /// Codex MAJOR: `observe_op` cancellation safety. If the wrapper
3529    /// future is dropped before the inner op completes, no outcome is
3530    /// recorded (intentional — dropped work was never observed by
3531    /// the network). This test asserts the contract: completed ops
3532    /// land observations, dropped ops do not corrupt the window.
3533    /// Two-sided: confirms cancellation is a NO-OP, AND confirms
3534    /// post-cancellation observations DO land normally (proving the
3535    /// limiter's internal state was not corrupted).
3536    #[tokio::test]
3537    async fn observe_op_cancellation_drops_silently() {
3538        let cfg = LimiterConfig {
3539            window_ops: 16,
3540            min_window_ops: 4,
3541            ..cfg_for_tests()
3542        };
3543        let l = Limiter::new(4, cfg);
3544        // Build a future that never completes, then drop it before
3545        // awaiting. observe_op must not panic on drop and must not
3546        // record an outcome.
3547        let l_clone = l.clone();
3548        let fut = observe_op(
3549            &l_clone,
3550            || async {
3551                std::future::pending::<()>().await;
3552                Ok::<(), &'static str>(())
3553            },
3554            |_| Outcome::Timeout,
3555        );
3556        drop(fut);
3557        // Cap unchanged: no observation was recorded.
3558        assert_eq!(l.current(), 4, "cancelled op moved the cap");
3559        // Now feed observations that ACTUALLY count as Success (the
3560        // Ok branch of observe_op is always Outcome::Success — the
3561        // classifier only runs on Err). Cold-start at 4 + a full
3562        // window of healthy successes = double to 8.
3563        for _ in 0..16 {
3564            let _: Result<(), &'static str> = observe_op(
3565                &l,
3566                || async { Ok(()) },
3567                // classifier only fires on Err; Ok is always Success
3568                |_| Outcome::NetworkError,
3569            )
3570            .await;
3571        }
3572        // STRICT: cap must have GROWN, not just held. If cancellation
3573        // had corrupted internal counters, slow-start might be stuck.
3574        assert!(
3575            l.current() > 4,
3576            "cap did not grow after 16 successes; controller corrupted by cancellation? cap={}",
3577            l.current(),
3578        );
3579    }
3580
3581    /// Codex MAJOR: Drop persistence must be reliable. The CLI relies
3582    /// on Client::drop firing a synchronous save. If save_snapshot
3583    /// were dispatched via fire-and-forget spawn_blocking, runtime
3584    /// teardown would silently lose the snapshot. This test asserts
3585    /// that calling save_snapshot synchronously from a normal context
3586    /// (not Drop, but the same code path) actually writes.
3587    #[test]
3588    fn save_snapshot_is_synchronous_and_durable() {
3589        let dir = tempfile::tempdir().unwrap();
3590        let path = dir.path().join("client_adaptive.json");
3591        let snap = ChannelStart {
3592            quote: 100,
3593            store: 50,
3594            fetch: 200,
3595        };
3596        save_snapshot(&path, snap);
3597        // The file must exist immediately after save_snapshot returns.
3598        // No async waiting, no spawn_blocking, no eventual consistency.
3599        assert!(
3600            path.exists(),
3601            "save_snapshot did not write file synchronously"
3602        );
3603        let loaded = load_snapshot(&path).unwrap();
3604        assert_eq!(loaded.quote, 100);
3605        assert_eq!(loaded.store, 50);
3606        assert_eq!(loaded.fetch, 200);
3607    }
3608
3609    // ---- Codex round 4 regression tests ----
3610
3611    /// Codex CR-2 fix: warm_start marks the limiter as having
3612    /// already-left-slow-start, so a single healthy window does NOT
3613    /// double the cap (which would be over-aggressive resume from a
3614    /// learned value).
3615    #[tokio::test]
3616    async fn warm_start_disables_slow_start_doubling() {
3617        let cfg = LimiterConfig {
3618            window_ops: 8,
3619            min_window_ops: 4,
3620            success_target: 0.9,
3621            ..cfg_for_tests()
3622        };
3623        let l = Limiter::new(2, cfg.clone());
3624        // Warm-start to a learned value of 16. This must not be
3625        // treated as a fresh slow-start.
3626        l.warm_start(16);
3627        assert_eq!(l.current(), 16);
3628        // One full healthy window: in slow-start would double to 32;
3629        // post-warm-start it should add +1 to 17.
3630        for _ in 0..cfg.window_ops {
3631            l.observe(Outcome::Success, Duration::from_millis(10));
3632        }
3633        assert_eq!(
3634            l.current(),
3635            17,
3636            "warm-start triggered slow-start doubling instead of additive +1"
3637        );
3638    }
3639
3640    /// Codex CR-3 fix: warm_start floors against the per-instance
3641    /// cold-start, NOT the global ChannelStart::default. A controller
3642    /// built with custom low starts must stay faithful to its
3643    /// construction parameters even after warm_start.
3644    #[test]
3645    fn controller_warm_start_floors_at_per_instance_cold_start() {
3646        let custom_cold = ChannelStart {
3647            quote: 2,
3648            store: 1,
3649            fetch: 4,
3650        };
3651        let c = AdaptiveController::new(custom_cold, AdaptiveConfig::default());
3652        // Snapshot below the per-instance cold-start floors at custom values.
3653        c.warm_start(ChannelStart {
3654            quote: 1,
3655            store: 1,
3656            fetch: 1,
3657        });
3658        assert_eq!(c.quote.current(), 2);
3659        assert_eq!(c.store.current(), 1);
3660        assert_eq!(c.fetch.current(), 4);
3661        // Snapshot above the per-instance cold-start uses the snapshot.
3662        c.warm_start(ChannelStart {
3663            quote: 10,
3664            store: 10,
3665            fetch: 10,
3666        });
3667        assert_eq!(c.quote.current(), 10);
3668        assert_eq!(c.store.current(), 10);
3669        assert_eq!(c.fetch.current(), 10);
3670    }
3671
3672    /// Codex CR-3 fix: when adaptive.enabled = false, warm_start is
3673    /// a no-op — fixed-concurrency mode means the user wants exactly
3674    /// the cold start, not a learned value from a prior run.
3675    #[test]
3676    fn warm_start_is_noop_when_adaptive_disabled() {
3677        let cfg = AdaptiveConfig {
3678            enabled: false,
3679            ..AdaptiveConfig::default()
3680        };
3681        let custom_cold = ChannelStart {
3682            quote: 5,
3683            store: 5,
3684            fetch: 5,
3685        };
3686        let c = AdaptiveController::new(custom_cold, cfg);
3687        c.warm_start(ChannelStart {
3688            quote: 100,
3689            store: 100,
3690            fetch: 100,
3691        });
3692        assert_eq!(c.quote.current(), 5, "warm_start moved cap when disabled");
3693        assert_eq!(c.store.current(), 5, "warm_start moved cap when disabled");
3694        assert_eq!(c.fetch.current(), 5, "warm_start moved cap when disabled");
3695    }
3696
3697    /// Codex CR-4 fix: rebucketed_unordered is rolling, not batch-fenced.
3698    /// One slow item must NOT block other items in the same logical
3699    /// "wave" — the in-flight set should refill as fast items complete.
3700    #[tokio::test]
3701    async fn rebucketed_unordered_is_rolling_not_fenced() {
3702        use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
3703        use std::sync::Arc as StdArc;
3704        let cfg = LimiterConfig {
3705            min_concurrency: 1,
3706            max_concurrency: 8,
3707            window_ops: 100,
3708            min_window_ops: 50,
3709            ..cfg_for_tests()
3710        };
3711        let l = Limiter::new(4, cfg);
3712        let in_flight = StdArc::new(AtomicUsize::new(0));
3713        let max_in_flight = StdArc::new(AtomicUsize::new(0));
3714        let started = StdArc::new(AtomicUsize::new(0));
3715        let _: Vec<()> = rebucketed_unordered(&l, 0..20usize, |i| {
3716            let in_flight = in_flight.clone();
3717            let max_in_flight = max_in_flight.clone();
3718            let started = started.clone();
3719            async move {
3720                let cur = in_flight.fetch_add(1, AtomicOrdering::Relaxed) + 1;
3721                max_in_flight.fetch_max(cur, AtomicOrdering::Relaxed);
3722                started.fetch_add(1, AtomicOrdering::Relaxed);
3723                // Item 0 is intentionally slow; items 1..20 are fast.
3724                // In a batch-fenced scheduler, item 0 would gate the
3725                // start of items in the next batch. In a rolling
3726                // scheduler, items 1..N can start as soon as their
3727                // slot frees from a fast completion.
3728                if i == 0 {
3729                    crate::runtime::sleep(Duration::from_millis(50)).await;
3730                } else {
3731                    crate::runtime::sleep(Duration::from_millis(1)).await;
3732                }
3733                in_flight.fetch_sub(1, AtomicOrdering::Relaxed);
3734                Ok::<(), &'static str>(())
3735            }
3736        })
3737        .await
3738        .unwrap();
3739        // All 20 items must have started; in a rolling scheduler the
3740        // peak in-flight should reach at least 4 (the cap).
3741        assert_eq!(started.load(AtomicOrdering::Relaxed), 20);
3742        let peak = max_in_flight.load(AtomicOrdering::Relaxed);
3743        assert!(
3744            peak >= 4,
3745            "rolling scheduler did not fill cap; peak in-flight = {peak}"
3746        );
3747    }
3748
3749    /// Codex CR-4 fix: rebucketed_ordered preserves input order.
3750    #[tokio::test]
3751    async fn rebucketed_ordered_preserves_input_order() {
3752        let cfg = LimiterConfig {
3753            min_concurrency: 1,
3754            max_concurrency: 4,
3755            ..cfg_for_tests()
3756        };
3757        let l = Limiter::new(4, cfg);
3758        let items: Vec<usize> = (0..50).collect();
3759        let result: Vec<usize> = rebucketed_ordered(
3760            &l,
3761            items.iter().copied().enumerate(),
3762            |(idx, v)| async move {
3763                // Reverse-bias delay so out-of-order completion is likely.
3764                let delay = (50 - v) as u64;
3765                crate::runtime::sleep(Duration::from_micros(delay)).await;
3766                Ok::<_, &'static str>((idx, v * 10))
3767            },
3768        )
3769        .await
3770        .unwrap();
3771        assert_eq!(result.len(), 50);
3772        for (i, v) in result.iter().enumerate() {
3773            assert_eq!(*v, i * 10, "out of order at index {i}: got {v}");
3774        }
3775    }
3776
3777    /// Codex CR-1 regression guard (logical, not the actual data path):
3778    /// rebucketed_ordered with a payload of (idx, hash) must always
3779    /// pair the right hash with the right chunk content even under
3780    /// adversarial out-of-order completion.
3781    #[tokio::test]
3782    async fn rebucketed_ordered_pairs_idx_with_payload_correctly() {
3783        let cfg = LimiterConfig {
3784            min_concurrency: 1,
3785            max_concurrency: 8,
3786            ..cfg_for_tests()
3787        };
3788        let l = Limiter::new(8, cfg);
3789        // Each item is (idx, fake_hash). The "fetch" returns
3790        // (idx, content_for_hash). We adversarially out-of-order them
3791        // and assert that the post-sort puts content with the right
3792        // index.
3793        let items: Vec<(usize, u64)> = (0..40).map(|i| (i, 1000u64 + i as u64)).collect();
3794        let result: Vec<u64> = rebucketed_ordered(&l, items, |(idx, hash)| async move {
3795            let delay = (40 - idx) as u64; // reverse delay
3796            crate::runtime::sleep(Duration::from_micros(delay)).await;
3797            // "content_for_hash" derived from the hash.
3798            Ok::<_, &'static str>((idx, hash * 7))
3799        })
3800        .await
3801        .unwrap();
3802        for (i, v) in result.iter().enumerate() {
3803            let expected = (1000 + i as u64) * 7;
3804            assert_eq!(
3805                *v, expected,
3806                "idx {i} paired with wrong content: {v}, expected {expected}"
3807            );
3808        }
3809    }
3810
3811    /// Codex CR-5 fix: snapshot temp file is unique per save call,
3812    /// not just per-PID. Two save_snapshot calls in the SAME process
3813    /// must not collide on the temp file.
3814    #[test]
3815    fn save_snapshot_temp_file_is_unique_per_call() {
3816        let dir = tempfile::tempdir().unwrap();
3817        let path = dir.path().join("client_adaptive.json");
3818        // Fire many saves back-to-back in the same process. Without
3819        // a per-call unique suffix, the temp file would be the same
3820        // for every call (PID is constant), and any partial write +
3821        // crash window would expose the race. We can't simulate the
3822        // exact race in a unit test, but we can confirm no panic and
3823        // the final file is correct after many calls.
3824        for i in 0..100 {
3825            save_snapshot(
3826                &path,
3827                ChannelStart {
3828                    quote: i + 1,
3829                    store: i + 1,
3830                    fetch: i + 1,
3831                },
3832            );
3833        }
3834        let loaded = load_snapshot(&path).unwrap();
3835        assert_eq!(loaded.quote, 100);
3836        assert_eq!(loaded.store, 100);
3837        assert_eq!(loaded.fetch, 100);
3838        // Confirm no leftover .tmp files.
3839        let leftover: Vec<_> = std::fs::read_dir(dir.path())
3840            .unwrap()
3841            .filter_map(|e| e.ok())
3842            .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
3843            .collect();
3844        assert!(
3845            leftover.is_empty(),
3846            "temp files leaked: {:?}",
3847            leftover.iter().map(|e| e.file_name()).collect::<Vec<_>>()
3848        );
3849    }
3850
3851    // ---- Edge case tests ----
3852
3853    /// Edge case: rebucketed_unordered with EMPTY input returns empty
3854    /// Vec immediately, no panic, no work scheduled.
3855    #[tokio::test]
3856    async fn rebucketed_empty_input_returns_empty() {
3857        let cfg = cfg_for_tests();
3858        let l = Limiter::new(4, cfg);
3859        let v: Vec<usize> = rebucketed_unordered(&l, std::iter::empty::<usize>(), |_| async {
3860            Ok::<_, &'static str>(42usize)
3861        })
3862        .await
3863        .unwrap();
3864        assert!(v.is_empty());
3865        let v: Vec<usize> = rebucketed_ordered(
3866            &l,
3867            std::iter::empty::<(usize, ())>(),
3868            |(idx, _)| async move { Ok::<_, &'static str>((idx, 42usize)) },
3869        )
3870        .await
3871        .unwrap();
3872        assert!(v.is_empty());
3873    }
3874
3875    /// Edge case: rebucketed_unordered with EXACTLY cap items.
3876    #[tokio::test]
3877    async fn rebucketed_exactly_cap_items() {
3878        let cfg = LimiterConfig {
3879            min_concurrency: 1,
3880            max_concurrency: 4,
3881            ..cfg_for_tests()
3882        };
3883        let l = Limiter::new(4, cfg);
3884        let v: Vec<usize> =
3885            rebucketed_unordered(
3886                &l,
3887                0..4usize,
3888                |i| async move { Ok::<_, &'static str>(i * 2) },
3889            )
3890            .await
3891            .unwrap();
3892        assert_eq!(v.len(), 4);
3893    }
3894
3895    /// Edge case: rebucketed_unordered preserves the FIRST error and
3896    /// discards subsequent ones, draining in-flight work first.
3897    #[tokio::test]
3898    async fn rebucketed_preserves_first_error() {
3899        use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
3900        use std::sync::Arc as StdArc;
3901        let cfg = LimiterConfig {
3902            min_concurrency: 1,
3903            max_concurrency: 4,
3904            ..cfg_for_tests()
3905        };
3906        let l = Limiter::new(4, cfg);
3907        let started = StdArc::new(AtomicUsize::new(0));
3908        let started_clone = started.clone();
3909        let result: Result<Vec<()>, &'static str> = rebucketed_unordered(&l, 0..20usize, |i| {
3910            let started = started_clone.clone();
3911            async move {
3912                started.fetch_add(1, AtomicOrdering::Relaxed);
3913                if i == 5 {
3914                    // Slight delay so item 6, 7 also start before
3915                    // this error propagates.
3916                    crate::runtime::sleep(Duration::from_micros(100)).await;
3917                    return Err("first error");
3918                }
3919                if i == 10 {
3920                    return Err("second error - should be ignored");
3921                }
3922                crate::runtime::sleep(Duration::from_micros(50)).await;
3923                Ok(())
3924            }
3925        })
3926        .await;
3927        match result {
3928            Err(e) => assert_eq!(e, "first error", "wrong error preserved"),
3929            Ok(_) => panic!("expected error, got ok"),
3930        }
3931        // The first error stops new launches, but in-flight items
3932        // drain. We don't assert exact count (nondeterministic) — only
3933        // that we did not launch ALL 20 items (proving error-stop
3934        // works) and we did launch more than just item 5 (proving
3935        // in-flight drain happens).
3936        let total = started.load(AtomicOrdering::Relaxed);
3937        assert!(
3938            (5..20).contains(&total),
3939            "started count out of range: {total}"
3940        );
3941    }
3942
3943    /// Edge case: limiter with min == max (degenerate single-value).
3944    /// Cap stays at the single value regardless of observations.
3945    #[test]
3946    fn limiter_with_min_equal_max_is_pinned() {
3947        let cfg = LimiterConfig {
3948            min_concurrency: 5,
3949            max_concurrency: 5,
3950            ..cfg_for_tests()
3951        };
3952        let l = Limiter::new(5, cfg);
3953        for _ in 0..1000 {
3954            l.observe(Outcome::Success, Duration::from_millis(1));
3955        }
3956        assert_eq!(l.current(), 5, "cap moved despite min==max");
3957        for _ in 0..1000 {
3958            l.observe(Outcome::Timeout, Duration::from_millis(50));
3959        }
3960        assert_eq!(l.current(), 5, "cap moved despite min==max");
3961    }
3962
3963    /// Direct test of `ewma()` math: alpha = 0 means new value =
3964    /// prev (the baseline never updates from new samples).
3965    #[test]
3966    fn ewma_alpha_zero_returns_prev() {
3967        let prev = Duration::from_millis(100);
3968        let sample = Duration::from_millis(500);
3969        let result = ewma(prev, sample, 0.0);
3970        assert_eq!(result, prev, "alpha=0 must return prev unchanged");
3971    }
3972
3973    /// Direct test of `ewma()` math: alpha = 1 means new value =
3974    /// sample (full overwrite).
3975    #[test]
3976    fn ewma_alpha_one_returns_sample() {
3977        let prev = Duration::from_millis(100);
3978        let sample = Duration::from_millis(500);
3979        let result = ewma(prev, sample, 1.0);
3980        // Allow 1ms of float-conversion slop.
3981        let diff = result.abs_diff(sample);
3982        assert!(
3983            diff <= Duration::from_millis(1),
3984            "alpha=1 should return sample; got {result:?}, expected ~{sample:?}"
3985        );
3986    }
3987
3988    /// Direct test of `ewma()`: alpha = 0.5 should give the midpoint.
3989    #[test]
3990    fn ewma_alpha_half_returns_midpoint() {
3991        let prev = Duration::from_millis(200);
3992        let sample = Duration::from_millis(400);
3993        let result = ewma(prev, sample, 0.5);
3994        let expected = Duration::from_millis(300);
3995        let diff = result.abs_diff(expected);
3996        assert!(
3997            diff <= Duration::from_millis(1),
3998            "alpha=0.5 midpoint: got {result:?}, expected ~{expected:?}"
3999        );
4000    }
4001
4002    /// Direct test of `ewma()`: NaN alpha must NOT panic and must
4003    /// preserve the previous value (defense against
4004    /// `Duration::from_secs_f64(NaN)` panic).
4005    #[test]
4006    fn ewma_nan_alpha_returns_prev() {
4007        let prev = Duration::from_millis(100);
4008        let sample = Duration::from_millis(500);
4009        let result = ewma(prev, sample, f64::NAN);
4010        assert_eq!(result, prev);
4011        let result = ewma(prev, sample, f64::INFINITY);
4012        assert_eq!(result, prev);
4013        let result = ewma(prev, sample, f64::NEG_INFINITY);
4014        assert_eq!(result, prev);
4015    }
4016
4017    /// Out-of-range alpha (e.g. 2.5) must clamp to [0,1] and NOT
4018    /// produce a negative result.
4019    #[test]
4020    fn ewma_clamps_alpha_above_one() {
4021        let prev = Duration::from_millis(100);
4022        let sample = Duration::from_millis(500);
4023        let result = ewma(prev, sample, 2.5);
4024        // Clamped to 1.0 -> should equal sample (~500ms).
4025        assert!(result >= Duration::from_millis(499));
4026        assert!(result <= Duration::from_millis(501));
4027    }
4028
4029    /// Edge case: window contains ONLY ApplicationErrors. Controller
4030    /// must HOLD (not move at all), because there are zero
4031    /// capacity-relevant samples.
4032    #[test]
4033    fn window_full_of_application_errors_does_not_move_cap() {
4034        let cfg = cfg_for_tests();
4035        let l = Limiter::new(8, cfg.clone());
4036        for _ in 0..(cfg.window_ops * 5) {
4037            l.observe(Outcome::ApplicationError, Duration::from_millis(50));
4038        }
4039        assert_eq!(
4040            l.current(),
4041            8,
4042            "cap moved on pure-app-error window; should hold"
4043        );
4044    }
4045
4046    /// Edge case: AdaptiveController with `enabled = false` plus
4047    /// observations does not move and does not interact with the
4048    /// observation window.
4049    #[test]
4050    fn disabled_adaptive_controller_truly_inert() {
4051        let cfg = AdaptiveConfig {
4052            enabled: false,
4053            ..AdaptiveConfig::default()
4054        };
4055        let c = AdaptiveController::new(ChannelStart::default(), cfg);
4056        let baseline_quote = c.quote.current();
4057        let baseline_store = c.store.current();
4058        let baseline_fetch = c.fetch.current();
4059        for _ in 0..10000 {
4060            c.quote.observe(Outcome::Timeout, Duration::from_millis(1));
4061            c.store.observe(Outcome::Timeout, Duration::from_millis(1));
4062            c.fetch.observe(Outcome::Timeout, Duration::from_millis(1));
4063        }
4064        assert_eq!(c.quote.current(), baseline_quote);
4065        assert_eq!(c.store.current(), baseline_store);
4066        assert_eq!(c.fetch.current(), baseline_fetch);
4067    }
4068
4069    /// Edge case: per-channel limiters share NO state. Hammering one
4070    /// channel must not move another. Two-sided: assert store DROPS
4071    /// to the floor (proving observations landed) AND quote/fetch
4072    /// are EXACTLY unchanged (proving zero cross-channel leakage).
4073    #[test]
4074    fn channel_state_is_independent() {
4075        let c = AdaptiveController::default();
4076        let q0 = c.quote.current();
4077        let f0 = c.fetch.current();
4078        let s0 = c.store.current();
4079        for _ in 0..1000 {
4080            c.store.observe(Outcome::Timeout, Duration::from_millis(1));
4081        }
4082        // Strict: store reached the floor (observations landed).
4083        assert_eq!(
4084            c.store.current(),
4085            c.config.min_concurrency,
4086            "store did not reach floor after 1000 timeouts; cap={}",
4087            c.store.current()
4088        );
4089        assert!(c.store.current() < s0, "store cap did not move at all");
4090        // Strict: quote and fetch unchanged.
4091        assert_eq!(c.quote.current(), q0, "quote leaked from store stress");
4092        assert_eq!(c.fetch.current(), f0, "fetch leaked from store stress");
4093    }
4094
4095    // ---- Round-5 test reviewer suggestions ----
4096
4097    /// Direct unit test for `AdaptiveConfig::sanitize`. Verifies that
4098    /// every clamped field is correctly fixed up, not merely that
4099    /// the controller doesn't crash.
4100    #[test]
4101    fn sanitize_corrects_pathological_floats() {
4102        let mut cfg = AdaptiveConfig {
4103            success_target: f64::NAN,
4104            timeout_ceiling: 5.0,
4105            latency_inflation_factor: f64::NEG_INFINITY,
4106            latency_ewma_alpha: 2.5,
4107            window_ops: 4,
4108            min_window_ops: 10,
4109            ..AdaptiveConfig::default()
4110        };
4111        cfg.sanitize();
4112        assert!(cfg.success_target.is_finite());
4113        assert!((0.0..=1.0).contains(&cfg.success_target));
4114        assert!((0.0..=1.0).contains(&cfg.timeout_ceiling));
4115        assert!(cfg.latency_inflation_factor.is_finite());
4116        assert!(cfg.latency_inflation_factor > 0.0);
4117        assert!((0.0..=1.0).contains(&cfg.latency_ewma_alpha));
4118        assert!(
4119            cfg.min_window_ops <= cfg.window_ops,
4120            "min_window_ops {} > window_ops {}",
4121            cfg.min_window_ops,
4122            cfg.window_ops
4123        );
4124    }
4125
4126    /// Snapshot persistence relies on serde for ChannelStart and
4127    /// ChannelMax. A field rename in either type would silently
4128    /// break warm-start across binary upgrades — this test catches
4129    /// that.
4130    #[test]
4131    fn channel_max_serde_round_trips() {
4132        let m = ChannelMax {
4133            quote: 7,
4134            store: 13,
4135            fetch: 200,
4136        };
4137        let json = serde_json::to_string(&m).unwrap();
4138        let back: ChannelMax = serde_json::from_str(&json).unwrap();
4139        assert_eq!(back.quote, 7);
4140        assert_eq!(back.store, 13);
4141        assert_eq!(back.fetch, 200);
4142    }
4143
4144    #[test]
4145    fn channel_start_serde_round_trips() {
4146        let s = ChannelStart {
4147            quote: 11,
4148            store: 22,
4149            fetch: 33,
4150        };
4151        let json = serde_json::to_string(&s).unwrap();
4152        let back: ChannelStart = serde_json::from_str(&json).unwrap();
4153        assert_eq!(back.quote, 11);
4154        assert_eq!(back.store, 22);
4155        assert_eq!(back.fetch, 33);
4156    }
4157
4158    /// Mid-flight cap SHRINKAGE: `rebucketed_picks_up_cap_changes_mid_stream`
4159    /// only proves growth. Overload protection requires the reverse —
4160    /// when the controller halves the cap mid-pipeline, in-flight
4161    /// must respect the new lower cap on the next refill.
4162    #[tokio::test]
4163    async fn rebucketed_honors_cap_shrinkage_mid_stream() {
4164        use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
4165        use std::sync::Arc as StdArc;
4166        let cfg = LimiterConfig {
4167            min_concurrency: 1,
4168            max_concurrency: 16,
4169            ..cfg_for_tests()
4170        };
4171        let l = Limiter::new(16, cfg);
4172        let in_flight = StdArc::new(AtomicUsize::new(0));
4173        let max_after_shrink = StdArc::new(AtomicUsize::new(0));
4174        let processed = StdArc::new(AtomicUsize::new(0));
4175        let shrunk = StdArc::new(std::sync::atomic::AtomicBool::new(false));
4176        let l_for_shrink = l.clone();
4177        let p_for_shrink = processed.clone();
4178        let shrunk_for_shrink = shrunk.clone();
4179        let shrink_handle = tokio::spawn(async move {
4180            // Bump down the cap once 50 items have completed.
4181            loop {
4182                crate::runtime::sleep(Duration::from_millis(2)).await;
4183                if p_for_shrink.load(AtomicOrdering::Relaxed) >= 50 {
4184                    l_for_shrink.warm_start(2);
4185                    shrunk_for_shrink.store(true, AtomicOrdering::Relaxed);
4186                    return;
4187                }
4188            }
4189        });
4190        let _: Vec<()> = rebucketed_unordered(&l, 0..400usize, |_i| {
4191            let in_flight = in_flight.clone();
4192            let max_after_shrink = max_after_shrink.clone();
4193            let processed = processed.clone();
4194            let shrunk = shrunk.clone();
4195            async move {
4196                let cur = in_flight.fetch_add(1, AtomicOrdering::Relaxed) + 1;
4197                if shrunk.load(AtomicOrdering::Relaxed) {
4198                    max_after_shrink.fetch_max(cur, AtomicOrdering::Relaxed);
4199                }
4200                crate::runtime::sleep(Duration::from_millis(1)).await;
4201                in_flight.fetch_sub(1, AtomicOrdering::Relaxed);
4202                processed.fetch_add(1, AtomicOrdering::Relaxed);
4203                Ok::<(), &'static str>(())
4204            }
4205        })
4206        .await
4207        .unwrap();
4208        shrink_handle.await.unwrap();
4209        let peak = max_after_shrink.load(AtomicOrdering::Relaxed);
4210        // After the shrink to cap=2, no NEW launches should put us
4211        // above 2. Already-launched in-flight may still be draining
4212        // briefly, so allow a small overshoot for the natural
4213        // refill-after-completion lag.
4214        assert!(
4215            peak <= 4,
4216            "rebucketed exceeded shrunk cap of 2: peak post-shrink in-flight = {peak}"
4217        );
4218    }
4219
4220    /// Mixed `ApplicationError` + capacity-relevant items in one
4221    /// window. ApplicationError must NOT contribute to the success
4222    /// rate denominator — otherwise a wave with some AppErrors and
4223    /// some healthy successes would falsely look like a stressed
4224    /// window.
4225    #[test]
4226    fn mixed_window_app_errors_with_capacity_signal() {
4227        let cfg = LimiterConfig {
4228            window_ops: 10,
4229            min_window_ops: 5,
4230            timeout_ceiling: 0.2,
4231            success_target: 0.9,
4232            ..cfg_for_tests()
4233        };
4234        // Case 1: 5 AppErrors + 5 Successes. Capacity-relevant
4235        // success_rate = 5/5 = 100%. Cap must NOT decrease (it may
4236        // hold at 8 or grow via slow-start; both prove the AppErrors
4237        // didn't poison the success-rate denominator).
4238        let l = Limiter::new(8, cfg.clone());
4239        for _ in 0..5 {
4240            l.observe(Outcome::ApplicationError, Duration::from_millis(50));
4241        }
4242        for _ in 0..5 {
4243            l.observe(Outcome::Success, Duration::from_millis(50));
4244        }
4245        assert!(
4246            l.current() >= 8,
4247            "AppErrors falsely depressed the success rate; cap dropped from 8 to {}",
4248            l.current()
4249        );
4250        // Case 2: 5 AppErrors + 5 Timeouts. Capacity-relevant
4251        // success_rate = 0/5 = 0%. Cap MUST decrease.
4252        let l2 = Limiter::new(8, cfg);
4253        for _ in 0..5 {
4254            l2.observe(Outcome::ApplicationError, Duration::from_millis(50));
4255        }
4256        for _ in 0..5 {
4257            l2.observe(Outcome::Timeout, Duration::from_millis(50));
4258        }
4259        assert!(
4260            l2.current() < 8,
4261            "all-timeouts (with AppError padding) did not decrease cap; got {}",
4262            l2.current()
4263        );
4264    }
4265
4266    /// Real concurrent torn-read test for save/load. The previous
4267    /// concurrent-write test only reads after both writers join;
4268    /// this version interleaves a reader thread with writers and
4269    /// asserts every successful load returns a coherent (non-torn)
4270    /// snapshot.
4271    #[test]
4272    fn concurrent_save_load_no_torn_reads() {
4273        use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
4274        use std::thread;
4275        let dir = tempfile::tempdir().unwrap();
4276        let path = dir.path().join("snap.json");
4277        // Seed the file so the reader doesn't get a None on first read.
4278        save_snapshot(
4279            &path,
4280            ChannelStart {
4281                quote: 1,
4282                store: 1,
4283                fetch: 1,
4284            },
4285        );
4286        let stop = std::sync::Arc::new(AtomicBool::new(false));
4287        let p_w = path.clone();
4288        let s_w = stop.clone();
4289        let writer = thread::spawn(move || {
4290            let mut i = 1usize;
4291            while !s_w.load(AtomicOrdering::Relaxed) {
4292                save_snapshot(
4293                    &p_w,
4294                    ChannelStart {
4295                        quote: i,
4296                        store: i,
4297                        fetch: i,
4298                    },
4299                );
4300                i = i.wrapping_add(1).max(1);
4301            }
4302        });
4303        let p_r = path.clone();
4304        let reader = thread::spawn(move || {
4305            let mut torn = 0usize;
4306            for _ in 0..2_000 {
4307                if let Some(snap) = load_snapshot(&p_r) {
4308                    // Coherent snapshots have all three channels equal
4309                    // (writer always saves equal values).
4310                    if snap.quote != snap.store || snap.store != snap.fetch {
4311                        torn += 1;
4312                    }
4313                }
4314            }
4315            torn
4316        });
4317        let torn = reader.join().unwrap();
4318        stop.store(true, AtomicOrdering::Relaxed);
4319        writer.join().unwrap();
4320        assert_eq!(
4321            torn, 0,
4322            "observed {torn} torn reads under concurrent writes"
4323        );
4324    }
4325
4326    /// Round-5 follow-up: `save_snapshot_with_timeout` returns
4327    /// promptly even when the underlying write would otherwise hang.
4328    /// Use a path mkdir cannot create — routed through an existing
4329    /// regular file, which fails on every platform (a Unix-root path
4330    /// is creatable on Windows — V2-1043) — to simulate a slow/failing
4331    /// filesystem (mkdir returns Err quickly so this isn't a real hang
4332    /// test, but it confirms the timeout wrapper does not block longer
4333    /// than the deadline on a fast-failing operation either).
4334    #[test]
4335    fn save_with_timeout_returns_promptly_on_fast_failure() {
4336        let blocker = tempfile::NamedTempFile::new().unwrap();
4337        let path = blocker.path().join("snap.json");
4338        let snap = ChannelStart {
4339            quote: 1,
4340            store: 1,
4341            fetch: 1,
4342        };
4343        let started = Instant::now();
4344        save_snapshot_with_timeout(path, snap, Duration::from_secs(5));
4345        let elapsed = started.elapsed();
4346        // Fast-failing mkdir returns immediately. The timeout
4347        // wrapper should not add measurable overhead.
4348        assert!(
4349            elapsed < Duration::from_secs(1),
4350            "save_snapshot_with_timeout took {elapsed:?} on fast-failing path"
4351        );
4352    }
4353
4354    /// Round-5 follow-up: a hung writer thread (simulated by a path
4355    /// the writer never returns from). The wrapper must time out and
4356    /// return without joining; the test must complete near the
4357    /// deadline, not hang.
4358    #[test]
4359    fn save_with_timeout_bounds_wall_time_on_hang() {
4360        // Use a real-but-slow-write simulation: hand the writer a
4361        // path that the OS will accept but with a synthetic delay
4362        // baked into a wrapping thread. Since save_snapshot itself
4363        // does no sleep, we instead test that the timeout wrapper
4364        // exits within deadline + small slack when the inner work
4365        // takes longer than the deadline. We approximate by giving
4366        // the wrapper a deadline shorter than any plausible local
4367        // disk write (1ms is too tight; 0ms is too tight). Use
4368        // 1ms deadline and assert wall time < 100ms — proving the
4369        // wrapper does NOT wait for the writer to actually finish
4370        // (the inner write to a tempdir takes a few ms typically).
4371        let dir = tempfile::tempdir().unwrap();
4372        let path = dir.path().join("snap.json");
4373        let snap = ChannelStart {
4374            quote: 1,
4375            store: 1,
4376            fetch: 1,
4377        };
4378        let started = Instant::now();
4379        // Deadline so short that on most machines the writer is
4380        // still running. The wrapper must NOT wait for it.
4381        save_snapshot_with_timeout(path, snap, Duration::from_micros(1));
4382        let elapsed = started.elapsed();
4383        assert!(
4384            elapsed < Duration::from_millis(200),
4385            "timeout wrapper did not bound wall time: {elapsed:?}"
4386        );
4387    }
4388}