Skip to main content

libfw_client/
tune.rs

1//! Adaptive tuning engine: TCP-style ramp over *real* transfers.
2//!
3//! # Model
4//!
5//! The engine treats each transfer as a slow-start probe: measurements are
6//! taken from the bytes the transfer actually moves (no synthetic probe
7//! requests), and the shared parameter table is ramped up dimension by
8//! dimension — per-file window first, then cross-file concurrency — until the
9//! link saturates, then settled.
10//!
11//! The **chunk size** is not ramped: it follows the measured link
12//! ([`chunk_for_link`]) — about [`TARGET_CHUNK_SPAN_MS`] of the current
13//! throughput, clamped into the server's advertised range and the client's
14//! in-flight budget. That gives a wider/faster link fewer, bigger requests and
15//! a narrow link the advertised minimum, and it applies for the whole
16//! transfer (including after the ramp settles). Sizing it from the link instead
17//! of ramping it is what makes it useful at all: the BDP saturation rule
18//! settles the ramp on the window and concurrency dimensions, so a chunk
19//! dimension in the ramp order would never be reached and the chunk would stay
20//! pinned at the advertised minimum (256 KiB) on every link.
21//!
22//! # Lifetime of a tuning result
23//!
24//! Within one client the settled parameters are reused by the next transfer —
25//! otherwise a folder of many files would re-ramp for each file — and they are
26//! discarded if a transfer fails. Optionally the result is *persisted* through
27//! a [`TuneStore`] (in the browser: `localStorage`) so a page refresh does not
28//! re-ramp: [`TuningEngine::set_cache`] installs the store together with a TTL
29//! (default [`DEFAULT_TUNE_TTL_MS`], `0` disables the cache). Entries are keyed
30//! by origin *and* direction ([`tune_key`]), tagged with the server
31//! capabilities they were measured against, and expire from the moment the
32//! ramp settled — a link measured long ago is re-measured even if it is used
33//! constantly. The native client installs no store and keeps everything in
34//! memory.
35//!
36//! # Design notes
37//!
38//! - **Pure decision logic**: [`ramp_action`], [`choose_auto_level`] and the
39//!   clamp/EWMA helpers are plain functions with table-driven tests. The
40//!   clock is injected (`tick` takes `now_ms`), so native tests never need a
41//!   browser.
42//! - **Shared state**: one [`TuningEngine`] per client, wrapped in
43//!   `Rc<RefCell<_>>` (WASM is single-threaded). All transfers read the
44//!   current params when they schedule work.
45//! - **One-second windows**: [`TuningEngine::tick`] accumulates bytes / RTT
46//!   samples / errors and evaluates exactly one window per second of wall
47//!   clock, so measurements are comparable across links.
48
49use std::cell::RefCell;
50use std::rc::Rc;
51
52use libfw_core::{Capabilities, IntRange};
53use serde::{Deserialize, Serialize};
54use wasm_bindgen::JsValue;
55
56/// Measurement window length (ms).
57pub const TUNE_WINDOW_MS: f64 = 1_000.0;
58/// Throughput growth that justifies raising a dimension (≥ +5%).
59pub const GAIN_THRESHOLD: f64 = 0.05;
60/// RTT inflation (vs the ramping baseline) that signals saturation (> 30%).
61pub const RTT_INFLATE_THRESHOLD: f64 = 0.30;
62/// How far a dimension drops on errors (× 0.5).
63pub const DEGRADE_FACTOR: f64 = 0.5;
64/// EWMA smoothing for RTT samples.
65pub const RTT_EWMA_ALPHA: f64 = 0.25;
66
67// Hard client-side ceilings on every tuned dimension.
68//
69// The server's `/capabilities` is *advisory input from a peer we do not
70// control*: it is fetched before authentication and may be served by a
71// proxy, a misconfigured host or a hostile peer. Tuning must therefore
72// never let an advertised range drive the client past its own hard
73// limits — a `chunkSize` of 1 GiB or a `concurrency` of 10 000 would
74// otherwise make the browser allocate / open far past any sane budget.
75/// Absolute cap on tuned cross-file concurrency.
76pub const HARD_MAX_CONCURRENCY: i64 = 64;
77/// Absolute cap on either per-file in-flight window.
78pub const HARD_MAX_WINDOW: i64 = 64;
79/// Absolute cap on the tuned chunk size (bytes).
80pub const HARD_MAX_CHUNK_SIZE: i64 = 16 * 1024 * 1024;
81/// Absolute cap on tuned in-flight bytes per direction
82/// (`concurrency × window × chunk_size`); the download path must hold the
83/// whole window in its reorder buffer, so this bounds peak memory too.
84/// 256 MiB is high enough to let the built-in caps (4 × 8 × 8 MiB) settle
85/// while still stopping an absurd advertisement from exhausting the tab.
86pub const HARD_MAX_IN_FLIGHT_BYTES: u64 = 256 * 1024 * 1024;
87/// Absolute cap on concurrent in-flight HTTP requests.
88///
89/// The in-flight pool is sized `concurrency × per-file window` so it bounds
90/// parallelism without becoming the bottleneck; this ceiling stops a hostile
91/// advertisement (or a 64 × 64 client config) from opening hundreds of
92/// sockets. Chromium itself multiplexes only ~6 connections per HTTP/1.1
93/// origin, so 64 is effectively "as parallel as the browser can use".
94pub const HARD_MAX_IN_FLIGHT_REQUESTS: usize = 64;
95/// Sample size for the compression-level micro-benchmark.
96///
97/// 64 KiB rather than 256 KiB: the benchmark runs synchronously on the WASM
98/// main thread (no worker threads available), so a large sample would block
99/// the JS event loop for a noticeable period (50 ms+) on slow devices. 64 KiB
100/// is sufficient for zstd's dictionary-learning stage to converge and gives a
101/// representative ratio without the UI stutter. The caller is expected to
102/// pass `sample[..LEVEL_SAMPLE_SIZE.min(sample.len())]`.
103pub const LEVEL_SAMPLE_SIZE: usize = 64 * 1024;
104
105// ---------------------------------------------------------------------------
106// Data model
107// ---------------------------------------------------------------------------
108
109/// Lifecycle of the tuning state machine.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "lowercase")]
112pub enum TunePhase {
113    /// No transfer has run yet in this session.
114    Uninitialized,
115    /// Ramping parameters up from the advertised minimums.
116    Ramping,
117    /// Parameters converged for this session.
118    Settled,
119    /// An error shrank the parameters; waiting for stable windows.
120    Degraded,
121}
122
123/// Smoothed transfer statistics.
124#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct TuneStats {
127    /// EWMA of chunk-request time-to-first-byte (ms).
128    pub rtt_ms: f64,
129    /// Last-window throughput (Mbps).
130    pub mbps: f64,
131}
132
133/// The shared, live parameter table.
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct TuneParams {
137    /// Cross-file fan-out / global in-flight request cap.
138    pub concurrency: usize,
139    /// Per-file upload in-flight chunk window.
140    pub upload_window: usize,
141    /// Per-file download in-flight chunk window.
142    pub download_window: usize,
143    /// Shared transfer chunk size for both uploads and parallel downloads.
144    pub chunk_size: u64,
145    /// zrip level for this transfer's compressed body.
146    pub compress_level: i32,
147}
148
149/// Concurrent HTTP request budget for the shared in-flight pool:
150/// `concurrency` files × the widest per-file window, clamped to
151/// [`HARD_MAX_IN_FLIGHT_REQUESTS`].
152///
153/// The pool exists to *bound* parallelism, not to define it. A pool sized to
154/// `concurrency` alone would make the per-file window a dead letter: right
155/// after an adaptive ramp starts the advertised `concurrency` minimum is 1, so
156/// every chunk serializes and growing the window changes nothing (measured:
157/// 1.1 MiB/s on a 4 MiB/s link with `uploadWindow = 4`).
158pub fn request_budget(concurrency: usize, per_file_window: usize) -> usize {
159    concurrency
160        .max(1)
161        .saturating_mul(per_file_window.max(1))
162        .clamp(1, HARD_MAX_IN_FLIGHT_REQUESTS)
163}
164
165/// Default lifetime of a cached tuning result (1 hour).
166///
167/// A settled parameter set is a property of the *link* the client measured,
168/// so keeping it for a while lets a page reload (or the next visit) skip the
169/// ramp instead of re-probing from the advertised minimums every time. The
170/// TTL is the safety valve: a link that changed in between (a laptop that
171/// moved networks, a mobile client on a different cell) re-measures once the
172/// entry is older than this. `0` disables the cache entirely.
173pub const DEFAULT_TUNE_TTL_MS: u64 = 60 * 60 * 1_000;
174
175/// Version tag inside a cache entry, so a format change invalidates old rows
176/// instead of feeding the engine a layout it cannot read.
177pub const TUNE_CACHE_VERSION: u32 = 1;
178
179/// Where settled tuning results are persisted (the browser's `localStorage`).
180///
181/// Deliberately three tiny methods with **no async/fallible surface**: every
182/// storage failure (private mode, quota, disabled storage) is swallowed by the
183/// implementation, because caching is an optimisation and must never fail a
184/// transfer.
185pub trait TuneStore {
186    /// Read a stored result.
187    fn get(&self, key: &str) -> Option<String>;
188    /// Store (or replace) a result.
189    fn set(&self, key: &str, value: &str);
190    /// Drop a result (a failed transfer invalidates it).
191    fn remove(&self, key: &str);
192}
193
194/// A persisted tuning result for one origin + direction.
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
196pub struct TuneCache {
197    /// [`TUNE_CACHE_VERSION`] at the time of writing.
198    pub v: u32,
199    /// `caps_hash` the parameters were measured against.
200    pub caps_hash: String,
201    /// When the settle happened (epoch ms, same clock as `tick`).
202    pub saved_at_ms: u64,
203    /// The settled parameter table.
204    pub params: TuneParamsCache,
205}
206
207/// The persisted subset of [`TuneParams`].
208///
209/// A separate mirror keeps the stored layout stable against internal field
210/// churn (and keeps `compress_level` out: it is a per-transfer policy, never a
211/// cached measurement).
212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
213pub struct TuneParamsCache {
214    pub concurrency: usize,
215    pub upload_window: usize,
216    pub download_window: usize,
217    pub chunk_size: u64,
218}
219
220impl TuneParamsCache {
221    fn of(params: &TuneParams) -> Self {
222        TuneParamsCache {
223            concurrency: params.concurrency,
224            upload_window: params.upload_window,
225            download_window: params.download_window,
226            chunk_size: params.chunk_size,
227        }
228    }
229
230    fn into_params(self, compress_level: i32) -> TuneParams {
231        TuneParams {
232            concurrency: self.concurrency,
233            upload_window: self.upload_window,
234            download_window: self.download_window,
235            chunk_size: self.chunk_size,
236            compress_level,
237        }
238    }
239}
240
241/// Storage key for one origin + direction.
242///
243/// The origin (scheme://host[:port]) is carried verbatim — it is short, and a
244/// readable key beats a hash when debugging a browser's storage inspector. The
245/// direction is part of the key because the ramped dimensions differ (an
246/// upload settle says nothing about the download window).
247pub fn tune_key(origin: &str, direction: TransferKind) -> String {
248    let dir = match direction {
249        TransferKind::Upload => "upload",
250        TransferKind::Download => "download",
251    };
252    format!("libfw.tune.v{TUNE_CACHE_VERSION}.{dir}.{origin}")
253}
254
255/// `scheme://host[:port]` of a URL, or an empty string when it cannot be told.
256///
257/// Only `http`/`https` are recognised: anything else (or a malformed URL) means
258/// "do not cache" — a wrong cache key is worse than no cache.
259pub fn origin_of(url: &str) -> String {
260    let (scheme, rest) = match url.split_once("://") {
261        Some((s, r)) => (s, r),
262        None => return String::new(),
263    };
264    if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
265        return String::new();
266    }
267    // Authority ends at the first `/`, `?` or `#`.
268    let authority = rest
269        .split(['/', '?', '#'])
270        .next()
271        .unwrap_or("")
272        .trim();
273    if authority.is_empty() {
274        return String::new();
275    }
276    format!("{}://{}", scheme.to_ascii_lowercase(), authority.to_ascii_lowercase())
277}
278
279impl TuneParams {
280    /// Concurrent HTTP requests this parameter set may keep in flight.
281    pub fn request_budget(&self) -> usize {
282        request_budget(self.concurrency, self.upload_window.max(self.download_window))
283    }
284
285    /// The parameter table at the server's advertised minimums (ramp start).
286    pub fn from_caps_mins(caps: &Capabilities) -> TuneParams {
287        TuneParams {
288            concurrency: floor_of(&caps.limits.concurrency, HARD_MAX_CONCURRENCY) as usize,
289            upload_window: floor_of(&caps.limits.upload_window, HARD_MAX_WINDOW) as usize,
290            download_window: floor_of(&caps.limits.download_window, HARD_MAX_WINDOW) as usize,
291            chunk_size: floor_of(&caps.limits.chunk_size, HARD_MAX_CHUNK_SIZE) as u64,
292            compress_level: caps.compression.zrip_levels.min,
293        }
294    }
295
296    /// A static table derived from the client configuration.
297    ///
298    /// The configured values, level-resolved and clamped into the advertised
299    /// caps — the table a transfer runs with while tuning is off.
300    pub fn from_config(
301        concurrency: usize,
302        upload_window: usize,
303        download_window: usize,
304        chunk_size: u64,
305        compress_level: i32,
306        caps: &Capabilities,
307    ) -> TuneParams {
308        TuneParams {
309            concurrency: bounded(
310                concurrency.max(1) as i64,
311                &caps.limits.concurrency,
312                HARD_MAX_CONCURRENCY,
313            ) as usize,
314            upload_window: bounded(
315                upload_window.max(1) as i64,
316                &caps.limits.upload_window,
317                HARD_MAX_WINDOW,
318            ) as usize,
319            download_window: bounded(
320                download_window.max(1) as i64,
321                &caps.limits.download_window,
322                HARD_MAX_WINDOW,
323            ) as usize,
324            chunk_size: bounded(
325                chunk_size.max(1) as i64,
326                &caps.limits.chunk_size,
327                HARD_MAX_CHUNK_SIZE,
328            ) as u64,
329            compress_level: caps.clamp_level(compress_level),
330        }
331    }
332
333    /// Clamp every dimension into the server's advertised ranges (defensive
334    /// against a server that shrank its caps since we cached params).
335    pub fn clamped_into(&self, caps: &Capabilities) -> TuneParams {
336        TuneParams {
337            concurrency: bounded(
338                self.concurrency as i64,
339                &caps.limits.concurrency,
340                HARD_MAX_CONCURRENCY,
341            ) as usize,
342            upload_window: bounded(
343                self.upload_window as i64,
344                &caps.limits.upload_window,
345                HARD_MAX_WINDOW,
346            ) as usize,
347            download_window: bounded(
348                self.download_window as i64,
349                &caps.limits.download_window,
350                HARD_MAX_WINDOW,
351            ) as usize,
352            chunk_size: bounded(
353                self.chunk_size as i64,
354                &caps.limits.chunk_size,
355                HARD_MAX_CHUNK_SIZE,
356            ) as u64,
357            compress_level: caps.clamp_level(self.compress_level),
358        }
359    }
360
361    /// In-flight bytes for an upload: concurrency × window × chunk size.
362    pub fn upload_in_flight(&self) -> u64 {
363        self.concurrency as u64 * self.upload_window as u64 * self.chunk_size
364    }
365
366    /// In-flight bytes for a download: concurrency × window × chunk size.
367    pub fn download_in_flight(&self) -> u64 {
368        self.concurrency as u64 * self.download_window as u64 * self.chunk_size
369    }
370}
371
372/// The advertised `min`, intersected with the client's hard ceiling.
373///
374/// `/capabilities` is untrusted input: a hostile or broken server may
375/// advertise a `min` larger than anything we are willing to allocate, so the
376/// ceiling always wins (and the result is never below 1).
377fn floor_of(range: &IntRange, hard_max: i64) -> i64 {
378    range.min.clamp(1, hard_max.max(1))
379}
380
381/// Clamp `v` into the advertised range **and** the client's hard ceiling.
382///
383/// The advertised bounds are never trusted: `min` may exceed `max` (an
384/// inverted range must not panic the way `i64::clamp` would) and either
385/// bound may be far above what a browser can afford.
386fn bounded(v: i64, range: &IntRange, hard_max: i64) -> i64 {
387    let hard_max = hard_max.max(1);
388    let v = v.max(1);
389    if range.min > range.max {
390        // Empty/inverted advertisement: the range carries no information, so
391        // honour the requested value and only apply our own ceiling.
392        return v.min(hard_max).max(1);
393    }
394    let lo = range.min.clamp(1, hard_max);
395    let hi = range.max.min(hard_max).max(lo);
396    v.clamp(lo, hi)
397}
398
399// ---------------------------------------------------------------------------
400// Pure helpers
401// ---------------------------------------------------------------------------
402
403/// EWMA of `sample` into `prev` (`None` seeds the first sample).
404pub fn ewma(prev: Option<f64>, sample: f64, alpha: f64) -> f64 {
405    match prev {
406        None => sample,
407        Some(p) => alpha * sample + (1.0 - alpha) * p,
408    }
409}
410
411/// Bandwidth-delay product in bytes from Mbps and RTT ms.
412pub fn bdp_bytes(mbps: f64, rtt_ms: f64) -> u64 {
413    (mbps * 1e6 / 8.0 * rtt_ms / 1_000.0).max(0.0) as u64
414}
415
416/// Minimum transfer time one chunk should represent (ms).
417///
418/// One chunk should carry about this much of the link's throughput, so a
419/// wide/faster link gets few big requests (less per-request overhead, better
420/// per-request compression) while a narrow link keeps the advertised minimum
421/// (fine granularity, bounded memory) — 256 KiB even on a 30 Mb/s link.
422///
423/// Deliberately *throughput-only*: an RTT term would make a single chunk as
424/// big as the whole bandwidth-delay product, which immediately satisfies the
425/// ramp's BDP rule and freezes cross-file concurrency at 1 — and uploads
426/// cannot sample an RTT at all (XHR exposes no TTFB).
427pub const TARGET_CHUNK_SPAN_MS: f64 = 100.0;
428
429/// Boundary the derived chunk size is rounded down to (bytes).
430///
431/// Chunks do not *have* to be aligned, but round request sizes keep the wire
432/// log, the SDK panel and the server's block bookkeeping readable — and the
433/// alignment is then re-clamped into the advertised range, so an advertised
434/// minimum that is not a multiple of it still wins.
435const CHUNK_ALIGN: u64 = 64 * 1024;
436
437/// The chunk size a link of this throughput can use, clamped into the
438/// server's advertised range, the client's hard ceiling and the in-flight
439/// memory budget (`budget_max`).
440///
441/// `mbps` is the engine's measured throughput for the current window.
442pub fn chunk_for_link(mbps: f64, range: &IntRange, budget_max: u64) -> u64 {
443    let floor = floor_of(range, HARD_MAX_CHUNK_SIZE).max(1) as u64;
444    if !mbps.is_finite() || mbps <= 0.0 {
445        return floor.min(budget_max.max(1)).max(1);
446    }
447    // bytes = bits/s ÷ 8 × span(s), rounded to a tidy boundary
448    let bytes = (mbps * 1e6 / 8.0 * TARGET_CHUNK_SPAN_MS / 1_000.0).clamp(0.0, i64::MAX as f64) as u64;
449    let aligned = (bytes / CHUNK_ALIGN).saturating_mul(CHUNK_ALIGN);
450    let clamped = bounded(aligned.max(1) as i64, range, HARD_MAX_CHUNK_SIZE) as u64;
451    clamped.clamp(1, budget_max.max(1))
452}
453
454/// The dimension currently being raised.
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum RampDim {
457    /// The active transfer's per-file window (upload or download).
458    Window,
459    /// Cross-file concurrency (global in-flight request cap).
460    Concurrency,
461}
462
463/// What a window evaluation decided to do.
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub enum RampAction {
466    /// Raise the current dimension (×2, or +1 near the cap).
467    Raise,
468    /// Current dimension is capped — move to the next one.
469    AdvanceDim,
470    /// The link is saturated (or stable long enough): settle.
471    Settle,
472    /// An error happened: halve the current dimension, phase → Degraded.
473    Degrade,
474    /// Below the gain threshold but not yet conclusive: hold.
475    Hold,
476}
477
478/// Inputs to the ramp decision (one measurement window).
479#[derive(Debug, Clone, Copy)]
480pub struct RampInput {
481    /// Throughput of the window just closed (Mbps).
482    pub mbps: f64,
483    /// Throughput of the previous window (0 on the first window).
484    pub prev_mbps: f64,
485    /// `rtt_ewma / rtt_baseline - 1` (0 when no baseline exists yet).
486    pub rtt_inflation: f64,
487    /// Errors observed in this window.
488    pub errors: u32,
489    /// Engine is in the post-degrade stability hold.
490    pub degraded: bool,
491    /// The engine is actively ramping (`phase == Ramping`). A settled state
492    /// (parameters reused from the previous transfer of this client) is *not*
493    /// ramping: its first window has no baseline yet and must not be mistaken
494    /// for +100% growth.
495    pub ramping: bool,
496    /// Consecutive windows below the gain threshold (before this one).
497    pub low_gain_windows: u32,
498    /// Current in-flight bytes (concurrency × window × chunk).
499    pub in_flight: u64,
500    /// Bandwidth-delay product estimate (bytes); 0 = unknown.
501    ///
502    /// Only consulted on the cross-file concurrency dimension: on the window
503    /// dimension the estimate is derived from the very throughput that single
504    /// connection achieved, so it would always "prove" that one connection is
505    /// enough and freeze the ramp at its minimum; on the chunk dimension the
506    /// thing being grown is request *count*, not in-flight bytes.
507    pub bdp: u64,
508    /// Whether the current dimension is at the server's max.
509    pub at_cap: bool,
510    /// Whether the current dimension is the last one to ramp.
511    pub last_dim: bool,
512}
513
514/// The pure ramp decision for one measurement window.
515///
516/// Returns `(action, new_low_gain_windows)`.
517///
518/// Rules (in priority order):
519/// 1. any error → [`RampAction::Degrade`];
520/// 2. current dimension capped → advance (or settle at the last one);
521/// 3. cross-file concurrency already ≥ BDP → the pipe is full, settle;
522/// 4. RTT inflated > 30% vs baseline → saturated, settle;
523/// 5. throughput gain ≥ +5% while ramping → raise (a held window, a settled
524///    reuse and the degraded stability hold never register a pseudo-gain);
525/// 6. gain < 5% for 2 consecutive windows → the dimension is exhausted: move
526///    to the next one (settle at the last dimension, or during a degraded
527///    hold);
528/// 7. otherwise hold.
529///
530/// The ramped dimensions are the per-file window and cross-file concurrency;
531/// the chunk size follows the measured link instead ([`chunk_for_link`]).
532pub fn ramp_action(input: &RampInput) -> (RampAction, u32) {
533    if input.errors > 0 {
534        return (RampAction::Degrade, 0);
535    }
536    if input.at_cap {
537        return if input.last_dim {
538            (RampAction::Settle, 0)
539        } else {
540            (RampAction::AdvanceDim, 0)
541        };
542    }
543    if input.bdp > 0 && input.in_flight >= input.bdp {
544        return (RampAction::Settle, 0);
545    }
546    if input.rtt_inflation > RTT_INFLATE_THRESHOLD {
547        return (RampAction::Settle, 0);
548    }
549    let gain = if input.degraded {
550        // Degraded stability hold: the window just saw (near-)zero traffic,
551        // so `prev_mbps` may be 0 — the first-window pseudo-gain below must
552        // not re-raise dimensions we just halved. Hold and settle instead.
553        0.0
554    } else if input.prev_mbps > 0.0 {
555        input.mbps / input.prev_mbps - 1.0
556    } else if input.ramping {
557        // First window of a fresh ramp: no baseline; treat the mere presence
558        // of traffic as growth so the first raise happens immediately.
559        1.0
560    } else {
561        // Settled reuse: no ramp is in flight, so the first window only
562        // establishes the baseline. Inventing +100% growth here would raise a
563        // dimension out of the tuned optimum on every transfer that reuses
564        // the previous settle.
565        0.0
566    };
567    // Only an *active* ramp explores dimensions. A settled engine (a reuse of
568    // the previous transfer's parameters) must not raise a dimension out of
569    // the tuned optimum just because one window happened to be faster; only a
570    // failure re-arms the ramp (`transfer_end(false)`).
571    if input.ramping && gain >= GAIN_THRESHOLD {
572        return (RampAction::Raise, 0);
573    }
574    let low = input.low_gain_windows + 1;
575    if low >= 2 {
576        // Flat throughput means the dimension we were growing is exhausted, not
577        // necessarily the link: a single-file transfer gains nothing from
578        // cross-file concurrency, and a big chunk size can still pay off after
579        // the window stopped helping. So explore the *next* dimension instead
580        // of settling — except at the last dimension, and in the degraded hold
581        // where the conservative settle is the whole point.
582        if input.degraded || input.last_dim {
583            (RampAction::Settle, low)
584        } else {
585            (RampAction::AdvanceDim, 0)
586        }
587    } else {
588        (RampAction::Hold, low)
589    }
590}
591
592/// Client-side compression level configuration.
593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594pub enum CompressLevel {
595    /// Micro-benchmark the advertised range on a real file sample.
596    Auto,
597    /// Cheapest (min) — least CPU.
598    Fast,
599    /// The server's advertised default.
600    Balanced,
601    /// Best ratio (max).
602    Max,
603    /// An explicit level (clamped into the advertised range).
604    Fixed(i32),
605}
606
607/// Resolve a [`CompressLevel`] against the advertised zrip range.
608///
609/// `Auto` without a benchmarkable sample degrades to the server default.
610pub fn resolve_level(level: CompressLevel, caps: &Capabilities) -> i32 {
611    let z = caps.compression.zrip_levels;
612    match level {
613        CompressLevel::Fast => z.min,
614        CompressLevel::Balanced | CompressLevel::Auto => z.default,
615        CompressLevel::Max => z.max,
616        CompressLevel::Fixed(l) => z.clamp_level(l),
617    }
618}
619
620/// Choose the best level from micro-benchmark results.
621///
622/// `candidates`: `(level, compressed_len, compress_ms)` in ascending level
623/// order. Scores each by `saved_transfer_ms - compress_ms`, where
624/// `saved_transfer_ms` converts the saved bytes at the current link speed.
625/// Ties go to the cheaper (lower) level. A sample that compresses to
626/// nothing (already-compressed data) yields the cheapest level so the CPU
627/// is never wasted.
628pub fn choose_auto_level(
629    candidates: &[(i32, usize, f64)],
630    uncompressed: usize,
631    mbps: f64,
632) -> i32 {
633    if candidates.is_empty() {
634        return 0;
635    }
636    // Bytes per ms at the current (or a conservative default) link speed.
637    let bytes_per_ms = if mbps > 0.0 {
638        mbps * 1e6 / 8.0 / 1_000.0
639    } else {
640        // 10 Mbps default: don't over-invest CPU on an unknown link.
641        10e6 / 8.0 / 1_000.0
642    };
643    let max_saved = candidates
644        .iter()
645        .map(|&(_, clen, _)| uncompressed.saturating_sub(clen))
646        .max()
647        .unwrap_or(0);
648    if max_saved == 0 {
649        // Incompressible sample: cheapest level.
650        return candidates.iter().map(|&(l, _, _)| l).min().unwrap_or(0);
651    }
652    let mut best = (candidates[0].0, f64::NEG_INFINITY);
653    for &(lvl, clen, cms) in candidates {
654        let saved = uncompressed.saturating_sub(clen) as f64;
655        let score = saved / bytes_per_ms - cms;
656        // Strict `>`: on ties the earlier (cheaper) candidate wins.
657        if score > best.1 {
658            best = (lvl, score);
659        }
660    }
661    best.0
662}
663
664// ---------------------------------------------------------------------------
665// Engine
666// ---------------------------------------------------------------------------
667
668/// A tuning event emitted when a window evaluation changed the state.
669#[derive(Debug, Clone)]
670pub struct TuneEvent {
671    pub phase: TunePhase,
672    pub params: TuneParams,
673    pub stats: TuneStats,
674}
675
676/// Shared handle to the tuning engine (Rc so clones share one table).
677pub type TuneHandle = Rc<RefCell<TuningEngine>>;
678
679/// The tuning state machine.
680pub struct TuningEngine {
681    enabled: bool,
682    level_cfg: CompressLevel,
683    /// zrip level resolved from the client's policy against the current
684    /// caps — remembered so a mid-transfer re-ramp keeps the policy level
685    /// instead of falling back to the advertised minimum.
686    policy_level: i32,
687    phase: TunePhase,
688    caps: Option<Capabilities>,
689    caps_hash: String,
690    params: TuneParams,
691    stats: TuneStats,
692    /// Cached auto-level result for this session (per-file benchmark).
693    auto_level: Option<i32>,
694    /// Whether the auto level was benchmarked this session.
695    auto_level_benchmarked: bool,
696    // Window accumulator.
697    window_start_ms: f64,
698    window_done_base: u64,
699    window_errors: u32,
700    windows_closed: u32,
701    rtt_ewma: Option<f64>,
702    /// RTT the *current ramp* started from (the link's baseline latency).
703    ///
704    /// Saturation is judged against this fixed reference, not against the
705    /// previous window's EWMA (comparing the EWMA with itself is always 0,
706    /// which would make the RTT-inflation rule unreachable).
707    rtt_baseline: Option<f64>,
708    prev_mbps: f64,
709    low_gain_windows: u32,
710    dim: RampDim,
711    degraded_windows: u32,
712    /// Parameters a transfer of *this* client settled on, reused by the next
713    /// transfer (same direction) so a folder of files does not re-ramp per
714    /// file. Dropped when the caps change or a transfer fails.
715    settled: Option<TuneParams>,
716    /// `caps_hash` the settled parameters were measured against.
717    settled_caps_hash: String,
718    /// Direction the in-memory settle applies to (window dimensions differ per
719    /// direction, so a download must not inherit an upload's settle).
720    settled_direction: Option<TransferKind>,
721    /// Whether we settled from a Degraded hold (not from a full ramp), in
722    /// which case the halved parameter set is not reused as a baseline.
723    post_degrade_settle: bool,
724    /// Which direction is ramping (drives window/chunk dimension selection).
725    direction: TransferKind,
726    /// Persistent cache of settled results (the browser's `localStorage`);
727    /// `None` = in-memory only (e.g. the native client).
728    store: Option<Rc<dyn TuneStore>>,
729    /// How long a cached result stays usable (ms); `0` disables the cache.
730    cache_ttl_ms: u64,
731    /// Origin the cache key is built from (`scheme://host[:port]`; empty =
732    /// caching off, e.g. an unusable base URL).
733    origin: String,
734    // JS callback for tuning events (SDK `onTuning`).
735    on_tuning: Option<js_sys::Function>,
736}
737
738impl TuningEngine {
739    /// Create the engine from client configuration.
740    pub fn new(enabled: bool, level_cfg: CompressLevel) -> TuningEngine {
741        TuningEngine {
742            enabled,
743            level_cfg,
744            policy_level: libfw_core::ZRIP_DEFAULT_LEVEL,
745            phase: TunePhase::Uninitialized,
746            caps: None,
747            caps_hash: String::new(),
748            // Placeholder; replaced on the first begin_transfer.
749            params: TuneParams {
750                concurrency: 1,
751                upload_window: 1,
752                download_window: 1,
753                chunk_size: 1,
754                compress_level: 0,
755            },
756            stats: TuneStats::default(),
757            auto_level: None,
758            auto_level_benchmarked: false,
759            window_start_ms: 0.0,
760            window_done_base: 0,
761            window_errors: 0,
762            windows_closed: 0,
763            rtt_ewma: None,
764            rtt_baseline: None,
765            prev_mbps: 0.0,
766            low_gain_windows: 0,
767            dim: RampDim::Window,
768            degraded_windows: 0,
769            settled: None,
770            settled_caps_hash: String::new(),
771            settled_direction: None,
772            post_degrade_settle: false,
773            direction: TransferKind::Download,
774            store: None,
775            cache_ttl_ms: DEFAULT_TUNE_TTL_MS,
776            origin: String::new(),
777            on_tuning: None,
778        }
779    }
780
781    /// Persist settled results through `store`, for at most `ttl_ms`.
782    ///
783    /// The store is consulted from [`Self::begin_transfer`] (reuse) and written
784    /// on every settle; a failed transfer removes the entry. `ttl_ms == 0`
785    /// disables the cache while keeping the in-memory reuse per client.
786    pub fn set_cache(&mut self, store: Rc<dyn TuneStore>, ttl_ms: u64) {
787        self.store = Some(store);
788        self.cache_ttl_ms = ttl_ms;
789    }
790
791    /// The TTL currently applied to cached results (0 = caching disabled).
792    pub fn cache_ttl_ms(&self) -> u64 {
793        self.cache_ttl_ms
794    }
795
796    /// Record the server base URL the cache key is derived from.
797    pub fn set_origin(&mut self, url: &str) {
798        self.origin = origin_of(url);
799    }
800
801    /// Cache key for the current origin + direction (`None` when caching is
802    /// off: no store, no TTL, or an unusable origin).
803    fn cache_key(&self) -> Option<String> {
804        if self.store.is_none() || self.cache_ttl_ms == 0 || self.origin.is_empty() {
805            return None;
806        }
807        Some(tune_key(&self.origin, self.direction))
808    }
809
810    /// Drop the persisted entry for the current origin + direction.
811    fn remove_cache_entry(&self) {
812        if let (Some(store), Some(key)) = (&self.store, self.cache_key()) {
813            store.remove(&key);
814        }
815    }
816
817    /// Read the persisted settle for the current origin + direction.
818    ///
819    /// Returns `None` — and cleans the entry up — unless it is readable, was
820    /// measured against the *current* capabilities and is younger than the
821    /// TTL. The TTL counts from the moment the ramp settled (not from the last
822    /// reuse), so a link that was measured long ago is re-measured even if it
823    /// is used constantly.
824    fn load_cached_settle(&mut self, now_ms: f64, caps: &Capabilities) -> Option<TuneParams> {
825        let key = self.cache_key()?;
826        let store = self.store.clone()?;
827        let raw = store.get(&key)?;
828        let entry: TuneCache = match serde_json::from_str(&raw) {
829            Ok(entry) => entry,
830            Err(_) => {
831                // Unreadable (a corrupted row or an unrecognized layout): drop it.
832                store.remove(&key);
833                return None;
834            }
835        };
836        let aged = now_ms - entry.saved_at_ms as f64;
837        let fresh = aged >= 0.0 && aged <= self.cache_ttl_ms as f64;
838        if entry.v != TUNE_CACHE_VERSION || entry.caps_hash != caps.caps_hash() || !fresh {
839            store.remove(&key);
840            return None;
841        }
842        Some(entry.params.into_params(self.policy_level))
843    }
844
845    /// Persist the current parameters as this origin + direction's settle.
846    fn save_cached_settle(&self, now_ms: f64) {
847        let Some(key) = self.cache_key() else { return };
848        let Some(store) = self.store.as_ref() else { return };
849        let entry = TuneCache {
850            v: TUNE_CACHE_VERSION,
851            caps_hash: self.caps_hash.clone(),
852            saved_at_ms: if now_ms > 0.0 { now_ms as u64 } else { 0 },
853            params: TuneParamsCache::of(&self.params),
854        };
855        if let Ok(json) = serde_json::to_string(&entry) {
856            store.set(&key, &json);
857        }
858    }
859
860    /// Record which direction the coming transfer tunes (sets which
861    /// window/chunk-size dimensions the ramp moves).
862    pub fn set_direction(&mut self, kind: TransferKind) {
863        self.direction = kind;
864    }
865
866    /// Current direction.
867    pub fn direction(&self) -> TransferKind {
868        self.direction
869    }
870
871    /// Install the JS `onTuning(phase, params, stats)` callback.
872    pub fn set_on_tuning(&mut self, cb: Option<js_sys::Function>) {
873        self.on_tuning = cb;
874    }
875
876    /// Current phase.
877    pub fn phase(&self) -> TunePhase {
878        self.phase
879    }
880
881    /// Current live parameters.
882    pub fn params(&self) -> TuneParams {
883        self.params.clone()
884    }
885
886    /// Current stats.
887    pub fn stats(&self) -> TuneStats {
888        self.stats
889    }
890
891    /// Whether tuning is active (autoTune on).
892    pub fn enabled(&self) -> bool {
893        self.enabled
894    }
895
896    /// Force-disable tuning for the rest of this client's lifetime.
897    ///
898    /// A server that does not serve `/capabilities` offers nothing to tune
899    /// against, so the client stops asking.
900    pub fn set_enabled(&mut self, enabled: bool) {
901        self.enabled = enabled;
902    }
903
904    /// The capabilities currently loaded for this session.
905    pub fn caps(&self) -> Option<Capabilities> {
906        self.caps.clone()
907    }
908
909    /// Snapshot the engine state as a JS object for the SDK:
910    /// `{ phase, params, stats }`.
911    pub fn state_js(&self) -> JsValue {
912        let o = js_sys::Object::new();
913        let phase = match self.phase {
914            TunePhase::Uninitialized => "uninitialized",
915            TunePhase::Ramping => "ramping",
916            TunePhase::Settled => "settled",
917            TunePhase::Degraded => "degraded",
918        };
919        let _ = js_sys::Reflect::set(&o, &JsValue::from_str("phase"), &JsValue::from_str(phase));
920        let _ = js_sys::Reflect::set(&o, &JsValue::from_str("params"), &params_to_js(&self.params));
921        let stats = js_sys::Object::new();
922        let _ = js_sys::Reflect::set(
923            &stats,
924            &JsValue::from_str("rttMs"),
925            &JsValue::from_f64(self.stats.rtt_ms),
926        );
927        let _ = js_sys::Reflect::set(
928            &stats,
929            &JsValue::from_str("mbps"),
930            &JsValue::from_f64(self.stats.mbps),
931        );
932        let _ = js_sys::Reflect::set(&o, &JsValue::from_str("stats"), &stats.into());
933        let _ = js_sys::Reflect::set(
934            &o,
935            &JsValue::from_str("capsHash"),
936            &JsValue::from_str(&self.caps_hash),
937        );
938        o.into()
939    }
940
941    /// The capabilities hash we are currently tuned against.
942    pub fn caps_hash(&self) -> &str {
943        &self.caps_hash
944    }
945
946    /// Start a transfer.
947    ///
948    /// The parameters are taken from the best available source, in order:
949    /// 1. an in-memory settle from an earlier transfer of this client (same
950    ///    direction, same capabilities);
951    /// 2. a *cached* settle for this origin + direction that is still inside
952    ///    its TTL and was measured against the same capabilities — so a page
953    ///    refresh (or a later visit) skips the ramp;
954    /// 3. otherwise the advertised minimums (a fresh ramp).
955    ///
956    /// Returns the parameters the transfer should start with. With tuning
957    /// disabled this is the static config-derived table, so callers can
958    /// always use the result.
959    pub fn begin_transfer(
960        &mut self,
961        caps: &Capabilities,
962        now_ms: f64,
963        static_params: &TuneParams,
964    ) -> TuneParams {
965        self.caps = Some(caps.clone());
966        self.caps_hash = caps.caps_hash();
967        self.windows_closed = 0;
968        self.window_errors = 0;
969        self.window_done_base = 0;
970        self.window_start_ms = now_ms;
971        self.auto_level_benchmarked = false;
972        self.auto_level = None;
973        self.post_degrade_settle = false;
974        // Per-transfer measurement state MUST NOT leak across transfers: a
975        // stale `prev_mbps` / `low_gain_windows` / RTT EWMA from the previous
976        // link would make the first window of this transfer settle (or raise)
977        // on numbers that describe a different connection.
978        self.prev_mbps = 0.0;
979        self.low_gain_windows = 0;
980        self.rtt_ewma = None;
981        self.rtt_baseline = None;
982        self.degraded_windows = 0;
983
984        // The compression level is a *client policy*, not a ramped dimension
985        // (the ramp only moves window / concurrency / chunk size), so it is
986        // resolved from the client's configured policy in every branch below.
987        let policy_level = static_params.compress_level;
988        self.policy_level = policy_level;
989
990        if !self.enabled {
991            self.phase = TunePhase::Uninitialized;
992            self.params = static_params.clamped_into(caps);
993            self.enforce_in_flight_budget();
994            return self.params.clone();
995        }
996
997        // Reuse only a settle measured against *these* capabilities *and* this
998        // direction: a server that changed its advertisement — or an upload
999        // settle offered to a download — invalidates it.
1000        let in_memory = self.settled.clone().filter(|_| {
1001            self.settled_caps_hash == self.caps_hash
1002                && self.settled_direction == Some(self.direction)
1003        });
1004        let reusable = match in_memory {
1005            Some(settled) => Some(settled),
1006            None => self.load_cached_settle(now_ms, caps),
1007        };
1008        match reusable {
1009            Some(settled) => {
1010                self.params = settled.clamped_into(caps);
1011                self.params.compress_level = policy_level;
1012                self.phase = TunePhase::Settled;
1013            }
1014            None => {
1015                self.params = TuneParams::from_caps_mins(caps);
1016                self.params.compress_level = policy_level;
1017                self.stats = TuneStats::default();
1018                self.phase = TunePhase::Ramping;
1019            }
1020        }
1021        self.dim = RampDim::Window;
1022        // The advertised minimums are untrusted: keep the table inside the
1023        // client's per-file in-flight byte budget.
1024        self.enforce_in_flight_budget();
1025        self.params.clone()
1026    }
1027
1028    /// Feed one measurement sample; the engine evaluates exactly one window
1029    /// per [`TUNE_WINDOW_MS`] of wall clock and may return a tuning event.
1030    ///
1031    /// `done_bytes` is the absolute transferred count (the engine computes
1032    /// the window delta), `rtt_ms` an optional TTFB sample, `error` whether
1033    /// a chunk/round failed in this window.
1034    pub fn tick(
1035        &mut self,
1036        now_ms: f64,
1037        done_bytes: u64,
1038        rtt_ms: Option<f64>,
1039        error: bool,
1040    ) -> Option<TuneEvent> {
1041        if error {
1042            self.window_errors += 1;
1043        }
1044        if let Some(r) = rtt_ms.filter(|&r| r > 0.0) {
1045            let new_ewma = ewma(self.rtt_ewma, r, RTT_EWMA_ALPHA);
1046            if self.enabled && self.rtt_baseline.is_none() {
1047                // Seed the ramp's latency baseline from this transfer's first
1048                // sample; `rtt_inflation` measures saturation against it, so
1049                // a growing RTT settles the ramp instead of pushing it on.
1050                self.rtt_baseline = Some(new_ewma);
1051            }
1052            self.rtt_ewma = Some(new_ewma);
1053        }
1054        let elapsed = now_ms - self.window_start_ms;
1055        if elapsed < TUNE_WINDOW_MS {
1056            return None;
1057        }
1058        if !self.enabled {
1059            return None;
1060        }
1061        let caps = self.caps.clone()?;
1062
1063        let bytes = done_bytes.saturating_sub(self.window_done_base);
1064        let mbps = if elapsed > 0.0 {
1065            bytes as f64 / elapsed * 1_000.0 * 8.0 / 1e6
1066        } else {
1067            0.0
1068        };
1069        self.stats.mbps = mbps;
1070        if let Some(rtt) = self.rtt_ewma {
1071            self.stats.rtt_ms = rtt;
1072        }
1073
1074        // Size the next chunk from the link we just measured: a wider/faster
1075        // link gets bigger chunks (fewer requests, better per-request
1076        // compression), a narrow one keeps the advertised minimum. This runs
1077        // on every window — including after the ramp settles — so the chunk
1078        // size tracks the link for the whole transfer, not only while the
1079        // window and concurrency dimensions are still being ramped.
1080        self.retune_chunk_size(mbps, &caps);
1081
1082        // BDP / RTT saturation only applies while actively ramping: a
1083        // Degraded phase's "stability windows" (or a settled reuse) must not
1084        // be cut short by a tiny-BDP computation on near-zero traffic.
1085        let ramping = self.phase == TunePhase::Ramping;
1086        let input = RampInput {
1087            mbps,
1088            prev_mbps: self.prev_mbps,
1089            rtt_inflation: if ramping { self.rtt_inflation() } else { 0.0 },
1090            errors: self.window_errors,
1091            degraded: self.phase == TunePhase::Degraded,
1092            ramping,
1093            low_gain_windows: self.low_gain_windows,
1094            in_flight: self.in_flight(),
1095            // See `RampInput::bdp`: the in-flight/BDP check only gates
1096            // cross-file concurrency, never the window or the chunk size.
1097            bdp: if ramping && self.dim == RampDim::Concurrency {
1098                bdp_bytes(mbps, self.rtt_ewma.unwrap_or(self.stats.rtt_ms))
1099            } else {
1100                0
1101            },
1102            at_cap: self.dim_at_cap(&caps),
1103            last_dim: self.dim == RampDim::Concurrency,
1104        };
1105        let (action, low) = ramp_action(&input);
1106        self.low_gain_windows = low;
1107        self.windows_closed += 1;
1108        self.prev_mbps = mbps;
1109
1110        match action {
1111            RampAction::Raise => {
1112                self.raise_dim(&caps);
1113                self.enforce_in_flight_budget();
1114                if self.phase == TunePhase::Degraded {
1115                    self.phase = TunePhase::Ramping;
1116                }
1117            }
1118            RampAction::AdvanceDim => {
1119                self.dim = next_dim(self.dim);
1120            }
1121            RampAction::Settle => {
1122                self.phase = TunePhase::Settled;
1123                self.low_gain_windows = 0;
1124                // Remember this settle for the *next* transfer of this same
1125                // client, and persist it for later visits (bounded by the
1126                // cache TTL). A post-degrade settle is excluded: its halved
1127                // parameters are not a good reuse baseline.
1128                if !self.post_degrade_settle {
1129                    self.settled = Some(self.params.clone());
1130                    self.settled_caps_hash = self.caps_hash.clone();
1131                    self.settled_direction = Some(self.direction);
1132                    self.save_cached_settle(now_ms);
1133                }
1134            }
1135            RampAction::Degrade => {
1136                self.halve_dim();
1137                self.phase = TunePhase::Degraded;
1138                self.degraded_windows = 0;
1139                self.low_gain_windows = 0;
1140            }
1141            RampAction::Hold => {
1142                if self.phase == TunePhase::Degraded {
1143                    // Conservative settle after 2 stable windows.
1144                    self.degraded_windows += 1;
1145                    if self.degraded_windows >= 2 {
1146                        self.phase = TunePhase::Settled;
1147                        // Mark that we settled from a degraded hold so that
1148                        // `transfer_end` does NOT persist the reduced params
1149                        // as the tuned baseline — the next ramp will
1150                        // re-explore from the minimums and cache only after
1151                        // a full convergence.
1152                        self.post_degrade_settle = true;
1153                    }
1154                }
1155            }
1156        }
1157
1158        // Reset the window accumulator.
1159        self.window_start_ms = now_ms;
1160        self.window_done_base = done_bytes;
1161        self.window_errors = 0;
1162
1163        let event = TuneEvent {
1164            phase: self.phase,
1165            params: self.params.clone(),
1166            stats: self.stats,
1167        };
1168        self.emit(&event);
1169        Some(event)
1170    }
1171
1172    /// Mark the transfer outcome.
1173    ///
1174    /// A failure forgets the in-session settle *and* drops the cached entry, so
1175    /// the next transfer re-ramps from the advertised minimums instead of
1176    /// reusing parameters that just failed.
1177    pub fn transfer_end(&mut self, ok: bool) {
1178        if !self.enabled {
1179            return;
1180        }
1181        if !ok {
1182            self.settled = None;
1183            self.settled_caps_hash.clear();
1184            self.settled_direction = None;
1185            self.remove_cache_entry();
1186            self.phase = TunePhase::Ramping;
1187        }
1188    }
1189
1190    /// The upload compression level for this session.
1191    ///
1192    /// `Auto` benchmarks the advertised candidates against `sample` once per
1193    /// session (reusing the fastest cache afterwards); without a sample it
1194    /// resolves to the server default.
1195    pub fn upload_compress_level(
1196        &mut self,
1197        caps: &Capabilities,
1198        sample: Option<&[u8]>,
1199        mbps: f64,
1200    ) -> i32 {
1201        match self.level_cfg {
1202            CompressLevel::Auto => {
1203                if !self.auto_level_benchmarked {
1204                    self.auto_level_benchmarked = true;
1205                    self.auto_level = Some(match sample {
1206                        Some(s) if !s.is_empty() => {
1207                            // Limit the sample to LEVEL_SAMPLE_SIZE so the
1208                            // synchronous benchmark does not stall the WASM
1209                            // main thread for too long on large files.
1210                            let s = &s[..LEVEL_SAMPLE_SIZE.min(s.len())];
1211                            let candidates = auto_candidates(caps);
1212                            let results = benchmark_levels(s, &candidates);
1213                            choose_auto_level(&results, s.len(), mbps.max(1.0))
1214                        }
1215                        _ => caps.compression.zrip_levels.default,
1216                    });
1217                }
1218                self.auto_level.unwrap_or(caps.compression.zrip_levels.default)
1219            }
1220            other => resolve_level(other, caps),
1221        }
1222    }
1223
1224    /// Whether the auto level has been benchmarked this session.
1225    pub fn auto_level_ready(&self) -> bool {
1226        self.auto_level_benchmarked
1227    }
1228
1229    fn rtt_inflation(&self) -> f64 {
1230        // Inflation is measured against the latency the *ramp* started from,
1231        // never against the current EWMA (comparing the EWMA with itself is
1232        // always 0, which would make this rule unreachable).
1233        match (self.rtt_ewma, self.rtt_baseline) {
1234            (Some(cur), Some(base)) if base > 0.0 => (cur / base - 1.0).max(0.0),
1235            _ => 0.0,
1236        }
1237    }
1238
1239    fn in_flight(&self) -> u64 {
1240        match self.last_transfer_kind() {
1241            TransferKind::Upload => self.params.upload_in_flight(),
1242            TransferKind::Download => self.params.download_in_flight(),
1243        }
1244    }
1245
1246    /// The current value of the dimension being ramped.
1247    fn dim_value(&self) -> i64 {
1248        match self.dim {
1249            RampDim::Window => match self.last_transfer_kind() {
1250                TransferKind::Upload => self.params.upload_window as i64,
1251                TransferKind::Download => self.params.download_window as i64,
1252            },
1253            RampDim::Concurrency => self.params.concurrency as i64,
1254        }
1255    }
1256
1257    /// The maximum the current dimension may reach: the server's advertised
1258    /// max intersected with the client's hard ceiling.
1259    fn dim_max(&self, caps: &Capabilities) -> i64 {
1260        let advertised = match self.dim {
1261            RampDim::Window => match self.last_transfer_kind() {
1262                TransferKind::Upload => caps.limits.upload_window.max,
1263                TransferKind::Download => caps.limits.download_window.max,
1264            },
1265            RampDim::Concurrency => caps.limits.concurrency.max,
1266        };
1267        let hard = match self.dim {
1268            RampDim::Window => HARD_MAX_WINDOW,
1269            RampDim::Concurrency => HARD_MAX_CONCURRENCY,
1270        };
1271        advertised.min(hard).max(1)
1272    }
1273
1274    /// Largest chunk size that keeps `concurrency × window × chunk` inside
1275    /// [`HARD_MAX_IN_FLIGHT_BYTES`] for the widest direction.
1276    fn max_chunk_for_budget(&self) -> u64 {
1277        let widest = self.params.upload_window.max(self.params.download_window).max(1) as u64;
1278        let divisor = (self.params.concurrency.max(1) as u64) * widest;
1279        (HARD_MAX_IN_FLIGHT_BYTES / divisor).max(1)
1280    }
1281
1282    /// Point the chunk size at the measured link (see [`chunk_for_link`]).
1283    ///
1284    /// A ±50 % deadband (hysteresis) keeps a link that hovers around a size
1285    /// boundary from flipping the chunk back and forth every window — each
1286    /// flip also re-aligns the remaining upload blocks / download batches, so
1287    /// stability matters more than tracking the estimate exactly.
1288    fn retune_chunk_size(&mut self, mbps: f64, caps: &Capabilities) {
1289        let target = chunk_for_link(mbps, &caps.limits.chunk_size, self.max_chunk_for_budget());
1290        let current = self.params.chunk_size.max(1);
1291        let grow = current.saturating_mul(3) / 2;
1292        let shrink = (current / 2).max(1);
1293        if target > grow || target < shrink {
1294            self.params.chunk_size = target;
1295            // A bigger chunk can push `concurrency × window × chunk` past the
1296            // memory budget (it divides by the *widest* direction's window, so
1297            // the mix matters); keep the table inside it.
1298            self.enforce_in_flight_budget();
1299        }
1300    }
1301
1302    /// Shrink the tuned chunk size into the in-flight byte budget.
1303    ///
1304    /// Applied to the ramp-start table (whose values come straight from the
1305    /// advertised minimums) and after every raise, so no sequence of
1306    /// server-advertised values can push the engine past its memory budget.
1307    fn enforce_in_flight_budget(&mut self) {
1308        let max_chunk = self.max_chunk_for_budget();
1309        self.params.chunk_size = self.params.chunk_size.clamp(1, max_chunk);
1310    }
1311
1312    fn dim_at_cap(&self, caps: &Capabilities) -> bool {
1313        self.dim_value() >= self.dim_max(caps)
1314    }
1315
1316    fn raise_dim(&mut self, caps: &Capabilities) {
1317        let dim_max: i64 = self.dim_max(caps);
1318        // Multiplicative increase (×2) until near the cap, then additive
1319        // (+1) — TCP slow-start style.
1320        let step = |v: i64| -> i64 {
1321            if v <= 0 {
1322                1
1323            } else if v * 2 <= dim_max {
1324                v * 2
1325            } else {
1326                (v + 1).min(dim_max)
1327            }
1328        };
1329        match self.dim {
1330            RampDim::Window => match self.last_transfer_kind() {
1331                TransferKind::Upload => {
1332                    self.params.upload_window = step(self.params.upload_window as i64).max(1) as usize
1333                }
1334                TransferKind::Download => {
1335                    self.params.download_window =
1336                        step(self.params.download_window as i64).max(1) as usize
1337                }
1338            },
1339            RampDim::Concurrency => {
1340                self.params.concurrency = step(self.params.concurrency as i64).max(1) as usize
1341            }
1342        }
1343    }
1344
1345    fn halve_dim(&mut self) {
1346        let step = |v: i64| -> i64 { ((v as f64) * DEGRADE_FACTOR).floor().max(1.0) as i64 };
1347        match self.dim {
1348            RampDim::Window => match self.last_transfer_kind() {
1349                TransferKind::Upload => {
1350                    self.params.upload_window = step(self.params.upload_window as i64) as usize
1351                }
1352                TransferKind::Download => {
1353                    self.params.download_window = step(self.params.download_window as i64) as usize
1354                }
1355            },
1356            RampDim::Concurrency => {
1357                self.params.concurrency = step(self.params.concurrency as i64) as usize
1358            }
1359        }
1360    }
1361}
1362
1363/// Which transfer direction is ramping (drives which window/chunk dims move).
1364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1365pub enum TransferKind {
1366    Upload,
1367    Download,
1368}
1369
1370impl TuningEngine {
1371    fn last_transfer_kind(&self) -> TransferKind {
1372        // The ramp dimension set is reset per transfer; the direction is
1373        // recorded by begin_transfer callers via `set_direction`.
1374        self.direction
1375    }
1376}
1377
1378/// The ramp order: per-file window → cross-file concurrency.
1379///
1380/// The chunk size is NOT a ramp dimension: it follows the measured link
1381/// (see [`chunk_for_link`]).
1382fn next_dim(dim: RampDim) -> RampDim {
1383    match dim {
1384        RampDim::Window => RampDim::Concurrency,
1385        // Concurrency is the terminal dimension: `ramp_action` returns
1386        // `Settle` (not `AdvanceDim`) when it is at cap, so this arm is
1387        // unreachable in practice. It is kept for exhaustiveness; if a new
1388        // dimension is added, the compiler will force updating this function.
1389        RampDim::Concurrency => RampDim::Concurrency,
1390    }
1391}
1392
1393/// Benchmark candidates: min, default, max of the advertised range.
1394fn auto_candidates(caps: &Capabilities) -> Vec<i32> {
1395    let z = caps.compression.zrip_levels;
1396    vec![z.min, z.default, z.max]
1397}
1398
1399/// Compress `sample` at each level, returning (level, len, ms).
1400///
1401/// One-shot `zrip::compress` (single frame) is fine for a 256 KiB sample.
1402fn benchmark_levels(sample: &[u8], levels: &[i32]) -> Vec<(i32, usize, f64)> {
1403    levels
1404        .iter()
1405        .map(|&level| {
1406            let t0 = now_ms();
1407            let out = zrip::compress(sample, level).unwrap_or_else(|_| sample.to_vec());
1408            (level, out.len(), now_ms() - t0)
1409        })
1410        .collect()
1411}
1412
1413/// Wall clock (epoch ms). WASM: `Date.now()`; native: `std::time`.
1414pub fn now_ms() -> f64 {
1415    #[cfg(target_arch = "wasm32")]
1416    {
1417        js_sys::Date::now()
1418    }
1419    #[cfg(not(target_arch = "wasm32"))]
1420    {
1421        std::time::SystemTime::now()
1422            .duration_since(std::time::UNIX_EPOCH)
1423            .map(|d| d.as_secs_f64() * 1_000.0)
1424            .unwrap_or(0.0)
1425    }
1426}
1427
1428impl TuningEngine {
1429    fn emit(&self, event: &TuneEvent) {
1430        if let Some(cb) = &self.on_tuning {
1431            let params = params_to_js(&event.params);
1432            let stats = js_sys::Object::new();
1433            let _ = js_sys::Reflect::set(
1434                &stats,
1435                &JsValue::from_str("rttMs"),
1436                &JsValue::from_f64(event.stats.rtt_ms),
1437            );
1438            let _ = js_sys::Reflect::set(
1439                &stats,
1440                &JsValue::from_str("mbps"),
1441                &JsValue::from_f64(event.stats.mbps),
1442            );
1443            let phase = match event.phase {
1444                TunePhase::Uninitialized => "uninitialized",
1445                TunePhase::Ramping => "ramping",
1446                TunePhase::Settled => "settled",
1447                TunePhase::Degraded => "degraded",
1448            };
1449            let _ = cb.call3(
1450                &JsValue::NULL,
1451                &JsValue::from_str(phase),
1452                &params,
1453                &stats.into(),
1454            );
1455        }
1456    }
1457}
1458
1459fn params_to_js(params: &TuneParams) -> JsValue {
1460    let o = js_sys::Object::new();
1461    let _ = js_sys::Reflect::set(&o, &JsValue::from_str("concurrency"), &JsValue::from_f64(params.concurrency as f64));
1462    let _ = js_sys::Reflect::set(&o, &JsValue::from_str("uploadWindow"), &JsValue::from_f64(params.upload_window as f64));
1463    let _ = js_sys::Reflect::set(&o, &JsValue::from_str("downloadWindow"), &JsValue::from_f64(params.download_window as f64));
1464    let _ = js_sys::Reflect::set(&o, &JsValue::from_str("chunkSize"), &JsValue::from_f64(params.chunk_size as f64));
1465    let _ = js_sys::Reflect::set(&o, &JsValue::from_str("compressLevel"), &JsValue::from_f64(params.compress_level as f64));
1466    o.into()
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471    use super::*;
1472
1473    fn caps() -> Capabilities {
1474        Capabilities::default()
1475    }
1476
1477    fn static_params() -> TuneParams {
1478        TuneParams {
1479            concurrency: 4,
1480            upload_window: 8,
1481            download_window: 4,
1482            chunk_size: 2 * 1024 * 1024,
1483            compress_level: 1,
1484        }
1485    }
1486
1487    /// An auto-tuning engine with one upload transfer already in flight.
1488    fn engine() -> TuningEngine {
1489        let mut e = TuningEngine::new(true, CompressLevel::Balanced);
1490        e.set_direction(TransferKind::Upload);
1491        e.begin_transfer(&caps(), 0.0, &static_params());
1492        e
1493    }
1494
1495    /// Drives the ramp with a flat throughput profile until the engine settles
1496    /// (the explore-then-settle path exercised by `flat_gain_...`), so the
1497    /// reuse tests start from a genuinely tuned state.
1498    fn ramp_to_settle(e: &mut TuningEngine) {
1499        let step = 12_500_000u64;
1500        let mut done = step;
1501        for i in 0..16 {
1502            let t = 1_000.0 * (i as f64 + 1.0);
1503            if let Some(TuneEvent {
1504                phase: TunePhase::Settled,
1505                ..
1506            }) = e.tick(t, done, Some(400.0), false)
1507            {
1508                return;
1509            }
1510            done += step;
1511        }
1512        panic!("engine did not settle");
1513    }
1514
1515    // --- pure decision logic ------------------------------------------------
1516
1517    #[test]
1518    fn ramp_action_error_degrades() {
1519        let input = RampInput {
1520            mbps: 10.0,
1521            prev_mbps: 5.0,
1522            rtt_inflation: 0.0,
1523            errors: 1,
1524            degraded: false,
1525            ramping: true,
1526            low_gain_windows: 0,
1527            in_flight: 100,
1528            bdp: 1_000,
1529            at_cap: false,
1530            last_dim: false,
1531        };
1532        assert_eq!(ramp_action(&input).0, RampAction::Degrade);
1533    }
1534
1535    #[test]
1536    fn ramp_action_cap_advances_or_settles() {
1537        let base = RampInput {
1538            mbps: 10.0,
1539            prev_mbps: 5.0,
1540            rtt_inflation: 0.0,
1541            errors: 0,
1542            degraded: false,
1543            ramping: true,
1544            low_gain_windows: 0,
1545            in_flight: 100,
1546            bdp: 1_000,
1547            at_cap: true,
1548            last_dim: false,
1549        };
1550        assert_eq!(ramp_action(&base).0, RampAction::AdvanceDim);
1551        let last = RampInput { last_dim: true, ..base };
1552        assert_eq!(ramp_action(&last).0, RampAction::Settle);
1553    }
1554
1555    #[test]
1556    fn ramp_action_bdp_and_rtt_saturate() {
1557        let base = RampInput {
1558            mbps: 10.0,
1559            prev_mbps: 5.0,
1560            rtt_inflation: 0.0,
1561            errors: 0,
1562            degraded: false,
1563            ramping: true,
1564            low_gain_windows: 0,
1565            in_flight: 2_000,
1566            bdp: 1_000,
1567            at_cap: false,
1568            last_dim: false,
1569        };
1570        assert_eq!(ramp_action(&base).0, RampAction::Settle, "in-flight ≥ BDP settles");
1571
1572        let rtt = RampInput {
1573            rtt_inflation: 0.5,
1574            in_flight: 10,
1575            bdp: 1_000,
1576            ..base
1577        };
1578        assert_eq!(ramp_action(&rtt).0, RampAction::Settle, "RTT inflation settles");
1579    }
1580
1581    #[test]
1582    fn ramp_action_gain_raises_and_low_gain_settles() {
1583        // +100% growth → raise (and the low-gain counter resets).
1584        let growing = RampInput {
1585            mbps: 20.0,
1586            prev_mbps: 10.0,
1587            ..RampInput {
1588                mbps: 0.0,
1589                prev_mbps: 0.0,
1590                rtt_inflation: 0.0,
1591                errors: 0,
1592                degraded: false,
1593                ramping: true,
1594                low_gain_windows: 7,
1595                in_flight: 10,
1596                bdp: 1_000,
1597                at_cap: false,
1598                last_dim: false,
1599            }
1600        };
1601        let (action, low) = ramp_action(&growing);
1602        assert_eq!(action, RampAction::Raise);
1603        assert_eq!(low, 0, "raise resets the low-gain counter");
1604
1605        // +4% growth (below the 5% threshold) → hold, counter 1.
1606        let flat = RampInput {
1607            mbps: 10.4,
1608            prev_mbps: 10.0,
1609            low_gain_windows: 0,
1610            ..growing
1611        };
1612        let (action, low) = ramp_action(&flat);
1613        assert_eq!(action, RampAction::Hold);
1614        assert_eq!(low, 1, "first below-threshold window holds");
1615
1616        // Second below-threshold window → the dimension is exhausted. Not the
1617        // last dimension → explore the next one (cross-file concurrency still
1618        // matters when the window stopped helping).
1619        let flat2 = RampInput { low_gain_windows: 1, ..flat };
1620        let (action, low) = ramp_action(&flat2);
1621        assert_eq!(action, RampAction::AdvanceDim);
1622        assert_eq!(low, 0, "advancing resets the low-gain counter");
1623
1624        // …unless it is the last dimension → settle.
1625        let flat_last = RampInput {
1626            last_dim: true,
1627            low_gain_windows: 1,
1628            ..flat
1629        };
1630        assert_eq!(ramp_action(&flat_last).0, RampAction::Settle);
1631
1632        // First window (no baseline) counts as growth.
1633        let first = RampInput {
1634            mbps: 1.0,
1635            prev_mbps: 0.0,
1636            low_gain_windows: 0,
1637            ..growing
1638        };
1639        assert_eq!(ramp_action(&first).0, RampAction::Raise);
1640    }
1641
1642    #[test]
1643    fn ramp_action_degraded_hold_never_raises() {
1644        // Near-zero traffic right after a degrade (prev_mbps == 0) must not
1645        // trigger the first-window pseudo-gain: the engine holds, then
1646        // settles on the second stable window instead of re-raising.
1647        let degraded = RampInput {
1648            mbps: 0.008,
1649            prev_mbps: 0.0,
1650            degraded: true,
1651            ..RampInput {
1652                mbps: 0.0,
1653                prev_mbps: 0.0,
1654                rtt_inflation: 0.0,
1655                errors: 0,
1656                degraded: false,
1657                ramping: true,
1658                low_gain_windows: 0,
1659                in_flight: 10,
1660                bdp: 1_000,
1661                at_cap: false,
1662                last_dim: false,
1663            }
1664        };
1665        let (action, low) = ramp_action(&degraded);
1666        assert_eq!(action, RampAction::Hold);
1667        assert_eq!(low, 1, "first stability window holds");
1668        let (action, low) = ramp_action(&RampInput { low_gain_windows: 1, ..degraded });
1669        assert_eq!(action, RampAction::Settle);
1670        assert_eq!(low, 2, "second stability window settles");
1671    }
1672
1673    #[test]
1674    fn ewma_seeds_and_smooths() {
1675        assert_eq!(ewma(None, 10.0, 0.25), 10.0);
1676        let v = ewma(Some(10.0), 20.0, 0.25);
1677        assert!((v - 12.5).abs() < 1e-9);
1678    }
1679
1680    #[test]
1681    fn bdp_conversion() {
1682        // 10 Mbps, 100 ms RTT → 10e6/8 * 0.1 = 125_000 bytes.
1683        assert_eq!(bdp_bytes(10.0, 100.0), 125_000);
1684        assert_eq!(bdp_bytes(0.0, 0.0), 0);
1685    }
1686
1687    #[test]
1688    fn request_budget_covers_files_and_the_per_file_window() {
1689        // A minimal ramp must still be able to pipeline its window: the pool is
1690        // `concurrency × window`, not `concurrency`.
1691        assert_eq!(request_budget(1, 1), 1);
1692        assert_eq!(request_budget(1, 8), 8);
1693        assert_eq!(request_budget(4, 8), 32);
1694        // Zeroes can never produce an empty (deadlocked) pool.
1695        assert_eq!(request_budget(0, 0), 1);
1696        // Hostile/absurd input stays inside the hard ceiling.
1697        assert_eq!(request_budget(64, 64), HARD_MAX_IN_FLIGHT_REQUESTS);
1698
1699        // The tuned table reports the widest direction's window.
1700        let mut p = static_params();
1701        p.concurrency = 2;
1702        p.upload_window = 8;
1703        p.download_window = 4;
1704        assert_eq!(p.request_budget(), 16);
1705        p.upload_window = 4;
1706        assert_eq!(p.request_budget(), 8);
1707    }
1708
1709    #[test]
1710    fn resolve_level_mapping() {
1711        let c = caps();
1712        assert_eq!(resolve_level(CompressLevel::Fast, &c), -8);
1713        assert_eq!(resolve_level(CompressLevel::Balanced, &c), 1);
1714        assert_eq!(resolve_level(CompressLevel::Max, &c), 4);
1715        assert_eq!(resolve_level(CompressLevel::Auto, &c), 1);
1716        assert_eq!(resolve_level(CompressLevel::Fixed(2), &c), 2);
1717        assert_eq!(resolve_level(CompressLevel::Fixed(99), &c), 4);
1718        assert_eq!(resolve_level(CompressLevel::Fixed(-99), &c), -8);
1719    }
1720
1721    #[test]
1722    fn choose_auto_level_picks_by_net_gain() {
1723        // Incompressible sample → cheapest level (min), never waste CPU.
1724        assert_eq!(
1725            choose_auto_level(&[(-8, 1000, 0.1), (1, 1000, 5.0), (4, 1000, 40.0)], 1000, 10.0),
1726            -8
1727        );
1728
1729        // CPU cost dominates (fast link) → cheaper level wins.
1730        let cpu_bound = choose_auto_level(&[(-8, 800_000, 0.1), (4, 400_000, 30.0)], 1_000_000, 1000.0);
1731        assert_eq!(cpu_bound, -8);
1732
1733        // Savings dominate (slow link, cheap CPU) → stronger level wins.
1734        let savings = choose_auto_level(&[(-8, 800_000, 0.1), (4, 400_000, 30.0)], 1_000_000, 10.0);
1735        assert_eq!(savings, 4);
1736
1737        // Ties → cheaper (first) candidate.
1738        assert_eq!(choose_auto_level(&[(0, 500, 1.0), (4, 500, 1.0)], 1000, 100.0), 0);
1739    }
1740
1741    // --- engine behaviour ---------------------------------------------------
1742
1743    #[test]
1744    fn begin_transfer_miss_ramps_from_mins() {
1745        let mut e = TuningEngine::new(true, CompressLevel::Balanced);
1746        e.set_direction(TransferKind::Upload);
1747        let p = e.begin_transfer(&caps(), 0.0, &static_params());
1748        assert_eq!(e.phase(), TunePhase::Ramping);
1749        assert_eq!(p.concurrency, 1);
1750        assert_eq!(p.upload_window, 1);
1751        assert_eq!(p.chunk_size, 262_144);
1752    }
1753
1754    #[test]
1755    fn settled_reuse_first_window_does_not_raise() {
1756        // A settled cache reuse is not a ramp: its first window has no
1757        // baseline, so it must hold rather than invent +100% growth and bump
1758        // a dimension out of the cached optimum.
1759        let input = RampInput {
1760            mbps: 100.0,
1761            prev_mbps: 0.0,
1762            rtt_inflation: 0.0,
1763            errors: 0,
1764            degraded: false,
1765            ramping: false,
1766            low_gain_windows: 0,
1767            in_flight: 10,
1768            bdp: 0,
1769            at_cap: false,
1770            last_dim: false,
1771        };
1772        let (action, low) = ramp_action(&input);
1773        assert_eq!(action, RampAction::Hold);
1774        assert_eq!(low, 1);
1775    }
1776
1777    #[test]
1778    fn reused_params_do_not_raise_on_the_first_window() {
1779        let mut e = engine();
1780        ramp_to_settle(&mut e);
1781        let tuned = e.params().clone();
1782        e.transfer_end(true);
1783        e.begin_transfer(&caps(), 60_000.0, &static_params());
1784        // A burst of throughput right away must not raise the tuned
1785        // dimensions: this transfer has no baseline yet and is not ramping.
1786        let ev = e.tick(61_000.0, 100_000_000, Some(400.0), false).unwrap();
1787        assert_eq!(ev.phase, TunePhase::Settled);
1788        assert_eq!(
1789            ev.params.upload_window, tuned.upload_window,
1790            "settled reuse must not raise the window"
1791        );
1792        assert_eq!(ev.params.concurrency, tuned.concurrency);
1793    }
1794
1795    #[test]
1796    fn rtt_inflation_settles_the_ramp() {
1797        let mut e = engine();
1798        // Window 1 anchors the ramp's latency baseline at 100 ms.
1799        let ev = e.tick(1_000.0, 12_500_000, Some(100.0), false).unwrap();
1800        assert_eq!(ev.phase, TunePhase::Ramping);
1801        // Constant throughput but the EWMA RTT inflates > 30% → saturated.
1802        let ev = e.tick(2_000.0, 25_000_000, Some(400.0), false).unwrap();
1803        assert_eq!(ev.phase, TunePhase::Settled, "RTT inflation must settle");
1804    }
1805
1806    #[test]
1807    fn ramp_start_keeps_the_configured_compress_policy() {
1808        let mut e = TuningEngine::new(true, CompressLevel::Balanced);
1809        e.set_direction(TransferKind::Upload);
1810        let sp = static_params(); // compress_level: 1 (Balanced)
1811        let p = e.begin_transfer(&caps(), 0.0, &sp);
1812        assert_eq!(
1813            p.compress_level, 1,
1814            "a ramp must not silently drop to the advertised minimum (-8)"
1815        );
1816        // A settle — and the in-session reuse that follows it — keep the
1817        // policy level too (it is never a ramped dimension).
1818        ramp_to_settle(&mut e);
1819        e.transfer_end(true);
1820        let p2 = e.begin_transfer(&caps(), 60_000.0, &sp);
1821        assert_eq!(p2.compress_level, 1, "reuse keeps the policy level");
1822    }
1823
1824    #[test]
1825    fn absurd_advertised_caps_are_clamped_to_hard_ceilings() {
1826        let mut hostile = caps();
1827        hostile.limits.concurrency = IntRange {
1828            default: 1,
1829            max: 100_000,
1830            min: 1_000,
1831        };
1832        hostile.limits.upload_window = IntRange {
1833            default: 1,
1834            max: 100_000,
1835            min: 500,
1836        };
1837        hostile.limits.download_window = IntRange {
1838            default: 1,
1839            max: 100_000,
1840            min: 500,
1841        };
1842        hostile.limits.chunk_size = IntRange {
1843            default: i64::MAX,
1844            max: i64::MAX,
1845            min: i64::MAX,
1846        };
1847        let mut e = TuningEngine::new(true, CompressLevel::Balanced);
1848        e.set_direction(TransferKind::Upload);
1849        let p = e.begin_transfer(&hostile, 0.0, &static_params());
1850        assert!(p.concurrency <= HARD_MAX_CONCURRENCY as usize);
1851        assert!(p.upload_window <= HARD_MAX_WINDOW as usize);
1852        assert!(p.download_window <= HARD_MAX_WINDOW as usize);
1853        assert!(p.chunk_size <= HARD_MAX_CHUNK_SIZE as u64);
1854        assert!(
1855            p.upload_in_flight() <= HARD_MAX_IN_FLIGHT_BYTES,
1856            "in-flight bytes {} exceed the budget",
1857            p.upload_in_flight()
1858        );
1859    }
1860
1861    #[test]
1862    fn inverted_advertised_range_does_not_panic() {
1863        let mut broken = caps();
1864        broken.limits.concurrency = IntRange {
1865            default: 4,
1866            max: 2,
1867            min: 8,
1868        };
1869        broken.limits.chunk_size = IntRange {
1870            default: 0,
1871            max: 0,
1872            min: 0,
1873        };
1874        // Both the static and the ramping paths must survive junk caps.
1875        let mut e = TuningEngine::new(false, CompressLevel::Balanced);
1876        e.set_direction(TransferKind::Upload);
1877        let p = e.begin_transfer(&broken, 0.0, &static_params());
1878        assert!(p.concurrency >= 1 && p.chunk_size >= 1);
1879        let mut e2 = TuningEngine::new(true, CompressLevel::Balanced);
1880        e2.set_direction(TransferKind::Upload);
1881        let p2 = e2.begin_transfer(&broken, 0.0, &static_params());
1882        assert!(p2.concurrency >= 1 && p2.chunk_size >= 1);
1883    }
1884
1885    #[test]
1886    fn ramp_raises_window_then_concurrency_until_cap() {
1887        let mut e = engine();
1888        // High-latency profile (400 ms RTT): BDP ≈ 5 MB at 100 Mbps, so the
1889        // ramp can climb the whole window dimension before saturating.
1890        // Deltas double every window (12.5 → 25 → 50 MB/s) → +100% gain.
1891        let mut t = 1_000.0;
1892        let mut done = 12_500_000u64;
1893        let mut delta = 12_500_000u64;
1894        for _ in 0..3 {
1895            let ev = e.tick(t, done, Some(400.0), false).expect("window closed");
1896            assert_eq!(ev.phase, TunePhase::Ramping);
1897            t += 1_000.0;
1898            delta *= 2;
1899            done += delta; // cumulative: 12.5M → 37.5M → 87.5M → ...
1900        }
1901        // Window: 1 → 2 → 4 → 8 (max). Next raise hits the cap → advance.
1902        assert_eq!(e.params().upload_window, 8);
1903        let ev = e.tick(t, done, Some(400.0), false).unwrap();
1904        assert_eq!(ev.params.upload_window, 8); // capped
1905        t += 1_000.0;
1906        delta *= 2;
1907        done += delta;
1908        // Concurrency now ramps: 1 → 2...
1909        let ev = e.tick(t, done, Some(400.0), false).unwrap();
1910        assert_eq!(ev.params.concurrency, 2);
1911        // ...and continues while the window dimension stays put.
1912        let ev = e.tick(t + 1_000.0, done + delta * 2, Some(400.0), false).unwrap();
1913        assert_eq!(ev.params.concurrency, 4);
1914        assert_eq!(ev.params.upload_window, 8);
1915    }
1916
1917    #[test]
1918    fn error_window_degrades_and_halves() {
1919        let mut e = engine();
1920        // Ramp the window up first (400 ms RTT keeps BDP above in-flight).
1921        let mut t = 1_000.0;
1922        let mut done = 12_500_000u64;
1923        let mut delta = 12_500_000u64;
1924        for _ in 0..2 {
1925            e.tick(t, done, Some(400.0), false);
1926            t += 1_000.0;
1927            delta *= 2;
1928            done += delta; // 12.5M → 37.5M: deltas double → +100% gain
1929        }
1930        assert_eq!(e.params().upload_window, 4);
1931        // Error window → degrade: 4 × 0.5 = 2.
1932        let ev = e.tick(t, done, Some(400.0), true).unwrap();
1933        assert_eq!(ev.phase, TunePhase::Degraded);
1934        assert_eq!(ev.params.upload_window, 2);
1935        // Two stable windows → conservative settle (BDP is NOT evaluated in
1936        // the degraded hold — near-zero traffic must not look "saturated").
1937        let ev = e.tick(t + 1_000.0, done + 1_000, None, false).unwrap();
1938        assert_eq!(ev.phase, TunePhase::Degraded);
1939        let ev = e.tick(t + 2_000.0, done + 2_000, None, false).unwrap();
1940        assert_eq!(ev.phase, TunePhase::Settled);
1941        assert_eq!(ev.params.upload_window, 2);
1942    }
1943
1944    #[test]
1945    fn flat_gain_explores_dimensions_then_settles() {
1946        let mut e = engine();
1947        // Constant throughput after the first raise: every dimension gets its
1948        // 2 flat windows and the ramp then moves on to the next one instead of
1949        // settling. The chunk size is not a ramp dimension — it follows the
1950        // measured link (see the `chunk_for_link` tests).
1951        let step = 12_500_000u64;
1952        let mut done = step;
1953        let mut t = 1_000.0;
1954
1955        // Window: one pseudo-gain raise, then 2 flat windows → explore.
1956        let ev = e.tick(t, done, Some(400.0), false).unwrap();
1957        assert_eq!(ev.params.upload_window, 2);
1958        for _ in 0..2 {
1959            t += 1_000.0;
1960            done += step;
1961            e.tick(t, done, Some(400.0), false);
1962        }
1963        assert_eq!(
1964            e.phase(),
1965            TunePhase::Ramping,
1966            "a flat window dimension must not end the ramp"
1967        );
1968
1969        // Cross-file concurrency is the last dimension → 2 flat windows settle.
1970        t += 1_000.0;
1971        done += step;
1972        let ev = e.tick(t, done, Some(400.0), false).unwrap();
1973        assert_eq!(ev.phase, TunePhase::Ramping);
1974        t += 1_000.0;
1975        done += step;
1976        let ev = e.tick(t, done, Some(400.0), false).unwrap();
1977        assert_eq!(ev.phase, TunePhase::Settled);
1978        assert_eq!(ev.params.upload_window, 2);
1979        e.transfer_end(true);
1980    }
1981
1982    #[test]
1983    fn chunk_size_tracks_the_link_and_ignores_jitter() {
1984        let mut e = engine();
1985        // First window ≈100 Mb/s → 100 ms of it, 64 KiB-aligned.
1986        let mut done = 12_500_000u64;
1987        let ev = e.tick(1_000.0, done, Some(400.0), false).unwrap();
1988        let first = ev.params.chunk_size;
1989        assert_eq!(first, 1_245_184, "the chunk must follow the measured link");
1990
1991        // ±10 % jitter must not move it (the deadband absorbs it, and every
1992        // move re-aligns the in-flight block list).
1993        let mut t = 1_000.0;
1994        for _ in 0..3 {
1995            t += 1_000.0;
1996            done += 13_500_000; // ≈108 Mb/s
1997            let ev = e.tick(t, done, Some(400.0), false).unwrap();
1998            assert_eq!(ev.params.chunk_size, first, "jitter must not re-size the chunk");
1999        }
2000
2001        // A clearly wider band does grow it.
2002        t += 1_000.0;
2003        done += 100_000_000; // ≈800 Mb/s
2004        let ev = e.tick(t, done, Some(400.0), false).unwrap();
2005        assert!(ev.params.chunk_size > first, "the chunk must grow on a wider band");
2006    }
2007
2008    #[test]
2009    fn chunk_size_follows_the_measured_link() {
2010        let range = IntRange {
2011            min: 262_144,
2012            default: 2 * 1024 * 1024,
2013            max: 8 * 1024 * 1024,
2014        };
2015        let budget = 8 * 1024 * 1024;
2016        // Unknown link → the advertised minimum (never a guess).
2017        assert_eq!(chunk_for_link(0.0, &range, budget), 262_144);
2018        // Narrow/slow link: still the min (100 ms of 10 Mb/s < 256 KiB).
2019        assert_eq!(chunk_for_link(10.0, &range, budget), 262_144);
2020        // Wider band → bigger chunk (100 ms of the link, 64 KiB-aligned).
2021        assert_eq!(chunk_for_link(30.0, &range, budget), 327_680);
2022        assert_eq!(chunk_for_link(100.0, &range, budget), 1_245_184);
2023        // …capped by the advertised max…
2024        assert_eq!(chunk_for_link(1_000.0, &range, budget), 8 * 1024 * 1024);
2025        // …and by the in-flight byte budget.
2026        assert_eq!(chunk_for_link(1_000.0, &range, 1_500_000), 1_500_000);
2027        // Monotonic in bandwidth, and never below the advertised minimum.
2028        let mut last = 0;
2029        for mbps in [1.0, 5.0, 20.0, 80.0, 320.0] {
2030            let v = chunk_for_link(mbps, &range, budget);
2031            assert!(v >= last && v >= 262_144, "{mbps} Mb/s → {v}");
2032            last = v;
2033        }
2034        // Junk (NaN / inverted range) must not panic or return 0.
2035        assert!(chunk_for_link(f64::NAN, &range, budget) >= 1);
2036        assert!(chunk_for_link(-5.0, &range, budget) >= 1);
2037        let inverted = IntRange {
2038            min: 9_000_000,
2039            max: 1,
2040            default: 0,
2041        };
2042        let v = chunk_for_link(50.0, &inverted, budget);
2043        assert!(v >= 1 && v <= budget, "{v}");
2044    }
2045
2046    #[test]
2047    fn settled_state_is_reused_within_the_same_client() {
2048        let mut e = engine();
2049        ramp_to_settle(&mut e);
2050        assert_eq!(e.phase(), TunePhase::Settled);
2051        let tuned = e.params().clone();
2052        e.transfer_end(true);
2053        // A follow-up transfer on the same client does not pay for another
2054        // ramp: the settle of the previous transfer is reused.
2055        let p = e.begin_transfer(&caps(), 60_000.0, &static_params());
2056        assert_eq!(e.phase(), TunePhase::Settled, "in-session reuse skips the ramp");
2057        assert_eq!(p.upload_window, tuned.upload_window);
2058        assert_eq!(p.concurrency, tuned.concurrency);
2059        assert_eq!(p.chunk_size, tuned.chunk_size);
2060        assert_eq!(p.compress_level, 1, "reuse keeps the configured policy level");
2061    }
2062
2063    #[test]
2064    fn a_new_engine_always_re_ramps_from_mins() {
2065        // With no store installed there is nothing to persist: a fresh client
2066        // re-measures the link from scratch. (The browser engine installs a
2067        // `localStorage` store — see the cache tests below.)
2068        let mut e = engine();
2069        ramp_to_settle(&mut e);
2070        e.transfer_end(true);
2071        let fresh = engine();
2072        assert_eq!(fresh.phase(), TunePhase::Ramping);
2073        assert_eq!(fresh.params().upload_window, 1);
2074        assert_eq!(fresh.params().chunk_size, 262_144);
2075    }
2076
2077    // --- persisted (browser) tuning cache -----------------------------------
2078
2079    /// In-memory `TuneStore` standing in for `localStorage`.
2080    #[derive(Clone, Default)]
2081    struct FakeStore(Rc<RefCell<std::collections::HashMap<String, String>>>);
2082
2083    impl FakeStore {
2084        fn len(&self) -> usize {
2085            self.0.borrow().len()
2086        }
2087        fn put_raw(&self, key: &str, value: &str) {
2088            self.0.borrow_mut().insert(key.to_string(), value.to_string());
2089        }
2090        fn keys(&self) -> Vec<String> {
2091            self.0.borrow().keys().cloned().collect()
2092        }
2093    }
2094
2095    impl TuneStore for FakeStore {
2096        fn get(&self, key: &str) -> Option<String> {
2097            self.0.borrow().get(key).cloned()
2098        }
2099        fn set(&self, key: &str, value: &str) {
2100            self.0.borrow_mut().insert(key.to_string(), value.to_string());
2101        }
2102        fn remove(&self, key: &str) {
2103            self.0.borrow_mut().remove(key);
2104        }
2105    }
2106
2107    const ORIGIN: &str = "http://localhost:8080";
2108
2109    /// An engine wired to `store`, pointed at `origin`, with no transfer yet.
2110    ///
2111    /// Used for the *reading* side of the cache: starting a transfer here at a
2112    /// bogus `now_ms` would look like a clock jump and evict the row.
2113    fn wired(
2114        store: &FakeStore,
2115        ttl_ms: u64,
2116        direction: TransferKind,
2117        origin: &str,
2118    ) -> TuningEngine {
2119        let mut e = TuningEngine::new(true, CompressLevel::Balanced);
2120        e.set_cache(Rc::new(store.clone()), ttl_ms);
2121        e.set_direction(direction);
2122        e.set_origin(origin);
2123        e
2124    }
2125
2126    /// The same engine, with an upload transfer already in flight at t = 0.
2127    fn cached_engine(store: &FakeStore, ttl_ms: u64) -> TuningEngine {
2128        let mut e = wired(store, ttl_ms, TransferKind::Upload, ORIGIN);
2129        e.begin_transfer(&caps(), 0.0, &static_params());
2130        e
2131    }
2132
2133    #[test]
2134    fn a_settle_is_cached_and_reused_by_a_new_engine() {
2135        let store = FakeStore::default();
2136        let mut a = cached_engine(&store, DEFAULT_TUNE_TTL_MS);
2137        ramp_to_settle(&mut a);
2138        assert_eq!(a.phase(), TunePhase::Settled);
2139        let tuned = a.params().clone();
2140        a.transfer_end(true);
2141        assert_eq!(store.len(), 1, "the settle is written on convergence");
2142        assert_eq!(
2143            store.keys(),
2144            vec![tune_key(ORIGIN, TransferKind::Upload)],
2145            "keyed by origin + direction"
2146        );
2147
2148        // A *new* engine (i.e. a page refresh) reuses it instead of ramping.
2149        let mut b = wired(&store, DEFAULT_TUNE_TTL_MS, TransferKind::Upload, ORIGIN);
2150        let p = b.begin_transfer(&caps(), 20_000.0, &static_params());
2151        assert_eq!(b.phase(), TunePhase::Settled, "a cache hit skips the ramp");
2152        assert_eq!(p.upload_window, tuned.upload_window);
2153        assert_eq!(p.concurrency, tuned.concurrency);
2154        assert_eq!(p.chunk_size, tuned.chunk_size);
2155        assert_eq!(p.compress_level, 1, "the policy level is not cached");
2156    }
2157
2158    #[test]
2159    fn a_cached_settle_expires_after_the_ttl() {
2160        let store = FakeStore::default();
2161        let ttl = 60_000;
2162        let mut a = cached_engine(&store, ttl);
2163        ramp_to_settle(&mut a);
2164        let saved = a.params().clone();
2165        assert!(saved.upload_window > 1, "the ramp must have raised something");
2166        a.transfer_end(true);
2167
2168        // The TTL counts from the settle, so the boundary is exact.
2169        let row: TuneCache = serde_json::from_str(
2170            &store.get(&tune_key(ORIGIN, TransferKind::Upload)).unwrap(),
2171        )
2172        .unwrap();
2173        let saved_at = row.saved_at_ms as f64;
2174
2175        // Just inside the TTL → reuse.
2176        let mut b = wired(&store, ttl, TransferKind::Upload, ORIGIN);
2177        b.begin_transfer(&caps(), saved_at + ttl as f64 - 1.0, &static_params());
2178        assert_eq!(b.phase(), TunePhase::Settled);
2179
2180        // Past the TTL → re-ramp, and the stale row is dropped.
2181        let mut c = wired(&store, ttl, TransferKind::Upload, ORIGIN);
2182        let p = c.begin_transfer(&caps(), saved_at + ttl as f64 + 1.0, &static_params());
2183        assert_eq!(c.phase(), TunePhase::Ramping);
2184        assert_eq!(p.upload_window, 1, "an expired result re-ramps from the mins");
2185        assert_eq!(store.len(), 0, "an expired row is cleaned up");
2186    }
2187
2188    #[test]
2189    fn a_zero_ttl_disables_the_cache() {
2190        let store = FakeStore::default();
2191        assert_eq!(cached_engine(&store, 0).cache_ttl_ms(), 0);
2192        let mut a = cached_engine(&store, 0);
2193        ramp_to_settle(&mut a);
2194        a.transfer_end(true);
2195        assert_eq!(store.len(), 0, "ttl = 0 never writes");
2196
2197        // An existing row is ignored as well.
2198        let store2 = FakeStore::default();
2199        let mut x = cached_engine(&store2, DEFAULT_TUNE_TTL_MS);
2200        ramp_to_settle(&mut x);
2201        x.transfer_end(true);
2202        assert_eq!(store2.len(), 1);
2203        let mut y = wired(&store2, 0, TransferKind::Upload, ORIGIN);
2204        y.begin_transfer(&caps(), 20_000.0, &static_params());
2205        assert_eq!(y.phase(), TunePhase::Ramping, "ttl = 0 never reads");
2206    }
2207
2208    #[test]
2209    fn a_cached_settle_is_scoped_to_one_direction() {
2210        let store = FakeStore::default();
2211        let mut a = cached_engine(&store, DEFAULT_TUNE_TTL_MS);
2212        ramp_to_settle(&mut a);
2213        a.transfer_end(true);
2214
2215        // The ramped upload dimensions say nothing about a download.
2216        let mut b = wired(&store, DEFAULT_TUNE_TTL_MS, TransferKind::Download, ORIGIN);
2217        let p = b.begin_transfer(&caps(), 20_000.0, &static_params());
2218        assert_eq!(b.phase(), TunePhase::Ramping);
2219        assert_eq!(p.download_window, 1);
2220
2221        // …but the upload direction still hits.
2222        let mut c = wired(&store, DEFAULT_TUNE_TTL_MS, TransferKind::Upload, ORIGIN);
2223        c.begin_transfer(&caps(), 20_000.0, &static_params());
2224        assert_eq!(c.phase(), TunePhase::Settled);
2225    }
2226
2227    #[test]
2228    fn a_cached_settle_is_scoped_to_one_origin() {
2229        let store = FakeStore::default();
2230        let mut a = cached_engine(&store, DEFAULT_TUNE_TTL_MS);
2231        ramp_to_settle(&mut a);
2232        a.transfer_end(true);
2233
2234        let mut b = wired(
2235            &store,
2236            DEFAULT_TUNE_TTL_MS,
2237            TransferKind::Upload,
2238            "https://other.example.com:8443",
2239        );
2240        b.begin_transfer(&caps(), 20_000.0, &static_params());
2241        assert_eq!(b.phase(), TunePhase::Ramping, "another origin is another link");
2242        assert_eq!(store.len(), 1, "the other origin's row is left alone");
2243    }
2244
2245    #[test]
2246    fn an_unusable_origin_disables_the_cache() {
2247        // Only http(s) URLs with an authority are cacheable: a bad key would
2248        // mean reusing another server's link.
2249        let store = FakeStore::default();
2250        for url in ["", "not a url", "ftp://host/x", "http://", "ws://h:80"] {
2251            let mut e = wired(&store, DEFAULT_TUNE_TTL_MS, TransferKind::Upload, url);
2252            let _ = e.begin_transfer(&caps(), 0.0, &static_params());
2253            ramp_to_settle(&mut e);
2254            e.transfer_end(true);
2255            assert_eq!(store.len(), 0, "{url:?} must not be cached");
2256        }
2257    }
2258
2259    #[test]
2260    fn changed_caps_invalidate_a_cached_settle() {
2261        let store = FakeStore::default();
2262        let mut a = cached_engine(&store, DEFAULT_TUNE_TTL_MS);
2263        ramp_to_settle(&mut a);
2264        a.transfer_end(true);
2265        assert_eq!(store.len(), 1);
2266
2267        let mut changed = caps();
2268        changed.limits.concurrency.default = 6;
2269        let mut b = wired(&store, DEFAULT_TUNE_TTL_MS, TransferKind::Upload, ORIGIN);
2270        let p = b.begin_transfer(&changed, 20_000.0, &static_params());
2271        assert_eq!(b.phase(), TunePhase::Ramping);
2272        assert_eq!(p.upload_window, 1);
2273        assert_eq!(store.len(), 0, "a caps mismatch drops the entry");
2274    }
2275
2276    #[test]
2277    fn a_failed_transfer_removes_the_cached_settle() {
2278        let store = FakeStore::default();
2279        let mut a = cached_engine(&store, DEFAULT_TUNE_TTL_MS);
2280        ramp_to_settle(&mut a);
2281        a.transfer_end(false);
2282        assert_eq!(store.len(), 0, "parameters that just failed are not kept");
2283
2284        // Sanity: the same engine re-ramps rather than replaying the failure.
2285        let p = a.begin_transfer(&caps(), 20_000.0, &static_params());
2286        assert_eq!(a.phase(), TunePhase::Ramping);
2287        assert_eq!(p.upload_window, 1);
2288    }
2289
2290    #[test]
2291    fn a_corrupt_or_foreign_cache_entry_is_dropped() {
2292        let store = FakeStore::default();
2293        let key = tune_key(ORIGIN, TransferKind::Upload);
2294
2295        // Not JSON at all.
2296        store.put_raw(&key, "{not json");
2297        let mut e = wired(&store, DEFAULT_TUNE_TTL_MS, TransferKind::Upload, ORIGIN);
2298        e.begin_transfer(&caps(), 20_000.0, &static_params());
2299        assert_eq!(e.phase(), TunePhase::Ramping);
2300        assert_eq!(store.len(), 0, "an unreadable row is removed");
2301
2302        // A future layout (version bump) is ignored, not mis-parsed.
2303        let future = TuneCache {
2304            v: TUNE_CACHE_VERSION + 1,
2305            caps_hash: caps().caps_hash(),
2306            saved_at_ms: 1_000,
2307            params: TuneParamsCache {
2308                concurrency: 12,
2309                upload_window: 16,
2310                download_window: 16,
2311                chunk_size: 4 * 1024 * 1024,
2312            },
2313        };
2314        store.put_raw(&key, &serde_json::to_string(&future).unwrap());
2315        let mut e = wired(&store, DEFAULT_TUNE_TTL_MS, TransferKind::Upload, ORIGIN);
2316        e.begin_transfer(&caps(), 20_000.0, &static_params());
2317        assert_eq!(e.phase(), TunePhase::Ramping);
2318        assert_eq!(store.len(), 0);
2319    }
2320
2321    #[test]
2322    fn a_valid_row_written_by_another_client_is_reused() {
2323        // The row format is the contract between sessions: a literal entry
2324        // (as `localStorage` would hold it) must load.
2325        let store = FakeStore::default();
2326        let row = TuneCache {
2327            v: TUNE_CACHE_VERSION,
2328            caps_hash: caps().caps_hash(),
2329            saved_at_ms: 5_000,
2330            params: TuneParamsCache {
2331                concurrency: 3,
2332                upload_window: 7,
2333                download_window: 3,
2334                chunk_size: 1_048_576,
2335            },
2336        };
2337        store.put_raw(
2338            &tune_key(ORIGIN, TransferKind::Upload),
2339            &serde_json::to_string(&row).unwrap(),
2340        );
2341        let mut e = wired(&store, DEFAULT_TUNE_TTL_MS, TransferKind::Upload, ORIGIN);
2342        let p = e.begin_transfer(&caps(), 10_000.0, &static_params());
2343        assert_eq!(e.phase(), TunePhase::Settled);
2344        assert_eq!(p.concurrency, 3);
2345        assert_eq!(p.upload_window, 7);
2346        assert_eq!(p.chunk_size, 1_048_576);
2347        assert_eq!(store.len(), 1, "a fresh, valid row is kept");
2348    }
2349
2350    #[test]
2351    fn tune_key_and_origin_of_normalise_the_scope() {
2352        assert_eq!(
2353            tune_key("http://localhost:8080", TransferKind::Upload),
2354            "libfw.tune.v1.upload.http://localhost:8080"
2355        );
2356        assert_eq!(
2357            tune_key("http://localhost:8080", TransferKind::Download),
2358            "libfw.tune.v1.download.http://localhost:8080"
2359        );
2360        assert_ne!(
2361            tune_key("http://a", TransferKind::Upload),
2362            tune_key("http://a", TransferKind::Download)
2363        );
2364
2365        assert_eq!(origin_of("http://Host:8080/x?y#z"), "http://host:8080");
2366        assert_eq!(origin_of("HTTPS://h/p"), "https://h");
2367        assert_eq!(origin_of("http://h"), "http://h");
2368        // Anything unusable → empty (caching off).
2369        assert_eq!(origin_of(""), "");
2370        assert_eq!(origin_of("/relative/path"), "");
2371        assert_eq!(origin_of("ftp://h/x"), "");
2372        assert_eq!(origin_of("http://"), "");
2373    }
2374
2375    #[test]
2376    fn failed_transfer_forgets_the_settled_state() {
2377        let mut e = engine();
2378        ramp_to_settle(&mut e);
2379        e.transfer_end(false);
2380        // Parameters that just failed must not be reused blindly.
2381        let p = e.begin_transfer(&caps(), 60_000.0, &static_params());
2382        assert_eq!(e.phase(), TunePhase::Ramping);
2383        assert_eq!(p.upload_window, 1, "a failed transfer re-ramps from the mins");
2384    }
2385
2386    #[test]
2387    fn changed_caps_invalidate_the_settled_state() {
2388        let mut e = engine();
2389        ramp_to_settle(&mut e);
2390        e.transfer_end(true);
2391        // A server that changed its advertisement invalidates the settle.
2392        let mut changed = caps();
2393        changed.limits.concurrency.default = 6;
2394        let p = e.begin_transfer(&changed, 60_000.0, &static_params());
2395        assert_eq!(e.phase(), TunePhase::Ramping);
2396        assert_eq!(p.upload_window, 1);
2397    }
2398
2399    #[test]
2400    fn reused_settle_does_not_re_ramp_on_later_windows() {
2401        let mut e = engine();
2402        ramp_to_settle(&mut e);
2403        let tuned = e.params().clone();
2404        e.transfer_end(true);
2405        e.begin_transfer(&caps(), 60_000.0, &static_params());
2406        // Several windows of a faster link must not restart the ramp: reuse is
2407        // a settled state, not a new exploration. (The chunk size may still
2408        // follow the measured link — it is sized, not ramped.)
2409        let mut t = 61_000.0;
2410        for i in 1..=3u64 {
2411            let ev = e.tick(t, i * 100_000_000, Some(400.0), false).unwrap();
2412            assert_eq!(ev.phase, TunePhase::Settled);
2413            assert_eq!(ev.params.upload_window, tuned.upload_window);
2414            assert_eq!(ev.params.concurrency, tuned.concurrency);
2415            t += 1_000.0;
2416        }
2417    }
2418
2419    #[test]
2420    fn transfer_end_without_settle_keeps_ramping_next_time() {
2421        let mut e = engine();
2422        e.tick(1_000.0, 12_500_000, Some(400.0), false);
2423        e.transfer_end(true);
2424        // One window is not a convergence: the next transfer re-ramps.
2425        let p = e.begin_transfer(&caps(), 60_000.0, &static_params());
2426        assert_eq!(e.phase(), TunePhase::Ramping);
2427        assert_eq!(p.upload_window, 1);
2428    }
2429
2430    #[test]
2431    fn upload_auto_level_benchmarks_once() {
2432        let mut e = TuningEngine::new(true, CompressLevel::Auto);
2433        e.begin_transfer(&caps(), 0.0, &static_params());
2434        let sample = vec![0xABu8; 256 * 1024];
2435        let lvl1 = e.upload_compress_level(&caps(), Some(&sample), 50.0);
2436        let lvl2 = e.upload_compress_level(&caps(), Some(&sample), 50.0);
2437        assert_eq!(lvl1, lvl2, "benchmark runs once, result cached");
2438        assert!(e.auto_level_ready());
2439        assert!((-8..=4).contains(&lvl1), "level inside advertised range, got {lvl1}");
2440        // Incompressible sample → cheapest level.
2441        let mut e2 = TuningEngine::new(true, CompressLevel::Auto);
2442        e2.begin_transfer(&caps(), 0.0, &static_params());
2443        let lvl = e2.upload_compress_level(&caps(), Some(&[0u8; 0]), 50.0);
2444        assert_eq!(lvl, caps().compression.zrip_levels.default, "no sample → server default");
2445    }
2446
2447    #[test]
2448    fn disabled_engine_uses_static_params() {
2449        let mut e = TuningEngine::new(false, CompressLevel::Balanced);
2450        e.set_direction(TransferKind::Upload);
2451        let p = e.begin_transfer(&caps(), 0.0, &static_params());
2452        assert_eq!(p.concurrency, 4, "static config preserved");
2453        assert_eq!(p.upload_window, 8);
2454        assert_eq!(e.phase(), TunePhase::Uninitialized);
2455        // Ticks are no-ops.
2456        assert!(e.tick(1_000.0, 12_500_000, Some(40.0), false).is_none());
2457        assert_eq!(p, static_params().clamped_into(&caps()));
2458    }
2459}