Skip to main content

hya_core/
sched.rs

1//! The scheduler kernel: pure state machine, no I/O, no clock, no allocation
2//! in the steady state.
3//!
4//! The caller drives it: feed observations (`on_bytes`, `on_complete`), call
5//! `tick(now)`, and act on the returned `Action`s. This is what lets the same
6//! code run under the discrete-event simulator and under real HTTP.
7//!
8//! Implements dynamic range partitioning, divergence-triggered steal-to-equalize,
9//! work-conserving assignment, queue dispatch, stall reclamation, and greedy concurrency.
10
11use crate::intervals::{IntervalSet, Range};
12
13/// Minimum steal quantum. A range rebalance smaller than this is not worth request overhead.
14pub const STEAL_QUANTUM: u64 = 64 * 1024;
15
16/// Bounded repairs per tick, so a tick is O(R * n).
17const MAX_REPAIRS_PER_TICK: usize = 4;
18
19/// EWMA weight on the newest goodput sample.
20const RATE_ALPHA: f64 = 0.3;
21
22/// Minimum wall clock a rate sample must span, in seconds.
23///
24/// Below this the quotient is dominated by socket buffering rather than by the
25/// link: consecutive `read()` calls draining one already-arrived TCP window return
26/// in microseconds and imply a rate the network never achieved. 200 ms is long
27/// enough to average over several windows and short enough that a genuine collapse
28/// is still graded within the stall timeout.
29const RATE_WINDOW: f64 = 0.2;
30
31#[derive(Clone, Copy, PartialEq, Eq, Debug)]
32pub enum Action {
33    /// Issue `GET` with `Range: bytes=lo-(hi-1)` on this connection.
34    Request { conn: usize, range: Range },
35    /// Stop reading this connection's current response; its range was reclaimed.
36    Cancel { conn: usize },
37    /// The far end of this connection's in-flight range moved DOWN to `hi`: a
38    /// repair handed the tail `[hi, old_hi)` to another connection. Stop reading
39    /// at `hi`.
40    ///
41    /// # Why this action has to exist
42    ///
43    /// The whole claim of this scheduler is that shrinking a laggard's range is
44    /// free, because an HTTP range request names both ends and the far end is
45    /// enforced by the client. That is true of the protocol. It was NOT true of
46    /// this implementation: the repair below moved `conns[vi].range` and emitted
47    /// nothing, while the transport's fetch loop runs `while off < hi` against
48    /// the `hi` it captured when the request was spawned. The victim therefore
49    /// kept pulling the bytes it had just been relieved of, at the same time as
50    /// the taker pulled them, over the same bottleneck.
51    ///
52    /// So each repair cost roughly one stolen span of duplicated traffic instead
53    /// of nothing, and since the duplicate traffic slowed the honest
54    /// connections, it manufactured the very divergence that triggers a repair.
55    /// That positive feedback loop is the measured "repair storm": at n=8 on a
56    /// stationary 5.3 MB transfer, 32-49 repairs where the correct count is 0,
57    /// with in-run throughput decaying 439 -> 306 KiB/s.
58    ///
59    /// A caller that ignores this action is not merely leaving an optimisation
60    /// on the table; it reintroduces the storm.
61    Shrink { conn: usize, hi: u64 },
62}
63
64#[derive(Clone, Copy, PartialEq, Eq, Debug)]
65pub enum Capability {
66    /// Ranges honoured, length known, strong validator: full scheduling.
67    Full,
68    /// Ranges honoured but no validator: partition, but pin to one source.
69    NoValidator,
70    /// Ranges ignored or unsupported: race whole-object fetches.
71    Race,
72    /// Length unknown: single stream per source, no range arithmetic.
73    Stream,
74}
75
76#[derive(Clone, Debug)]
77pub struct Source {
78    pub caps: Capability,
79    /// Per-connection goodput ceiling estimate, bytes/s.
80    pub gamma_est: f64,
81    /// Per-source shaping cap estimate, bytes/s.
82    pub rho_est: f64,
83    /// Measured request setup cost, seconds.
84    pub delta_est: f64,
85    /// Suspended until this time (429/503 Retry-After, or stall backoff).
86    pub suspended_until: f64,
87    /// Consecutive stalls observed on this source; drives exponential backoff.
88    pub consecutive_stalls: u32,
89}
90
91impl Default for Source {
92    fn default() -> Self {
93        Source {
94            caps: Capability::Full,
95            gamma_est: 0.0,
96            rho_est: f64::INFINITY,
97            delta_est: 0.05,
98            suspended_until: 0.0,
99            consecutive_stalls: 0,
100        }
101    }
102}
103
104#[derive(Clone, Debug)]
105struct Conn {
106    source: usize,
107    /// Active range and how far into it we are.
108    range: Option<Range>,
109    pos: u64,
110    /// One-slot pipeline: a range handed over by a repair.
111    queued: Option<Range>,
112    rate_est: f64,
113    /// Changepoint detector. `rate_est` remains the smoothed rate used for ETA
114    /// projection; this grades the connection so repair can pre-empt a collapse
115    /// instead of waiting for the stall timeout (see `detect.rs`).
116    detector: crate::detect::CollapseDetector,
117    last_progress: f64,
118    setup_end: f64,
119    stalled: bool,
120    /// Bytes and wall clock accumulated since the last RATE sample.
121    ///
122    /// Rate is measured over a fixed WINDOW, not per arrival. An arrival is one
123    /// `read()` return, and a read served from the socket's already-buffered data
124    /// completes in microseconds, so `bytes/dt` for that arrival measures memcpy
125    /// speed rather than network speed — observed as 128 MiB/s on a connection
126    /// whose link was doing well under 1 MiB/s.
127    ///
128    /// That is not merely a cosmetic display bug. Those inflated samples raise the
129    /// detector's reference level, after which every honest sample looks like a
130    /// collapse against it, and the CUSUM grades a perfectly healthy connection
131    /// `Degraded` — which is why all eight connections of a working transfer
132    /// showed as `bad`. Byte accounting stays exactly per-arrival (coverage must
133    /// be exact); only the rate estimate is windowed.
134    rate_acc_bytes: u64,
135    rate_acc_dt: f64,
136}
137
138impl Conn {
139    fn new(source: usize) -> Self {
140        Conn {
141            source,
142            range: None,
143            pos: 0,
144            queued: None,
145            rate_est: 0.0,
146            detector: crate::detect::CollapseDetector::new(),
147            rate_acc_bytes: 0,
148            rate_acc_dt: 0.0,
149            last_progress: 0.0,
150            setup_end: 0.0,
151            stalled: false,
152        }
153    }
154
155    #[inline]
156    fn busy(&self) -> bool {
157        self.range.map(|r| self.pos < r.hi).unwrap_or(false)
158    }
159
160    /// Bytes still owed on the active range plus anything pipelined.
161    #[inline]
162    fn outstanding(&self) -> u64 {
163        let active = self
164            .range
165            .map(|r| r.hi.saturating_sub(self.pos))
166            .unwrap_or(0);
167        active + self.queued.map(|r| r.len()).unwrap_or(0)
168    }
169
170    /// Projected seconds to drain. A stalled or unmeasured connection projects
171    /// to infinity so it is always chosen as the repair victim.
172    fn eta(&self) -> f64 {
173        let out = self.outstanding();
174        if out == 0 {
175            return 0.0;
176        }
177        if self.rate_est <= 0.0 {
178            return f64::INFINITY;
179        }
180        out as f64 / self.rate_est
181    }
182}
183
184#[derive(Clone, Copy, Debug, Default)]
185pub struct Stats {
186    pub requests: u64,
187    pub repairs: u64,
188    pub reclaims: u64,
189    pub bytes_held: u64,
190}
191
192pub struct Scheduler {
193    size: u64,
194    unassigned: IntervalSet,
195    held: u64,
196    conns: Vec<Conn>,
197    sources: Vec<Source>,
198    /// Repair deadband scale; theta = scale * sqrt(delta * T_rem / n).
199    /// Reused index buffer for the per-tick stalled-connection scan.
200    ///
201    /// The scan runs 50 times a second at the default tick and allocated a fresh `Vec`
202    /// each time, to hold at most `n_conns` indices. Reusing one buffer costs a field
203    /// and removes the allocation from the hot loop.
204    scratch_idx: Vec<usize>,
205    theta_scale: f64,
206    stall_timeout: f64,
207    /// How many connections may hold work at once. Adjustable mid-transfer so the
208    /// concurrency search can run on the real transfer rather than on probe
209    /// traffic; see `set_active_limit`.
210    active_limit: usize,
211    /// When false, victim selection ignores detector health and ranks purely by
212    /// projected ETA (the pre-detector behaviour). Exists so the detector's
213    /// contribution can be A/B measured rather than assumed.
214    health_ranking: bool,
215    started: bool,
216    pub stats: Stats,
217}
218
219impl Scheduler {
220    pub fn new(size: u64, sources: Vec<Source>, conns_per_source: &[usize]) -> Self {
221        let mut conns = Vec::new();
222        for (i, &k) in conns_per_source.iter().enumerate() {
223            for _ in 0..k {
224                conns.push(Conn::new(i));
225            }
226        }
227        Scheduler {
228            size,
229            unassigned: IntervalSet::full(size),
230            held: 0,
231            conns,
232            sources,
233            scratch_idx: Vec::new(),
234            theta_scale: 1.0,
235            stall_timeout: 1.0,
236            health_ranking: true,
237            // Default: every connection active, so nothing changes for callers that
238            // do not opt into the ramp.
239            active_limit: usize::MAX,
240            started: false,
241            stats: Stats::default(),
242        }
243    }
244
245    /// Cap how many connections may hold work at once, adjustable mid-transfer.
246    ///
247    /// # Why the concurrency search belongs here and not in a probe
248    ///
249    /// Finding the useful connection count by *probing* — fetch a slab with one
250    /// connection, then with two, then three, comparing goodput — is the standard
251    /// approach and it is what this client did. HARP (Kim, Yildirim, Kosar, SC'16)
252    /// names the cost directly: probing "may bring too much probing overhead",
253    /// because the samples are extra transfers whose price is paid before the real
254    /// one starts. Measured here on a 3.15 MB object over a live path, the climbing
255    /// probe made the transfer **1.96x slower** than not probing at all
256    /// (paired over 9 interleaved reps, p = 0.004) — the search cost more than the
257    /// concurrency it found could save.
258    ///
259    /// The probe is only necessary because concurrency is fixed when the transfer
260    /// starts. Make it adjustable and the same search runs on the *real* transfer:
261    /// start at one connection, measure aggregate goodput over a short window,
262    /// admit another connection while the marginal gain justifies it, and stop.
263    /// Every byte moved during the search is a byte of the object, so the search
264    /// is free — the object had to be fetched anyway. What HARP buys with a
265    /// historical corpus, this buys by putting the measurement in-band.
266    ///
267    /// Connections above the limit stay dormant: they are not given work and open
268    /// no socket. Raising the limit lets the next tick hand them work through the
269    /// ordinary work-conserving path, so no new admission machinery is needed.
270    pub fn set_active_limit(&mut self, n: usize) {
271        self.active_limit = n.clamp(1, self.conns.len().max(1));
272    }
273
274    /// The current concurrency cap.
275    pub fn active_limit(&self) -> usize {
276        self.active_limit
277    }
278
279    /// When every source is deliberately suspended, the earliest time one returns.
280    ///
281    /// `None` means at least one source is usable now, so a lack of progress is a
282    /// genuine stall. `Some(t)` means the scheduler has *chosen* to pause every
283    /// source until `t` — nothing can move before then, and that silence is planned
284    /// rather than pathological.
285    ///
286    /// # Why a caller must consult this
287    ///
288    /// The transport's no-progress watchdog exists to fail a transfer where nothing
289    /// will ever happen again. A scheduled retry is the opposite of that, and
290    /// conflating the two is not hypothetical: with one source (the common case —
291    /// one URL, one CDN), `stall_timeout` 4.0s gives a watchdog of
292    /// `4 * (4.0 + delta)` = 16.2s, while five consecutive stalls suspend that sole
293    /// source for `min(4.0 * 2^3, 30)` = 30s. The transfer is then killed at 16.2s
294    /// for failing to make progress it had itself forbidden.
295    ///
296    /// Measured consequence on a 121.7 MiB GitHub release asset: 4 of 8 runs at
297    /// `-x 8`/`-x 16` aborted with a digest mismatch, three of them having already
298    /// received 126.9-127.0 MB of 127.6 MB — 99.6% complete, killed during a
299    /// deliberate backoff over the last half-megabyte.
300    pub fn all_sources_suspended_until(&self, now: f64) -> Option<f64> {
301        let mut earliest = f64::INFINITY;
302        for s in &self.sources {
303            if s.suspended_until <= now {
304                return None;
305            }
306            earliest = earliest.min(s.suspended_until);
307        }
308        if earliest.is_finite() {
309            Some(earliest)
310        } else {
311            None
312        }
313    }
314
315    /// Whether any work is still unclaimed by any connection.
316    ///
317    /// Exposed so the ramp's contract is testable: while concurrency is below the
318    /// budget, work must remain here for connections admitted later to pick up.
319    pub fn unassigned_is_empty(&self) -> bool {
320        self.unassigned.is_empty()
321    }
322
323    /// How many connections currently hold a range.
324    pub fn busy_conns(&self) -> usize {
325        self.conns.iter().filter(|c| c.busy()).count()
326    }
327
328    /// Start with only `n` connections active, ramping up from there.
329    pub fn with_active_limit(mut self, n: usize) -> Self {
330        self.set_active_limit(n);
331        self
332    }
333
334    pub fn with_theta_scale(mut self, s: f64) -> Self {
335        self.theta_scale = s;
336        self
337    }
338
339    /// Disable health-ranked victim selection (for A/B measurement only).
340    pub fn with_health_ranking(mut self, on: bool) -> Self {
341        self.health_ranking = on;
342        self
343    }
344
345    pub fn with_stall_timeout(mut self, t: f64) -> Self {
346        self.stall_timeout = t;
347        self
348    }
349
350    /// Mark `[lo, hi)` as already held, for resuming a partial transfer.
351    ///
352    /// Must be called before the first `tick`: the initial split assigns all
353    /// unassigned work, and bytes already on disk must not be part of it.
354    pub fn mark_done(&mut self, lo: u64, hi: u64) {
355        let (lo, hi) = (lo.min(self.size), hi.min(self.size));
356        if hi <= lo {
357            return;
358        }
359        // Credit only the bytes this call actually claims, measured as the drop in
360        // the unassigned set — NOT the width of the span asked for.
361        //
362        // Callers legitimately overlap. A `-c` resume marks the sidecar's ranges
363        // held, and the concurrency probe separately reports the bytes it fetched;
364        // both start at offset 0, so the same prefix is marked twice. Crediting
365        // `hi - lo` each time made `held` exceed the bytes that exist, and `held`
366        // is what `is_complete()` tests: the transfer stopped early believing it
367        // was finished, leaving a zero-filled hole in the tail of a file reported
368        // as a success. Measured on an interrupted-then-resumed 11 200 900-byte
369        // object: 240 138 bytes of tail never written, `ok: true`, and the gzip
370        // refused to decompress.
371        let before = self.unassigned.total();
372        self.unassigned.remove(lo, hi);
373        let claimed = before.saturating_sub(self.unassigned.total());
374        self.held = self.held.saturating_add(claimed);
375    }
376
377    /// Health grade of a connection, for the progress UI and for tests.
378    pub fn conn_health(&self, j: usize) -> crate::detect::Health {
379        self.conns
380            .get(j)
381            .map(|c| c.detector.health())
382            .unwrap_or_default()
383    }
384
385    /// Source index a connection belongs to, for the progress UI.
386    pub fn conn_source(&self, j: usize) -> usize {
387        self.conns.get(j).map(|c| c.source).unwrap_or(0)
388    }
389
390    /// Smoothed rate estimate of a connection (bytes/s), for the progress UI.
391    pub fn conn_rate(&self, j: usize) -> f64 {
392        self.conns.get(j).map(|c| c.rate_est).unwrap_or(0.0)
393    }
394
395    /// Active range of a connection, for the progress UI.
396    pub fn conn_range(&self, j: usize) -> Option<(u64, u64, u64)> {
397        self.conns
398            .get(j)
399            .and_then(|c| c.range.map(|r| (r.lo, c.pos, r.hi)))
400    }
401
402    pub fn n_conns(&self) -> usize {
403        self.conns.len()
404    }
405
406    pub fn is_complete(&self) -> bool {
407        self.held >= self.size
408    }
409
410    pub fn bytes_held(&self) -> u64 {
411        self.held
412    }
413
414    /// The ranges that are complete on disk, as `(lo, hi)` pairs.
415    ///
416    /// This is the complement of the unassigned set minus what is still in flight, and
417    /// it is what a resume record must contain. Reporting only a byte COUNT is not
418    /// enough: positioned writes land ranges out of order, so "2 MB held" says nothing
419    /// about which 2 MB, and a resume that assumed a contiguous prefix would skip holes
420    /// and silently corrupt the file.
421    pub fn held_ranges(&self) -> Vec<(u64, u64)> {
422        // Start from everything, then subtract what is unassigned and what is
423        // outstanding on a connection; what remains has arrived.
424        let mut done = IntervalSet::full(self.size);
425        for r in self.unassigned.ranges() {
426            done.remove(r.lo, r.hi);
427        }
428        for c in &self.conns {
429            if let Some(r) = c.range {
430                // Bytes before the cursor have arrived; the rest has not.
431                done.remove(c.pos, r.hi);
432            }
433            if let Some(q) = c.queued {
434                done.remove(q.lo, q.hi);
435            }
436        }
437        done.ranges().iter().map(|r| (r.lo, r.hi)).collect()
438    }
439
440    /// Coverage audit: held + outstanding + unassigned == size.
441    ///
442    /// This is a SAFETY invariant and it does NOT imply liveness -- the
443    /// livelock this code is written to avoid (a fully-stolen range leaving a
444    /// connection idle with a non-empty queue) satisfies it at every instant.
445    /// `liveness_holds` is the property that matters.
446    /// The largest measured request setup cost across sources, in seconds.
447    ///
448    /// Exposed because a transport-layer watchdog must express its patience in
449    /// units of what a request actually costs on this path rather than as a
450    /// hardcoded constant: `delta` differs by an order of magnitude between a
451    /// LAN mirror and a TLS connection through a proxy, and a fixed timeout is
452    /// either trigger-happy on the slow path or useless on the fast one.
453    ///
454    /// This is the same quantity the repair deadband is built from
455    /// (`theta = scale * sqrt(delta * T_rem / n)`), so a client that widens
456    /// `delta` widens both together, which is the intended coupling.
457    pub fn worst_delta(&self) -> f64 {
458        self.sources
459            .iter()
460            .map(|s| s.delta_est)
461            .fold(0.0f64, f64::max)
462    }
463
464    /// The configured stall timeout, in seconds.
465    pub fn stall_timeout(&self) -> f64 {
466        self.stall_timeout
467    }
468
469    pub fn coverage_holds(&self) -> bool {
470        let outstanding: u64 = self.conns.iter().map(|c| c.outstanding()).sum();
471        self.held + outstanding + self.unassigned.total() == self.size
472            && self.unassigned.invariant_holds()
473    }
474
475    /// True when some enabled transition strictly decreases the unheld-byte count.
476    /// False means the scheduler is stuck.
477    pub fn liveness_holds(&self) -> bool {
478        if self.is_complete() {
479            return true;
480        }
481        // progress possible if: someone is receiving, or work is assignable,
482        // or a connection holds a queue it can start, or a stall can be reclaimed
483        self.conns.iter().any(|c| c.busy() && !c.stalled)
484            || !self.unassigned.is_empty()
485            || self.conns.iter().any(|c| c.queued.is_some())
486            || self.conns.iter().any(|c| c.stalled)
487    }
488
489    // ---------------------------------------------------------------- input
490
491    /// Record `n` bytes arriving on `conn` at time `now` over `dt` seconds.
492    ///
493    /// Convenience wrapper that assumes the arrival is contiguous at the
494    /// connection's cursor. Real transports must use [`Scheduler::on_bytes_at`]:
495    /// a response still draining from a range that was completed or stolen would
496    /// otherwise be credited against whatever range the connection holds NOW,
497    /// silently advancing a cursor over bytes that never arrived and leaving a
498    /// hole of zeros in the output file.
499    pub fn on_bytes(&mut self, conn: usize, n: u64, now: f64, dt: f64) {
500        let at = self.conns[conn].pos;
501        self.on_bytes_at(conn, at, n, now, dt);
502    }
503
504    /// Record `n` bytes that landed at absolute offset `off`.
505    ///
506    /// Arrivals that do not begin exactly at the connection's cursor are stale
507    /// (they belong to a superseded request) and are discarded: the bytes are
508    /// still written to the file by the transport, but they are not credited,
509    /// so the scheduler's coverage accounting stays exact.
510    pub fn on_bytes_at(&mut self, conn: usize, off: u64, n: u64, now: f64, dt: f64) {
511        let c = &mut self.conns[conn];
512        let Some(r) = c.range else { return };
513        if off != c.pos || off < r.lo {
514            return; // stale arrival from a superseded range
515        }
516        let room = r.hi.saturating_sub(c.pos);
517        let step = n.min(room);
518        if step == 0 {
519            return;
520        }
521        c.pos += step;
522        self.held += step;
523        c.last_progress = now;
524        c.stalled = false;
525        let src = c.source;
526        self.sources[src].consecutive_stalls = 0;
527        if dt > 0.0 {
528            // Accumulate, and only take a rate sample once the window has enough
529            // wall clock in it to mean something.
530            c.rate_acc_bytes += step;
531            c.rate_acc_dt += dt;
532            if c.rate_acc_dt >= RATE_WINDOW {
533                let sample = c.rate_acc_bytes as f64 / c.rate_acc_dt;
534                c.rate_acc_bytes = 0;
535                c.rate_acc_dt = 0.0;
536                c.detector.observe_rate(sample);
537                c.rate_est = if c.rate_est <= 0.0 {
538                    sample
539                } else {
540                    RATE_ALPHA * sample + (1.0 - RATE_ALPHA) * c.rate_est
541                };
542            }
543        }
544        if c.pos >= r.hi {
545            c.range = None;
546        }
547    }
548
549    /// Suspend a source (429/503 with Retry-After) and reclaim its ranges.
550    pub fn suspend_source(&mut self, src: usize, until: f64) {
551        self.sources[src].suspended_until = until;
552        let idxs: Vec<usize> = (0..self.conns.len())
553            .filter(|&j| self.conns[j].source == src)
554            .collect();
555        for j in idxs {
556            self.reclaim(j);
557        }
558    }
559
560    fn reclaim(&mut self, j: usize) {
561        let c = &mut self.conns[j];
562        if let Some(r) = c.range {
563            if c.pos < r.hi {
564                let back = Range::new(c.pos, r.hi);
565                c.range = None;
566                let q = c.queued.take();
567                self.unassigned.insert(back);
568                if let Some(q) = q {
569                    self.unassigned.insert(q);
570                }
571                self.stats.reclaims += 1;
572            } else {
573                c.range = None;
574            }
575        } else if let Some(q) = c.queued.take() {
576            self.unassigned.insert(q);
577            self.stats.reclaims += 1;
578        }
579        let c = &mut self.conns[j];
580        c.rate_est = 0.0;
581        c.stalled = true;
582    }
583
584    // ---------------------------------------------------------------- tick
585
586    /// Advance the scheduler. Returns the actions the caller must perform.
587    pub fn tick(&mut self, now: f64) -> Vec<Action> {
588        let mut acts = Vec::new();
589
590        if !self.started {
591            self.initial_split(now, &mut acts);
592            self.started = true;
593            return acts;
594        }
595
596        // ---- feed wall-clock silence to the detectors ----------------------
597        // A connection delivering nothing produces no rate samples at all, so
598        // silence is evidence that only the clock can supply. Grading it here
599        // lets repair pre-empt at half the stall timeout instead of waiting for
600        // the full timeout to expire.
601        for j in 0..self.conns.len() {
602            let c = &self.conns[j];
603            if c.busy() && now >= c.setup_end {
604                let quiet = now - c.last_progress.max(c.setup_end);
605                let st = self.stall_timeout;
606                self.conns[j].detector.observe_silence(quiet, st);
607            }
608        }
609
610        // ---- liveness path 1: reclaim stalled connections -----------------
611        //
612        // Collected into a reused buffer rather than a fresh `Vec` each tick. The
613        // indices cannot be reclaimed in the same pass that finds them — `reclaim`
614        // takes `&mut self` while the filter borrows `self.conns` — so the two-phase
615        // shape stays, but the allocation does not have to. `std::mem::take` moves the
616        // buffer out so the loop below can hold it while `self` is borrowed mutably,
617        // and it is put back at the end for the next tick.
618        let mut stalled = std::mem::take(&mut self.scratch_idx);
619        stalled.clear();
620        stalled.extend((0..self.conns.len()).filter(|&j| {
621            let c = &self.conns[j];
622            c.busy()
623                && now >= c.setup_end
624                && (now - c.last_progress.max(c.setup_end)) > self.stall_timeout
625        }));
626        for j in stalled.drain(..) {
627            self.reclaim(j);
628            acts.push(Action::Cancel { conn: j });
629            // A source that keeps stalling must be suspended, not merely
630            // retried: otherwise work-conserving assignment hands it the same
631            // bytes repeatedly without making forward progress.
632            let src = self.conns[j].source;
633            self.sources[src].consecutive_stalls += 1;
634            let k = self.sources[src].consecutive_stalls;
635            if k >= 2 {
636                let mut backoff = (self.stall_timeout * (1u64 << (k - 2).min(5)) as f64).min(30.0);
637                // Never suspend the LAST usable source for longer than a caller's
638                // watchdog will wait. Exponential backoff is right when there is
639                // somewhere else to send the work; when this is the only source it
640                // is a self-inflicted outage, and a transport that fails on silence
641                // cannot tell it apart from the source being gone.
642                //
643                // Callers should also consult `all_sources_suspended_until` so a
644                // planned pause is not charged against a no-progress deadline. This
645                // clamp is the second line of defence: it keeps the invariant local
646                // to the scheduler, so a caller that does not know about deliberate
647                // suspension still cannot be starved by it.
648                if self.sources.len() == 1 {
649                    backoff = backoff.min(self.stall_timeout.max(1.0));
650                }
651                self.sources[src].suspended_until = now + backoff;
652            }
653        }
654
655        // ---- liveness path 2: an idle connection holding a queue MUST start it
656        //
657        // Mandatory: a connection whose active range was entirely stolen goes
658        // idle WITHOUT completing, so the completion path in on_bytes never fires
659        // and the queued bytes would be owned by an idle connection that never
660        // requests them.
661        for j in 0..self.conns.len() {
662            if !self.conns[j].busy()
663                && self.conns[j].queued.is_some()
664                && now >= self.conns[j].setup_end
665            {
666                let r = self.conns[j].queued.take().unwrap();
667                self.start(j, r, now);
668                acts.push(Action::Request { conn: j, range: r });
669            }
670        }
671
672        // ---- divergence-triggered repair ---------------------------------
673        let theta = self.theta(now);
674        for _ in 0..MAX_REPAIRS_PER_TICK {
675            let Some((vi, ti)) = self.pick_victim_taker(now) else {
676                break;
677            };
678            let (v_eta, t_eta) = (self.conns[vi].eta(), self.conns[ti].eta());
679            // Explicit ordering test: an unknown ETA yields NaN, and a NaN
680            // divergence must NOT trigger a repair (a repair costs a full delta,
681            // so acting on an unmeasured quantity is strictly a loss).
682            if !matches!(
683                (v_eta - t_eta).partial_cmp(&theta),
684                Some(core::cmp::Ordering::Greater)
685            ) {
686                break;
687            }
688            if self.conns[ti].queued.is_some() {
689                break;
690            }
691            let Some(vr) = self.conns[vi].range else {
692                break;
693            };
694            let left = vr.hi.saturating_sub(self.conns[vi].pos) as f64;
695            let rv = self.conns[vi].rate_est;
696            let rt = self.conns[ti].rate_est;
697            let delta = self.sources[self.conns[ti].source].delta_est;
698            // Equalise projected finishes, charging the taker one setup:
699            //   (left - x)/rv == t_eta + delta + x/rt
700            let x = if rv <= 0.0 {
701                // Victim is stalled: hand over everything it has not received.
702                left
703            } else if rt <= 0.0 {
704                0.0
705            } else {
706                ((left / rv - t_eta - delta) * (rv * rt) / (rv + rt)).clamp(0.0, left)
707            };
708            if x <= STEAL_QUANTUM as f64 {
709                break;
710            }
711
712            // ---- does this repair actually pay for itself? -------------------
713            //
714            // The equalisation above solves `(left - x)/rv == t_eta + delta + x/rt`,
715            // which treats `rt` as capacity that `x` bytes can be moved ONTO. That
716            // is true when the connections have independent bottlenecks — separate
717            // mirrors, separate paths. It is false in the case that dominates real
718            // use: several connections to one origin, sharing one bottleneck. There
719            // the taker's rate is not spare capacity, it is a share of the same
720            // capacity the victim is using, so moving bytes across does not make
721            // them arrive faster. It only re-labels which connection carries them,
722            // and charges a setup for the privilege.
723            //
724            // Worse, the per-connection rate divergence that triggers the repair is
725            // largely a property of the PATH, not of the assignment: flows sharing
726            // a bottleneck settle at persistently unequal shares (roughly 1/RTT,
727            // with cwnd history making the asymmetry outlive any round trip). A
728            // repair cannot move that. So the divergence survives the repair, and
729            // re-triggers it.
730            //
731            // The test: compare the makespan now against the makespan after, where
732            // "after" charges the setup and credits only the improvement in the
733            // WORST finishing time — because the makespan is a max, not a sum, and
734            // improving anything other than the laggard buys nothing.
735            let makespan_now =
736                self.conns
737                    .iter()
738                    .map(|c| c.eta())
739                    .fold(0.0f64, |a, b| if b > a { b } else { a });
740            // The victim keeps `left - x` at its own rate; the taker takes on `x`
741            // after paying `delta`, on top of what it already owes.
742            let v_after = if rv > 0.0 {
743                (left - x) / rv
744            } else {
745                f64::INFINITY
746            };
747            let t_after = if rt > 0.0 {
748                t_eta + delta + x / rt
749            } else {
750                f64::INFINITY
751            };
752            // Every other connection is unaffected by this particular exchange.
753            let others = self
754                .conns
755                .iter()
756                .enumerate()
757                .filter(|(j, _)| *j != vi && *j != ti)
758                .map(|(_, c)| c.eta())
759                .fold(0.0f64, |a, b| if b > a { b } else { a });
760            let makespan_after = v_after.max(t_after).max(others);
761            // Require the gain to exceed the setup it costs, not merely to be
762            // positive: a repair that improves the projected makespan by less than
763            // one delta has not accounted for its own price. `theta` above is the
764            // hysteresis that stops oscillation; this is the profitability test,
765            // and both are needed — the first keeps jitter from triggering repair,
766            // the second keeps a real-but-unprofitable divergence from doing so.
767            // Explicit ordering, matching the theta test above: an unmeasured rate
768            // makes this difference NaN, and a NaN must REFUSE the repair rather
769            // than fall through either way. Acting on an unmeasured quantity is
770            // strictly a loss, because the setup cost is certain and the gain is not.
771            if !matches!(
772                (makespan_now - makespan_after).partial_cmp(&delta),
773                Some(core::cmp::Ordering::Greater)
774            ) {
775                break;
776            }
777
778            let x = x as u64;
779            let new_hi = vr.hi - x;
780            let stolen = Range::new(new_hi, vr.hi);
781            // Client-side shrink: the victim's target end moves and the server is
782            // never told. Free on the WIRE — no cancellation, no round trip — but
783            // only if the local fetch loop is told, which is what `Shrink` does.
784            // Without it the victim streams the stolen span anyway; see the
785            // `Action::Shrink` docs for what that costs.
786            self.conns[vi].range = Some(Range::new(vr.lo, new_hi));
787            self.conns[ti].queued = Some(stolen);
788            acts.push(Action::Shrink {
789                conn: vi,
790                hi: new_hi,
791            });
792            self.stats.repairs += 1;
793        }
794
795        // ---- work-conserving assignment (Lemma 2) -------------------------
796        //
797        // A connection above the active limit is DORMANT: it is skipped here, so it
798        // is never given work and never opens a socket. This is the whole mechanism
799        // behind the in-band concurrency ramp — raising the limit makes the next
800        // tick admit the connection through this ordinary path, and lowering it
801        // lets an already-busy connection finish its range and then go quiet, with
802        // no cancellation and no wasted bytes.
803        for j in 0..self.conns.len().min(self.active_limit) {
804            if self.conns[j].busy() || now < self.conns[j].setup_end {
805                continue;
806            }
807            let src = self.conns[j].source;
808            if now < self.sources[src].suspended_until {
809                continue;
810            }
811            // How much to hand this connection.
812            //
813            // `u64::MAX` — take everything — is right once concurrency has settled:
814            // maximal ranges mean the fewest requests, which is the whole point of
815            // range scheduling. It is wrong while the ramp is still growing, because
816            // the first idle connection would swallow the reserve that connections
817            // admitted later are supposed to pick up, and they would be left to
818            // STEAL from it. That is a repair per admission, and the repair
819            // undoes a split that had just been made for no reason.
820            //
821            // So while ramping, hand out a budget-sized share and leave the rest.
822            // The cost of being wrong in this direction is one extra request later —
823            // now nearly free on a pooled connection — against one repair per
824            // admitted connection the other way.
825            let want = if self.active_limit < self.conns.len() {
826                let remaining = self.unassigned.total();
827                let share = remaining / self.conns.len().max(1) as u64;
828                share.max(STEAL_QUANTUM * 4)
829            } else {
830                u64::MAX
831            };
832            if let Some(r) = self.unassigned.take_front(want) {
833                self.start(j, r, now);
834                acts.push(Action::Request { conn: j, range: r });
835                continue;
836            }
837            // Nothing unassigned: steal from the worst laggard.
838            //
839            // This is the steal-half heuristic, and it fires on a DIFFERENT
840            // trigger from the divergence repair above: not "the finishes have
841            // diverged" but "a connection has gone idle and there is nothing left
842            // to give it". Splitting the laggard's remainder down the middle is the
843            // right move when the idle connection has capacity the laggard cannot
844            // use. It is churn when they share one bottleneck — the same span is
845            // re-requested, a setup is paid, and the aggregate rate is unchanged
846            // because it was never the assignment that limited it.
847            //
848            // So the same profitability test applies. An idle connection is not a
849            // reason to move work; it is a reason to ASK whether moving work helps.
850            if let Some(vi) = self.worst_busy(j) {
851                let vr = self.conns[vi].range.unwrap();
852                let left = vr.hi.saturating_sub(self.conns[vi].pos);
853                let half = left / 2;
854                // Will the taker, paying one setup, actually finish this half
855                // sooner than the victim would have finished the whole remainder?
856                // With `rt` unknown (a connection that has just gone idle may have
857                // no estimate yet) fall back to the victim's own rate, which makes
858                // the test neutral rather than optimistic.
859                let rv = self.conns[vi].rate_est;
860                let rt = if self.conns[j].rate_est > 0.0 {
861                    self.conns[j].rate_est
862                } else {
863                    rv
864                };
865                let delta = self.sources[self.conns[j].source].delta_est;
866                let worth_it = if rv <= 0.0 {
867                    // The victim is delivering nothing measurable: anything is better.
868                    true
869                } else if rt <= 0.0 {
870                    false
871                } else {
872                    let before = left as f64 / rv;
873                    let after = (half as f64 / rv).max(delta + half as f64 / rt);
874                    before - after > delta
875                };
876                if half > STEAL_QUANTUM && worth_it {
877                    let new_hi = vr.hi - half;
878                    self.conns[vi].range = Some(Range::new(vr.lo, new_hi));
879                    let stolen = Range::new(new_hi, vr.hi);
880                    // Same shrink discipline as the divergence repair above: the
881                    // victim must be told its far end moved, or it streams the
882                    // half we just handed away.
883                    acts.push(Action::Shrink {
884                        conn: vi,
885                        hi: new_hi,
886                    });
887                    self.start(j, stolen, now);
888                    acts.push(Action::Request {
889                        conn: j,
890                        range: stolen,
891                    });
892                    self.stats.repairs += 1;
893                }
894            }
895            // NOTE: no hedging. Redundant requests waste bandwidth on non-erasure channels.
896        }
897
898        self.stats.bytes_held = self.held;
899        // Hand the scratch buffer back so its capacity survives to the next tick.
900        // Without this the `mem::take` above would leave an empty Vec in the field and
901        // the next tick would allocate again — the reuse would be nominal only.
902        self.scratch_idx = stalled;
903        acts
904    }
905
906    fn start(&mut self, j: usize, r: Range, now: f64) {
907        let delta = self.sources[self.conns[j].source].delta_est;
908        let c = &mut self.conns[j];
909        c.range = Some(r);
910        c.pos = r.lo;
911        c.setup_end = now + delta;
912        c.last_progress = now + delta;
913        c.stalled = false;
914        self.stats.requests += 1;
915    }
916
917    fn initial_split(&mut self, now: f64, acts: &mut Vec<Action>) {
918        // Maximal ranges, proportional to rate estimate where known, else equal.
919        //
920        // Only the ACTIVE prefix takes part. With the ramp enabled the transfer
921        // opens one connection, and the rest are admitted by `set_active_limit` as
922        // the in-band search finds them worth their setup cost. Splitting the
923        // object across connections that will not run would strand those bytes in
924        // a quota nobody fetches.
925        let n = self.conns.len().min(self.active_limit);
926        if n == 0 || self.size == 0 {
927            return;
928        }
929        let weights: Vec<f64> = self
930            .conns
931            .iter()
932            .take(n)
933            .map(|c| {
934                let g = self.sources[c.source].gamma_est;
935                if g > 0.0 {
936                    g
937                } else {
938                    1.0
939                }
940            })
941            .collect();
942        let total: f64 = weights.iter().sum();
943
944        // Split what is ACTUALLY unassigned, not `[0, size)`.
945        //
946        // An earlier version partitioned the whole object arithmetically, which
947        // silently ignored `mark_done`. That broke both features that depend on
948        // it: `--range` fetched from offset 0 instead of the requested interval,
949        // and `--continue` re-fetched bytes already on disk. The unassigned set is
950        // the single source of truth for what remains, so the split must be taken
951        // from it.
952        let remaining: Vec<Range> = self.unassigned.ranges().to_vec();
953        let avail: u64 = remaining.iter().map(|r| r.hi - r.lo).sum();
954        if avail == 0 {
955            return;
956        }
957        // Per-connection byte quotas, proportional to rate estimate.
958        //
959        // Divided over the FULL connection budget, not just the active prefix, and
960        // this matters specifically when the ramp is running. With one connection
961        // active, dividing by the active count alone hands that connection the
962        // entire object — so a connection admitted later finds the unassigned set
963        // empty and its only route to work is to STEAL, which pays a repair to
964        // undo a split that should never have been made. Measured cost of getting
965        // this wrong: every ramped transfer of a 3.15 MB object took ~21 s against
966        // 6.3 s for fixed concurrency, and several were reported as failures
967        // despite delivering byte-exact files.
968        //
969        // Quotas over the full budget leave the remainder UNASSIGNED, which is
970        // exactly where a newly admitted connection takes work from through
971        // ordinary work-conserving assignment — no repair, no steal, no duplicate
972        // request. If the ramp never grows, nothing is lost: the active connection
973        // finishes its quota and work-conserving assignment gives it the next
974        // piece, which connection reuse now makes nearly free.
975        let budget = self.conns.len().max(1);
976        let mut quota: Vec<u64> = weights
977            .iter()
978            .map(|w| ((w / total) * (avail as f64 / budget as f64) * n as f64) as u64)
979            .collect();
980        // Rounding must not strand bytes — but only when every connection is
981        // active. While ramping, the unclaimed remainder is deliberate.
982        if n >= budget {
983            let assigned: u64 = quota.iter().sum();
984            if let Some(last) = quota.last_mut() {
985                *last += avail.saturating_sub(assigned);
986            }
987        }
988
989        // Walk the unassigned ranges, carving each connection's quota out of them
990        // in order. A connection may receive a range that is not contiguous with
991        // its neighbours' — that is fine, since ranges are independent requests.
992        let mut it = remaining.into_iter();
993        let mut cur = it.next();
994        for (j, want_total) in quota.iter().enumerate() {
995            let mut want = *want_total;
996            while want > 0 {
997                let Some(seg) = cur else { break };
998                let take = want.min(seg.hi - seg.lo);
999                let r = Range::new(seg.lo, seg.lo + take);
1000                // A connection holds one active range plus a one-slot pipeline.
1001                // Anything beyond that stays UNASSIGNED rather than being stashed:
1002                // work-conserving assignment will hand it out as connections free
1003                // up, and leaving it in the set is what keeps the coverage
1004                // invariant checkable.
1005                if self.conns[j].range.is_none() {
1006                    self.unassigned.remove(r.lo, r.hi);
1007                    self.start(j, r, now);
1008                    acts.push(Action::Request { conn: j, range: r });
1009                } else if self.conns[j].queued.is_none() {
1010                    self.unassigned.remove(r.lo, r.hi);
1011                    self.conns[j].queued = Some(r);
1012                } else {
1013                    break;
1014                }
1015                want -= take;
1016                cur = if seg.hi - seg.lo > take {
1017                    Some(Range::new(seg.lo + take, seg.hi))
1018                } else {
1019                    it.next()
1020                };
1021            }
1022        }
1023    }
1024
1025    /// The current repair deadband, in seconds. Exposed for measurement.
1026    pub fn theta_now(&self, now: f64) -> f64 {
1027        self.theta(now)
1028    }
1029
1030    fn theta(&self, now: f64) -> f64 {
1031        // One fold, no allocation. This is called from the tick loop — 50 times a
1032        // second at the default 20 ms tick, for the whole transfer — and it collected
1033        // a `Vec<&Conn>` on every call only to take its length and sum one field.
1034        // Nothing here needs the intermediate collection.
1035        let (live_count, agg) = self
1036            .conns
1037            .iter()
1038            .filter(|c| now >= self.sources[c.source].suspended_until)
1039            .fold((0usize, 0.0f64), |(k, sum), c| {
1040                (k + 1, sum + c.rate_est.max(0.0))
1041            });
1042        let n = live_count.max(1) as f64;
1043        let agg = if agg > 0.0 { agg } else { 1.0 };
1044        let remaining = self.size.saturating_sub(self.held) as f64;
1045        let t_rem = remaining / agg;
1046        let delta = self
1047            .sources
1048            .iter()
1049            .map(|s| s.delta_est)
1050            .fold(0.0f64, f64::max);
1051        let band = self.theta_scale * (delta * t_rem.max(0.0) / n).sqrt();
1052
1053        // ---- floor the deadband at what a repair actually costs --------------
1054        //
1055        // `sqrt(delta * T_rem / n)` is the right SHAPE — it is the granularity
1056        // trade-off — but it is unbounded below, and it approaches zero from two
1057        // directions that both make repair a worse idea, not a better one:
1058        // `T_rem` shrinks as the transfer finishes, and `n` grows with
1059        // concurrency. So the deadband is narrowest exactly when a repair has the
1060        // least remaining time to earn its cost back and the most competitors to
1061        // pay it against.
1062        //
1063        // Measured on the shared-bottleneck harness (examples/storm.rs, 12 seeds):
1064        // theta reached 0.061-0.081 s against a delta of 0.12 s. Every repair
1065        // triggered in that regime spends one full setup to recover a divergence
1066        // smaller than the setup — a guaranteed loss, taken deliberately, dozens
1067        // of times per transfer.
1068        //
1069        // A repair cannot be worth making unless the divergence it corrects
1070        // exceeds what correcting it costs, so `delta` is the floor. This is not a
1071        // tuning constant: it is the break-even point, and it is measured per
1072        // source rather than guessed, so a high-RTT path widens it automatically.
1073        band.max(delta)
1074    }
1075
1076    fn pick_victim_taker(&self, now: f64) -> Option<(usize, usize)> {
1077        // Victim ranking is (health, ETA), health first. A connection the
1078        // detector has graded Suspect is a victim even when its *projected* ETA
1079        // still looks acceptable -- which is the whole point of detecting a
1080        // collapse early, since the ETA is computed from a rate estimate that
1081        // the collapse has not yet dragged down.
1082        let mut victim: Option<(usize, crate::detect::Health, f64)> = None;
1083        let mut taker: Option<(usize, f64)> = None;
1084        // Dormant connections (above the active limit) are excluded from BOTH
1085        // roles. As taker, admitting one would open a socket the concurrency ramp
1086        // has not yet justified — quietly defeating the limit through the repair
1087        // path. As victim, one cannot be: it holds no range.
1088        for j in 0..self.conns.len().min(self.active_limit) {
1089            let c = &self.conns[j];
1090            if now < c.setup_end || now < self.sources[c.source].suspended_until {
1091                continue;
1092            }
1093            let e = c.eta();
1094            let h = if self.health_ranking {
1095                c.detector.health()
1096            } else {
1097                crate::detect::Health::Healthy
1098            };
1099            if c.busy() && victim.map(|(_, vh, ve)| (h, e) > (vh, ve)).unwrap_or(true) {
1100                victim = Some((j, h, e));
1101            }
1102            // A degraded connection must never be chosen as the TAKER: handing
1103            // work to a collapsing connection is the failure mode this whole
1104            // mechanism exists to prevent.
1105            if !h.is_suspect_or_worse() && taker.map(|(_, te)| e < te).unwrap_or(true) {
1106                taker = Some((j, e));
1107            }
1108        }
1109        let (vi, _, _) = victim?;
1110        let (ti, _) = taker?;
1111        if vi == ti {
1112            return None;
1113        }
1114        Some((vi, ti))
1115    }
1116
1117    fn worst_busy(&self, exclude: usize) -> Option<usize> {
1118        let mut best: Option<(usize, u64)> = None;
1119        for j in 0..self.conns.len() {
1120            if j == exclude {
1121                continue;
1122            }
1123            let c = &self.conns[j];
1124            if !c.busy() {
1125                continue;
1126            }
1127            let left = c.range.unwrap().hi.saturating_sub(c.pos);
1128            if best.map(|(_, bl)| left > bl).unwrap_or(true) {
1129                best = Some((j, left));
1130            }
1131        }
1132        best.map(|(j, _)| j)
1133    }
1134}
1135
1136/// Greedy concurrency allocation across multiple sources.
1137pub fn greedy_concurrency(
1138    rho: &[f64],
1139    gamma: &[f64],
1140    access_cap: f64,
1141    budget: usize,
1142) -> Vec<usize> {
1143    let m = rho.len();
1144    let mut n = vec![0usize; m];
1145    let g = |n: &[usize]| -> f64 {
1146        let sum: f64 = (0..m).map(|i| rho[i].min(n[i] as f64 * gamma[i])).sum();
1147        sum.min(access_cap)
1148    };
1149    let mut cur = g(&n);
1150    for _ in 0..budget {
1151        let mut best = (0usize, 0.0f64);
1152        for i in 0..m {
1153            n[i] += 1;
1154            let gain = g(&n) - cur;
1155            n[i] -= 1;
1156            if gain > best.1 {
1157                best = (i, gain);
1158            }
1159        }
1160        if best.1 <= 0.0 {
1161            break; // saturated: further connections are pure cost
1162        }
1163        n[best.0] += 1;
1164        cur += best.1;
1165    }
1166    n
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171    use super::*;
1172
1173    fn src(gamma: f64) -> Source {
1174        Source {
1175            gamma_est: gamma,
1176            delta_est: 0.05,
1177            ..Default::default()
1178        }
1179    }
1180
1181    #[test]
1182    fn initial_split_covers_exactly() {
1183        let mut s = Scheduler::new(1000, vec![src(1.0), src(1.0)], &[1, 1]);
1184        let acts = s.tick(0.0);
1185        assert_eq!(acts.len(), 2);
1186        assert!(s.coverage_holds());
1187        assert!(s.unassigned.is_empty());
1188    }
1189
1190    #[test]
1191    fn coverage_and_liveness_hold_through_a_transfer() {
1192        let mut s = Scheduler::new(1_000_000, vec![src(1e5), src(5e4)], &[2, 2]);
1193        let mut now = 0.0;
1194        for _ in 0..4000 {
1195            s.tick(now);
1196            for j in 0..s.n_conns() {
1197                s.on_bytes(j, 500, now, 0.01);
1198            }
1199            assert!(s.coverage_holds(), "coverage broke at t={now}");
1200            assert!(s.liveness_holds(), "stuck at t={now}");
1201            now += 0.01;
1202            if s.is_complete() {
1203                break;
1204            }
1205        }
1206        assert!(
1207            s.is_complete(),
1208            "did not finish: {} / {}",
1209            s.bytes_held(),
1210            1_000_000
1211        );
1212    }
1213
1214    #[test]
1215    fn fully_stolen_range_does_not_livelock() {
1216        // Regression: a connection whose active range is stolen down to its
1217        // current position goes idle WITHOUT completing. If the queue-start
1218        // path is missing, its queued bytes are never requested.
1219        let mut s = Scheduler::new(200_000, vec![src(1e5), src(1e5)], &[1, 1]);
1220        s.tick(0.0);
1221        // conn 0 makes progress, conn 1 stalls entirely
1222        let mut now = 0.06;
1223        for _ in 0..50 {
1224            s.on_bytes(0, 1000, now, 0.01);
1225            now += 0.01;
1226            s.tick(now);
1227        }
1228        // force a steal by making conn 1 look terrible, then run to completion
1229        for _ in 0..20000 {
1230            s.tick(now);
1231            s.on_bytes(0, 1000, now, 0.01);
1232            now += 0.01;
1233            assert!(s.liveness_holds(), "livelocked at t={now}");
1234            if s.is_complete() {
1235                break;
1236            }
1237        }
1238        assert!(s.is_complete());
1239    }
1240
1241    #[test]
1242    fn stall_reclaim_returns_bytes() {
1243        let mut s = Scheduler::new(100_000, vec![src(1e5), src(1e5)], &[1, 1]);
1244        s.tick(0.0);
1245        let before = s.stats.reclaims;
1246        // no bytes at all: both connections must be reclaimed after the timeout
1247        let acts = s.tick(5.0);
1248        assert!(s.stats.reclaims > before);
1249        assert!(acts.iter().any(|a| matches!(a, Action::Cancel { .. })));
1250        assert!(s.coverage_holds());
1251        assert!(s.liveness_holds());
1252    }
1253
1254    #[test]
1255    fn suspend_source_reclaims_and_reassigns() {
1256        let mut s = Scheduler::new(100_000, vec![src(1e5), src(1e5)], &[1, 1]);
1257        s.tick(0.0);
1258        s.suspend_source(0, 10.0);
1259        // Reclaimed bytes are now unassigned. They are NOT reassigned instantly:
1260        // the surviving connection is still streaming its own range, and taking
1261        // work from it would violate nothing but achieve nothing either. Work
1262        // conservation only requires that no connection sit IDLE while work
1263        // remains -- so the reassignment happens when conn 1 next goes idle.
1264        assert!(s.coverage_holds());
1265        assert!(s.unassigned.total() > 0);
1266
1267        let mut now = 0.2;
1268        let mut served_by_1 = false;
1269        for _ in 0..20_000 {
1270            let acts = s.tick(now);
1271            if acts
1272                .iter()
1273                .any(|a| matches!(a, Action::Request { conn, .. } if s.conns[*conn].source == 1))
1274            {
1275                served_by_1 = true;
1276            }
1277            s.on_bytes(1, 1000, now, 0.01);
1278            now += 0.01;
1279            assert!(s.coverage_holds());
1280            assert!(s.liveness_holds());
1281            if s.is_complete() {
1282                break;
1283            }
1284        }
1285        assert!(
1286            served_by_1,
1287            "surviving source never picked up the reclaimed work"
1288        );
1289        assert!(s.is_complete(), "held {} of 100000", s.bytes_held());
1290    }
1291
1292    #[test]
1293    fn greedy_matches_exhaustive_small() {
1294        // rho/gamma chosen so the optimum is interior
1295        let rho = [2.2e6, 1.1e6, 0.7e6];
1296        let gam = [0.55e6, 0.45e6, 0.35e6];
1297        let cap = 5.0e6;
1298        for budget in 1..10usize {
1299            let n = greedy_concurrency(&rho, &gam, cap, budget);
1300            let g = |n: &[usize]| -> f64 {
1301                let s: f64 = (0..3).map(|i| rho[i].min(n[i] as f64 * gam[i])).sum();
1302                s.min(cap)
1303            };
1304            let mut best = 0.0f64;
1305            for a in 0..=budget {
1306                for b in 0..=budget {
1307                    for c in 0..=budget {
1308                        if a + b + c <= budget {
1309                            best = best.max(g(&[a, b, c]));
1310                        }
1311                    }
1312                }
1313            }
1314            assert!(
1315                (g(&n) - best).abs() < 1.0,
1316                "budget {budget}: greedy {} vs {}",
1317                g(&n),
1318                best
1319            );
1320        }
1321    }
1322
1323    #[test]
1324    fn saturation_stops_allocation() {
1325        // one source, rho = 2*gamma: two connections saturate it
1326        let n = greedy_concurrency(&[2.0e6], &[1.0e6], 1e9, 10);
1327        assert_eq!(
1328            n[0], 2,
1329            "allocated {n:?}, expected exactly the saturation point"
1330        );
1331    }
1332    /// The detector must make the SCHEDULER act sooner, not merely grade sooner.
1333    ///
1334    /// A connection collapsing to 3% of its rate must be chosen as a repair
1335    /// victim well before the stall timeout would have reclaimed it. Without
1336    /// health-ranked victim selection the scheduler waits for the projected ETA
1337    /// to drift, which is the fixed detection cost measured at 0.25-0.9 s.
1338    #[test]
1339    fn collapsed_connection_becomes_a_repair_victim_before_the_stall_timeout() {
1340        const S: u64 = 40_000_000;
1341        let mut sc = Scheduler::new(S, vec![src(4e6), src(4e6)], &[1, 1]).with_stall_timeout(10.0);
1342        sc.tick(0.0);
1343        let mut now = 0.0;
1344        // Both healthy for a while.
1345        for _ in 0..12 {
1346            now += 0.1;
1347            sc.on_bytes(0, 400_000, now, 0.1);
1348            sc.on_bytes(1, 400_000, now, 0.1);
1349            sc.tick(now);
1350        }
1351        assert_eq!(sc.conn_health(0), crate::detect::Health::Healthy);
1352
1353        // Connection 0 collapses; connection 1 keeps its rate.
1354        let mut flagged_at = None;
1355        for _ in 0..8 {
1356            now += 0.1;
1357            sc.on_bytes(0, 12_000, now, 0.1);
1358            sc.on_bytes(1, 400_000, now, 0.1);
1359            sc.tick(now);
1360            if flagged_at.is_none() && sc.conn_health(0).is_suspect_or_worse() {
1361                flagged_at = Some(now);
1362            }
1363        }
1364        let t = flagged_at.expect("collapse must be graded");
1365        assert!(
1366            t < 1.2 + 10.0,
1367            "must be flagged well before the 10 s stall timeout, was {t}"
1368        );
1369        // And the healthy connection must never be the one downgraded.
1370        assert_eq!(
1371            sc.conn_health(1),
1372            crate::detect::Health::Healthy,
1373            "the connection holding its rate must stay Healthy"
1374        );
1375        assert!(sc.coverage_holds() && sc.liveness_holds());
1376    }
1377    /// The repair deadband must never fall below what a repair costs.
1378    ///
1379    /// `theta = scale*sqrt(delta*T_rem/n)` has the right shape but is unbounded
1380    /// below, and it approaches zero from two directions that both make repair a
1381    /// worse idea: `T_rem` shrinks as the transfer ends, `n` grows with
1382    /// concurrency. Measured on the shared-bottleneck harness, theta reached
1383    /// 0.061-0.081 s against a delta of 0.12 s — so the scheduler was spending a
1384    /// 0.12 s setup to recover a 0.06 s divergence, dozens of times per transfer.
1385    #[test]
1386    fn the_repair_deadband_never_drops_below_one_setup_cost() {
1387        const S: u64 = 8_000_000;
1388        const D: f64 = 0.12;
1389        let mk = |n: usize| {
1390            let sources = vec![Source {
1391                gamma_est: 1.4e6 / n as f64,
1392                delta_est: D,
1393                ..Default::default()
1394            }];
1395            Scheduler::new(S, sources, &[n])
1396        };
1397        // Sweep concurrency and progress: both drive theta down.
1398        for &n in &[1usize, 2, 4, 8, 16, 64] {
1399            let mut sc = mk(n);
1400            sc.tick(0.0);
1401            let mut now = 0.0;
1402            // Deliver most of the object, so T_rem — and with it the unfloored
1403            // band — becomes small.
1404            for _ in 0..60 {
1405                now += 0.05;
1406                for j in 0..n {
1407                    if sc.conn_range(j).is_some() {
1408                        sc.on_bytes(j, 100_000 / n as u64, now, 0.05);
1409                    }
1410                }
1411                sc.tick(now);
1412                let th = sc.theta_now(now);
1413                assert!(
1414                    th >= D - 1e-12,
1415                    "theta {th} fell below delta {D} at n={n}, progress {}/{S}: \
1416                     the scheduler would pay a full setup to recover a smaller divergence",
1417                    sc.bytes_held()
1418                );
1419            }
1420        }
1421    }
1422
1423    /// A stable unequal split settles after ONE equalisation; a collapse still
1424    /// gets answered.
1425    ///
1426    /// These two assertions are one test on purpose. Suppressing spurious repair is
1427    /// trivial in isolation — never repair — and that would be a regression, not a
1428    /// fix: the mechanism exists for the mirror that dies mid-transfer. The
1429    /// property worth pinning is the DISCRIMINATION between the two cases.
1430    ///
1431    /// # What this test does NOT cover
1432    ///
1433    /// It does not reproduce the repair storm, and no test in this crate can. The
1434    /// storm was a feedback loop between the scheduler and the transport: a repair
1435    /// shrank the victim's range, the victim's socket kept streaming the span
1436    /// anyway, the duplicate traffic slowed the honest connections, and that
1437    /// slowdown re-diverged the finish times into another repair. The core cannot
1438    /// see any of that — it has no sockets — so it cannot close the loop. Feeding
1439    /// it a stable unequal split, as here, correctly produces exactly one repair
1440    /// (equalising a persistent 60/40 asymmetry IS profitable) and then stops.
1441    ///
1442    /// The loop itself is tested where it lives, against a served-byte count at the
1443    /// origin: `hydra-net/tests/shrink_e2e.rs`.
1444    #[test]
1445    fn a_stable_unequal_split_settles_and_a_collapse_is_still_answered() {
1446        const S: u64 = 40_000_000;
1447        let src4 = || Source {
1448            gamma_est: 2e6,
1449            delta_est: 0.12,
1450            ..Default::default()
1451        };
1452
1453        // --- stationary: two connections at persistently unequal but stable shares.
1454        // This is what flows sharing one bottleneck look like (share ~ 1/RTT), and
1455        // no repair can change it — the asymmetry is a property of the path.
1456        let mut sc = Scheduler::new(S, vec![src4(), src4()], &[1, 1]);
1457        sc.tick(0.0);
1458        let mut now = 0.0;
1459        for k in 0..60 {
1460            now += 0.1;
1461            // 60/40 split, with a little jitter, conserving the aggregate.
1462            let wobble = if k % 3 == 0 { 12_000 } else { -8_000 };
1463            sc.on_bytes(0, (240_000i64 + wobble) as u64, now, 0.1);
1464            sc.on_bytes(1, (160_000i64 - wobble) as u64, now, 0.1);
1465            sc.tick(now);
1466        }
1467        // One equalisation is correct here and the scheduler must then SETTLE: the
1468        // 60/40 share ratio is a property of the path, so re-equalising cannot
1469        // improve it and every further repair is a pure setup cost. 60 ticks over
1470        // 6 s of simulated transfer would be ample room for a storm.
1471        let stationary_repairs = sc.stats.repairs;
1472        assert!(
1473            stationary_repairs <= 1,
1474            "a stable unequal split provoked {stationary_repairs} repairs over 60 \
1475             ticks; one equalisation is profitable, repeated ones only pay setups"
1476        );
1477
1478        // --- collapse: connection 0 drops to 2% and stays there.
1479        let mut sc = Scheduler::new(S, vec![src4(), src4()], &[1, 1]);
1480        sc.tick(0.0);
1481        let mut now = 0.0;
1482        for _ in 0..20 {
1483            now += 0.1;
1484            sc.on_bytes(0, 200_000, now, 0.1);
1485            sc.on_bytes(1, 200_000, now, 0.1);
1486            sc.tick(now);
1487        }
1488        let before = sc.stats.repairs;
1489        for _ in 0..40 {
1490            now += 0.1;
1491            sc.on_bytes(0, 4_000, now, 0.1);
1492            sc.on_bytes(1, 200_000, now, 0.1);
1493            sc.tick(now);
1494        }
1495        assert!(
1496            sc.stats.repairs > before,
1497            "a connection collapsing to 2% of its rate produced no repair: the \
1498             profitability test is suppressing the case repair exists for"
1499        );
1500        assert!(sc.coverage_holds() && sc.liveness_holds());
1501    }
1502
1503    /// A sole source must never be suspended past a caller's patience.
1504    ///
1505    /// Exponential backoff is right when work can go somewhere else. With one source
1506    /// it is a self-inflicted outage: nothing can move until the suspension expires,
1507    /// and a transport whose watchdog fails on silence cannot distinguish that from
1508    /// the source being gone.
1509    ///
1510    /// The numbers that made this real: `stall_timeout` 4.0s gives the transport a
1511    /// no-progress deadline of `4 * (4.0 + delta)` = 16.2s, while five consecutive
1512    /// stalls suspended the sole source for `min(4.0 * 2^3, 30)` = 30s. Measured on a
1513    /// 121.7 MiB GitHub release asset, 4 of 8 multi-connection runs aborted with a
1514    /// digest mismatch — three holding 126.9-127.0 MB of 127.6 MB, killed during a
1515    /// deliberate backoff over the final half-megabyte.
1516    #[test]
1517    fn a_sole_source_is_never_suspended_longer_than_its_stall_timeout() {
1518        const S: u64 = 8_000_000;
1519        let st = 4.0;
1520        let mut sc = Scheduler::new(S, vec![src(4e6)], &[4]).with_stall_timeout(st);
1521        sc.tick(0.0);
1522
1523        // Drive it through many consecutive stalls, which is what escalates backoff.
1524        let mut now = 0.0;
1525        let mut worst_suspension = 0.0f64;
1526        for _ in 0..12 {
1527            now += st * 1.5;
1528            sc.tick(now);
1529            if let Some(until) = sc.all_sources_suspended_until(now) {
1530                worst_suspension = worst_suspension.max(until - now);
1531            }
1532        }
1533        assert!(
1534            worst_suspension <= st.max(1.0) + 1e-9,
1535            "sole source suspended for {worst_suspension:.1}s against a {st:.1}s stall \
1536             timeout: a caller's no-progress watchdog will kill the transfer during a \
1537             pause the scheduler chose"
1538        );
1539    }
1540
1541    /// Ramping concurrency must find work WAITING, not have to steal it.
1542    ///
1543    /// With the ramp enabled the transfer starts with one connection active. If the
1544    /// initial split gives that connection the whole object, every connection
1545    /// admitted afterwards finds the unassigned set empty and its only route to
1546    /// work is a steal — paying a repair to undo a split that should not have been
1547    /// made. Measured cost of that mistake on a live 3.15 MB transfer: ~21 s
1548    /// against 6.3 s for fixed concurrency, with several runs reported as failures
1549    /// despite delivering byte-exact files.
1550    ///
1551    /// The invariant: while the active limit is below the connection budget, some
1552    /// work stays unassigned, and raising the limit produces `Request` actions
1553    /// rather than repairs.
1554    #[test]
1555    fn a_ramping_transfer_finds_unassigned_work_instead_of_stealing() {
1556        const S: u64 = 40_000_000;
1557        let sources = vec![Source {
1558            gamma_est: 2e6,
1559            delta_est: 0.05,
1560            ..Default::default()
1561        }];
1562        let mut sc = Scheduler::new(S, sources, &[8]).with_active_limit(1);
1563        let acts = sc.tick(0.0);
1564        assert_eq!(
1565            acts.iter()
1566                .filter(|a| matches!(a, Action::Request { .. }))
1567                .count(),
1568            1,
1569            "only the active connection may be given work"
1570        );
1571        assert!(
1572            !sc.unassigned_is_empty(),
1573            "the whole object was handed to one connection: connections admitted \
1574             later can only steal, which costs a repair each"
1575        );
1576
1577        // Deliver some bytes, then admit more connections as the ramp would.
1578        let mut now = 0.0;
1579        for _ in 0..5 {
1580            now += 0.1;
1581            sc.on_bytes(0, 200_000, now, 0.1);
1582            sc.tick(now);
1583        }
1584        let repairs_before = sc.stats.repairs;
1585        sc.set_active_limit(4);
1586        now += 0.1;
1587        let acts = sc.tick(now);
1588        let reqs = acts
1589            .iter()
1590            .filter(|a| matches!(a, Action::Request { .. }))
1591            .count();
1592        assert!(
1593            reqs >= 3,
1594            "admitting 3 connections produced {reqs} requests: they are not being \
1595             given the reserved work"
1596        );
1597        assert_eq!(
1598            sc.stats.repairs, repairs_before,
1599            "admitting a connection must not cost a repair"
1600        );
1601        assert!(sc.coverage_holds() && sc.liveness_holds());
1602    }
1603
1604    /// Every range shrink must be ANNOUNCED, not just performed.
1605    ///
1606    /// Regression test for the repair storm. The scheduler used to move
1607    /// `conns[victim].range` and emit nothing, so the transport's fetch loop —
1608    /// which tests `off < hi` against the bound it captured at request time —
1609    /// went on pulling the span that had just been handed to another connection.
1610    /// Both connections then fetched the same bytes over the same bottleneck, the
1611    /// resulting slowdown read as fresh divergence, and that triggered further
1612    /// repairs: measured at 32-49 repairs on a stationary 5.3 MB transfer whose
1613    /// correct repair count is zero, for ~2.2x the fluid optimum.
1614    ///
1615    /// The invariant is therefore stronger than "a repair happened": for every
1616    /// repair counted, the victim whose far end moved must appear in a `Shrink`
1617    /// carrying the new bound. A caller cannot honour what it is not told.
1618    #[test]
1619    fn every_repair_announces_the_victims_new_far_end() {
1620        const S: u64 = 40_000_000;
1621        let mut sc = Scheduler::new(S, vec![src(4e6), src(4e6)], &[1, 1]).with_stall_timeout(10.0);
1622        sc.tick(0.0);
1623        let mut now = 0.0;
1624        for _ in 0..12 {
1625            now += 0.1;
1626            sc.on_bytes(0, 400_000, now, 0.1);
1627            sc.on_bytes(1, 400_000, now, 0.1);
1628            sc.tick(now);
1629        }
1630
1631        // Collapse connection 0 so a divergence repair becomes correct to make.
1632        let mut shrinks: Vec<(usize, u64)> = Vec::new();
1633        let mut repairs_before = sc.stats.repairs;
1634        let mut saw_repair = false;
1635        for _ in 0..25 {
1636            now += 0.1;
1637            sc.on_bytes(0, 4_000, now, 0.1);
1638            sc.on_bytes(1, 400_000, now, 0.1);
1639            // Snapshot each victim's far end before the tick that may move it.
1640            let before: Vec<Option<u64>> = (0..sc.n_conns())
1641                .map(|j| sc.conn_range(j).map(|(_, _, hi)| hi))
1642                .collect();
1643            let acts = sc.tick(now);
1644            for a in &acts {
1645                if let Action::Shrink { conn, hi } = a {
1646                    shrinks.push((*conn, *hi));
1647                    // The announced bound must be the one actually installed, and
1648                    // it must be a genuine reduction — never a raise, which would
1649                    // hand out bytes another connection may already hold.
1650                    assert_eq!(
1651                        sc.conn_range(*conn).map(|(_, _, h)| h),
1652                        Some(*hi),
1653                        "announced bound must match the installed one"
1654                    );
1655                    if let Some(Some(b)) = before.get(*conn) {
1656                        assert!(*hi <= *b, "a shrink must lower the far end: {b} -> {hi}");
1657                    }
1658                }
1659            }
1660            if sc.stats.repairs > repairs_before {
1661                saw_repair = true;
1662                assert!(
1663                    !shrinks.is_empty(),
1664                    "a repair was counted with no Shrink announced: the victim's \
1665                     socket would keep streaming the stolen span"
1666                );
1667                repairs_before = sc.stats.repairs;
1668            }
1669        }
1670        assert!(saw_repair, "the scenario must produce at least one repair");
1671        assert!(sc.coverage_holds() && sc.liveness_holds());
1672    }
1673
1674    /// The initial split must respect `mark_done`.
1675    ///
1676    /// Regression test: an earlier version partitioned `[0, size)` arithmetically
1677    /// and never consulted the unassigned set, so `mark_done` was silently
1678    /// ignored. That broke `--range` (fetched from offset 0 instead of the
1679    /// requested interval) and `--continue` (re-fetched bytes already on disk).
1680    #[test]
1681    fn initial_split_never_requests_bytes_marked_done() {
1682        let size = 100_000u64;
1683        let mut s = Scheduler::new(size, vec![src(1e6), src(1e6)], &[1, 1]);
1684        // Range mode: only [90_000, 90_512) is wanted.
1685        s.mark_done(0, 90_000);
1686        s.mark_done(90_512, size);
1687        let acts = s.tick(0.0);
1688        assert!(
1689            !acts.is_empty(),
1690            "the wanted interval must still be requested"
1691        );
1692        for a in &acts {
1693            if let Action::Request { range, .. } = a {
1694                assert!(
1695                    range.lo >= 90_000 && range.hi <= 90_512,
1696                    "requested {range:?} outside the wanted interval"
1697                );
1698            }
1699        }
1700        assert!(s.coverage_holds());
1701    }
1702
1703    /// Overlapping `mark_done` calls must not inflate the held count.
1704    ///
1705    /// Regression test for a silent truncation. `mark_done` credited the width of
1706    /// the span it was given rather than the bytes it actually claimed, so two
1707    /// callers marking the same prefix — a `-c` resume replaying its sidecar, and
1708    /// the concurrency probe reporting the bytes it fetched, both of which start
1709    /// at offset 0 — pushed `held` past the object's real length. `is_complete()`
1710    /// tests exactly that counter, so the transfer stopped believing it was
1711    /// finished and left a zero-filled hole in the tail of a file it reported as
1712    /// a success: measured at 240 138 unwritten bytes on an 11 200 900-byte
1713    /// object whose gzip then refused to decompress.
1714    #[test]
1715    fn overlapping_mark_done_credits_each_byte_once() {
1716        let size = 100_000u64;
1717        let mut s = Scheduler::new(size, vec![src(1e6)], &[1]);
1718        s.mark_done(0, 30_000); // a resume record
1719        s.mark_done(0, 10_000); // the probe, re-reporting part of the same prefix
1720        assert_eq!(
1721            s.bytes_held(),
1722            30_000,
1723            "the overlap must be credited once, not twice"
1724        );
1725        assert!(!s.is_complete(), "70 000 bytes are still missing");
1726
1727        // Marking every byte, in overlapping pieces, is completion — and exactly
1728        // completion, never more.
1729        s.mark_done(20_000, size);
1730        s.mark_done(0, size);
1731        assert_eq!(s.bytes_held(), size);
1732        assert!(s.is_complete());
1733    }
1734
1735    /// After the probe's ranges are marked, `held_ranges` must describe them.
1736    ///
1737    /// This is what the pre-transfer checkpoint writes into the sidecar, so that a
1738    /// ^C during or shortly after the concurrency probe does not discard bytes the
1739    /// probe already fetched at true offsets. The periodic checkpoint inside the
1740    /// transfer only fires after 2 seconds, which an early interrupt beats.
1741    #[test]
1742    fn held_ranges_reports_probe_bytes_before_any_transfer() {
1743        let size = 11_200_900u64;
1744        let mut s = Scheduler::new(size, vec![Source::default()], &[1]);
1745        // Nothing fetched yet: nothing to checkpoint, and an empty record must not
1746        // be written as though it were progress.
1747        assert!(s.held_ranges().is_empty());
1748
1749        // The probe fetched a 3 MiB prefix into the real output.
1750        s.mark_done(0, 3 << 20);
1751        assert_eq!(s.held_ranges(), vec![(0, 3 << 20)]);
1752        assert_eq!(s.bytes_held(), 3 << 20);
1753
1754        // A second, disjoint probe range is reported as its own span rather than
1755        // merged into a count: a byte count cannot describe a hole, which is why
1756        // the sidecar stores ranges.
1757        s.mark_done(5 << 20, 6 << 20);
1758        assert_eq!(s.held_ranges(), vec![(0, 3 << 20), (5 << 20, 6 << 20)]);
1759
1760        // Adjacent spans DO coalesce, so the record stays compact across a long run.
1761        s.mark_done(3 << 20, 5 << 20);
1762        assert_eq!(s.held_ranges(), vec![(0, 6 << 20)]);
1763    }
1764
1765    /// Resume: bytes already on disk must never be re-requested.
1766    #[test]
1767    fn resume_does_not_refetch_held_prefix() {
1768        let size = 64_000u64;
1769        let mut s = Scheduler::new(size, vec![src(1e6)], &[2]);
1770        s.mark_done(0, 48_000); // three quarters already fetched
1771        let acts = s.tick(0.0);
1772        for a in &acts {
1773            if let Action::Request { range, .. } = a {
1774                assert!(
1775                    range.lo >= 48_000,
1776                    "re-requested a held byte at {}",
1777                    range.lo
1778                );
1779            }
1780        }
1781        assert_eq!(
1782            s.bytes_held(),
1783            48_000,
1784            "held count must include the resumed prefix"
1785        );
1786        assert!(s.coverage_holds());
1787    }
1788}