Skip to main content

simulator_api/
lib.rs

1use std::{
2    collections::{BTreeMap, BTreeSet, HashSet},
3    fmt,
4};
5
6use base64::{
7    DecodeError as Base64DecodeError, Engine as _, engine::general_purpose::STANDARD as BASE64,
8};
9use serde::{Deserialize, Serialize};
10use solana_address::Address;
11
12pub mod subscribe_config;
13pub mod usage;
14pub mod ws_compression;
15
16/// Backtest RPC methods exposed to the client.
17#[derive(Debug, Serialize, Deserialize)]
18#[serde(tag = "method", content = "params", rename_all = "camelCase")]
19pub enum BacktestRequest {
20    CreateBacktestSession(CreateBacktestSessionRequest),
21    Continue(ContinueParams),
22    ContinueTo(ContinueToParams),
23    ContinueSessionV1(ContinueSessionRequestV1),
24    ContinueToSessionV1(ContinueToSessionRequestV1),
25    CloseBacktestSession,
26    CloseSessionV1(CloseSessionRequestV1),
27    AttachBacktestSession {
28        session_id: String,
29        /// Last sequence number the client received. Responses after this sequence
30        /// will be replayed from the session's buffer. None = replay entire buffer.
31        last_sequence: Option<u64>,
32    },
33    /// Sent after reattaching and rebuilding any dependent subscriptions.
34    /// Allows the manager to resume a session that was paused for handoff.
35    ResumeAttachedSession,
36    AttachParallelControlSessionV2 {
37        control_session_id: String,
38        /// Last per-session sequence number received by the client. Responses after
39        /// these sequence numbers will be replayed from the manager's per-session
40        /// replay store. Missing sessions replay their entire retained history.
41        #[serde(default)]
42        last_sequences: BTreeMap<String, u64>,
43    },
44}
45
46/// Versioned payload for `CreateBacktestSession`.
47///
48/// - `V0` keeps backwards-compatible shape by using `CreateSessionParams` directly.
49/// - `V1` keeps the same shape and adds `parallel`.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(untagged)]
52pub enum CreateBacktestSessionRequest {
53    V1(CreateBacktestSessionRequestV1),
54    V0(CreateSessionParams),
55}
56
57impl CreateBacktestSessionRequest {
58    pub fn into_request_options(self) -> CreateBacktestSessionRequestOptions {
59        match self {
60            Self::V0(request) => CreateBacktestSessionRequestOptions {
61                request,
62                parallel: false,
63            },
64            Self::V1(CreateBacktestSessionRequestV1 { request, parallel }) => {
65                CreateBacktestSessionRequestOptions { request, parallel }
66            }
67        }
68    }
69
70    pub fn into_request_and_parallel(self) -> (CreateSessionParams, bool) {
71        let options = self.into_request_options();
72        (options.request, options.parallel)
73    }
74}
75
76impl From<CreateSessionParams> for CreateBacktestSessionRequest {
77    fn from(value: CreateSessionParams) -> Self {
78        Self::V0(value)
79    }
80}
81
82impl From<CreateBacktestSessionRequestV1> for CreateBacktestSessionRequest {
83    fn from(value: CreateBacktestSessionRequestV1) -> Self {
84        Self::V1(value)
85    }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct CreateBacktestSessionRequestV1 {
91    #[serde(flatten)]
92    pub request: CreateSessionParams,
93    pub parallel: bool,
94}
95
96#[derive(Debug, Clone)]
97pub struct CreateBacktestSessionRequestOptions {
98    pub request: CreateSessionParams,
99    pub parallel: bool,
100}
101
102#[derive(Debug, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct ContinueSessionRequestV1 {
105    pub session_id: String,
106    pub request: ContinueParams,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct ContinueToSessionRequestV1 {
112    pub session_id: String,
113    pub request: ContinueToParams,
114}
115
116#[derive(Debug, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct CloseSessionRequestV1 {
119    pub session_id: String,
120}
121
122/// A filter registered at session creation describing which upcoming batches
123/// the session should announce ahead of execution via
124/// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins). Each
125/// filter describes an event of interest (e.g. a specific program executing);
126/// the session "discovers" the batch in which that event will occur and
127/// emits a `DiscoveryBatch` so the client can pause immediately before it.
128#[serde_with::serde_as]
129#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
130#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
131pub enum DiscoveryFilter {
132    /// Discover batches containing a transaction that invokes this program.
133    ProgramExecuted(#[serde_as(as = "serde_with::DisplayFromStr")] Address),
134}
135
136/// Per-transaction facts a [`DiscoveryFilter`] can inspect when deciding
137/// whether to match. Callers build this once per transaction and feed it to
138/// every registered filter; new variants add fields here rather than growing
139/// the [`DiscoveryFilter::matches`] signature.
140pub struct TxMatchContext<'a> {
141    /// Programs invoked by the transaction (top-level + CPI observed in logs).
142    pub invoked_programs: &'a HashSet<Address>,
143}
144
145impl DiscoveryFilter {
146    /// Return `true` when this filter is satisfied by the transaction
147    /// described by `ctx`.
148    pub fn matches(&self, ctx: &TxMatchContext<'_>) -> bool {
149        match self {
150            Self::ProgramExecuted(target) => ctx.invoked_programs.contains(target),
151        }
152    }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub enum ActionKind {
158    Simulate,
159    Send,
160}
161
162/// Where a [`ScheduledAction`] fires.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(tag = "at", rename_all = "camelCase")]
165pub enum ActionAnchor {
166    /// At the end of each slot, after its transactions execute but before it
167    /// finalizes. Fires the entry for the current slot, if any.
168    AfterSlot {
169        #[serde(default, with = "compressed")]
170        slots: Vec<u64>,
171    },
172    /// Before each batch matching `filter` executes. The whole sequence fires at
173    /// each matching batch.
174    BeforeMatch { filter: DiscoveryFilter },
175    /// After each batch matching `filter` commits. The whole sequence fires at
176    /// each matching batch.
177    AfterMatch { filter: DiscoveryFilter },
178}
179
180/// Upper bound on a decompressed transactions blob, guarding the server against
181/// a decompression bomb in client input.
182const MAX_ACTION_TRANSACTIONS_BYTES: u64 = 64 * 1024 * 1024;
183
184/// serde adapter that stores any value as a base64-encoded, zstd-compressed JSON
185/// blob on the wire, so transaction bytes never travel raw. Used via
186/// `#[serde(with = "compressed")]` on [`ScheduledAction`]'s `transactions` field
187/// and [`ActionAnchor`]'s `slots` field.
188mod compressed {
189    use std::io::Read;
190
191    use base64::Engine as _;
192    use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned};
193
194    use super::{BASE64, MAX_ACTION_TRANSACTIONS_BYTES};
195
196    /// zstd compression level; `0` selects zstd's default (level 3).
197    const COMPRESSION_LEVEL: i32 = 0;
198
199    pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
200    where
201        T: Serialize,
202        S: Serializer,
203    {
204        let json = serde_json::to_vec(value).map_err(serde::ser::Error::custom)?;
205        let compressed = zstd::stream::encode_all(json.as_slice(), COMPRESSION_LEVEL)
206            .map_err(serde::ser::Error::custom)?;
207        serializer.serialize_str(&BASE64.encode(compressed))
208    }
209
210    pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
211    where
212        T: DeserializeOwned,
213        D: Deserializer<'de>,
214    {
215        let blob = String::deserialize(deserializer)?;
216        let compressed = BASE64
217            .decode(blob.as_bytes())
218            .map_err(serde::de::Error::custom)?;
219        let mut json = Vec::new();
220        zstd::stream::read::Decoder::new(compressed.as_slice())
221            .and_then(|d| Read::take(d, MAX_ACTION_TRANSACTIONS_BYTES).read_to_end(&mut json))
222            .map_err(serde::de::Error::custom)?;
223        serde_json::from_slice(&json).map_err(serde::de::Error::custom)
224    }
225}
226
227/// A sequence of transactions the server runs automatically during a backtest,
228/// with results streamed over the `actionSubscribe` subscription.
229#[serde_with::serde_as]
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct ScheduledAction {
233    pub anchor: ActionAnchor,
234    pub kind: ActionKind,
235    /// Base64-encoded transactions, carried over the wire as a base64-encoded,
236    /// zstd-compressed blob. For an `afterSlot` anchor, entry `i` fires at
237    /// `slots[i]` — the two lists must be the same length, and repeating a slot
238    /// runs its entries there in order as one sequence. For match anchors the
239    /// whole list fires, in order, at each matching batch. For `simulate`, each
240    /// transaction's writes are visible to the next without committing to the
241    /// session ledger.
242    #[serde(default, with = "compressed")]
243    pub transactions: Vec<String>,
244    /// Account state overrides seeding the first transaction's simulation, per
245    /// `simulateTransaction` `modifyAccountStates` semantics. Only valid for
246    /// `kind: simulate`.
247    #[serde(default)]
248    pub account_overrides: AccountModifications,
249    /// Accounts whose post-execution state to return, following
250    /// `simulateTransaction` semantics; reflects cumulative state after the
251    /// final transaction.
252    #[serde_as(as = "Vec<serde_with::DisplayFromStr>")]
253    #[serde(default)]
254    pub return_accounts: Vec<Address>,
255    /// Optional client tag echoed back on each `actionSubscribe` result to correlate it to this action.
256    #[serde(default)]
257    pub label: Option<String>,
258}
259
260/// Parameters required to start a new backtest session.
261#[serde_with::serde_as]
262#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
263#[serde(rename_all = "camelCase")]
264pub struct CreateSessionParams {
265    /// First slot (inclusive) to replay.
266    pub start_slot: u64,
267    /// Last slot (inclusive) to replay.
268    pub end_slot: u64,
269    #[serde_as(as = "BTreeSet<serde_with::DisplayFromStr>")]
270    #[serde(default)]
271    #[builder(default)]
272    /// Skip transactions signed by these addresses.
273    pub signer_filter: BTreeSet<Address>,
274    /// When true, include a session summary with transaction statistics in client-facing
275    /// `Completed` responses. Summary generation remains enabled internally for metrics.
276    #[serde(default)]
277    #[builder(default)]
278    pub send_summary: bool,
279    /// When true, each historical swap is re-quoted through the metis router and the routed
280    /// transaction is *simulated* just before the original executes — never committed, so the
281    /// replay is unaffected. Within a block the simulated swaps compose.
282    #[serde(default)]
283    #[builder(default)]
284    pub reroute_order_flow: bool,
285    /// Maximum seconds to wait for ECS capacity-related startup retries before
286    /// failing session creation. If not set (or 0), capacity errors fail immediately.
287    #[serde(default)]
288    pub capacity_wait_timeout_secs: Option<u16>,
289    /// Maximum seconds to keep the session alive after the control websocket disconnects.
290    /// If not set (or 0), the session tears down immediately on disconnect.
291    /// Maximum value: 900 (15 minutes).
292    #[serde(default)]
293    pub disconnect_timeout_secs: Option<u16>,
294    /// Extra compute units to add to each transaction's `SetComputeUnitLimit` budget.
295    /// Useful when replaying with an account override whose program uses more CU than
296    /// the original, causing otherwise-healthy transactions to run out of budget.
297    /// Only applied when a `SetComputeUnitLimit` instruction is already present.
298    #[serde(default)]
299    pub extra_compute_units: Option<u32>,
300    /// Agent configurations to run as sidecars alongside this session.
301    #[serde(default)]
302    #[builder(default)]
303    pub agents: Vec<AgentParams>,
304    /// Events of interest the session should watch for. When an upcoming
305    /// batch matches any filter, the server emits
306    /// [`BacktestResponse::DiscoveryBatch`] (and its session-event twins)
307    /// ahead of execution so the client can follow up with
308    /// [`BacktestRequest::ContinueTo`] to pause before the batch. Empty
309    /// means no batch discoveries are performed (existing behaviour).
310    #[serde(default, skip_serializing_if = "Vec::is_empty")]
311    #[builder(default)]
312    pub discoveries: Vec<DiscoveryFilter>,
313    /// Transactions the server runs automatically during the backtest, with
314    /// results streamed over `actionSubscribe`.
315    #[serde(default, skip_serializing_if = "Vec::is_empty")]
316    #[builder(default)]
317    pub actions: Vec<ScheduledAction>,
318}
319
320/// What counts as a "non-benign" divergence for the backtest session fail-fast
321/// behaviour (see `BacktestSessionOptions::fail_fast`).
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
323#[serde(rename_all = "kebab-case")]
324pub enum FailFastDivergenceKind {
325    /// Any divergence other than a benign log diff (the full `is_divergent()` set:
326    /// log mismatch, error mismatch, balance-diff mismatch).
327    #[default]
328    AnyNonBenign,
329    /// Only divergences on transactions that touch an account currently watched by an
330    /// account-diff subscription (subscribed directly, or owned by a subscribed program).
331    Tracked,
332}
333
334impl FailFastDivergenceKind {
335    /// Stable string form used for CLI/env-var serialization. Matches the serde
336    /// `kebab-case` representation.
337    pub fn as_str(self) -> &'static str {
338        match self {
339            Self::AnyNonBenign => "any-non-benign",
340            Self::Tracked => "tracked",
341        }
342    }
343
344    /// Inverse of [`Self::as_str`]; returns `None` for an unrecognized value.
345    pub fn from_str_opt(value: &str) -> Option<Self> {
346        match value {
347            "any-non-benign" => Some(Self::AnyNonBenign),
348            "tracked" => Some(Self::Tracked),
349            _ => None,
350        }
351    }
352}
353
354/// Available agent types for sidecar participation in backtest sessions.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357pub enum AgentType {
358    Arb,
359}
360
361/// Parameters for a circular arbitrage route.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase")]
364pub struct ArbRouteParams {
365    pub base_mint: String,
366    pub temp_mint: String,
367    #[serde(default)]
368    pub buy_dexes: Vec<String>,
369    #[serde(default)]
370    pub sell_dexes: Vec<String>,
371    pub min_input: u64,
372    pub max_input: u64,
373    #[serde(default)]
374    pub min_profit: u64,
375}
376
377/// Configuration for an agent to run alongside a backtest session.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct AgentParams {
381    pub agent_type: AgentType,
382    pub wallet: Option<String>,
383    /// Base58-encoded 64-byte keypair for signing transactions (compatible with `solana-keygen`).
384    pub keypair: Option<String>,
385    pub seed_sol_lamports: Option<u64>,
386    #[serde(default)]
387    pub seed_token_accounts: BTreeMap<String, u64>,
388    #[serde(default)]
389    pub arb_routes: Vec<ArbRouteParams>,
390}
391
392/// Account state modifications to apply.
393#[serde_with::serde_as]
394#[derive(Debug, Clone, Serialize, Deserialize, Default)]
395pub struct AccountModifications(
396    #[serde_as(as = "BTreeMap<serde_with::DisplayFromStr, _>")]
397    #[serde(default)]
398    pub BTreeMap<Address, AccountData>,
399);
400
401/// Arguments used to continue an existing session.
402#[serde_with::serde_as]
403#[derive(Debug, Serialize, Deserialize)]
404#[serde(rename_all = "camelCase")]
405pub struct ContinueParams {
406    #[serde(default = "ContinueParams::default_advance_count")]
407    /// Number of blocks to advance before waiting.
408    pub advance_count: u64,
409    #[serde(default)]
410    /// Base64-encoded transactions to execute before advancing.
411    pub transactions: Vec<String>,
412    #[serde(default)]
413    /// Account state overrides to apply ahead of execution.
414    pub modify_account_states: AccountModifications,
415}
416
417impl Default for ContinueParams {
418    fn default() -> Self {
419        Self {
420            advance_count: Self::default_advance_count(),
421            transactions: Vec::new(),
422            modify_account_states: AccountModifications(BTreeMap::new()),
423        }
424    }
425}
426
427impl ContinueParams {
428    pub fn default_advance_count() -> u64 {
429        1
430    }
431}
432
433/// Payload emitted when a session halts at a caller-specified point.
434/// `batch_index` is `None` for block-boundary pauses and `Some(n)` when the
435/// session stopped *before* batch `n` within `slot` (no transaction from
436/// batch `n` has been applied). While paused, RPC reads against the session
437/// observe partial state up through batch `n - 1`.
438#[derive(Debug, Clone, Serialize, Deserialize)]
439#[serde(rename_all = "camelCase")]
440pub struct PausedEvent {
441    pub slot: u64,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub batch_index: Option<u32>,
444}
445
446/// Payload emitted when the session has *discovered* an upcoming batch that
447/// matches one or more registered [`DiscoveryFilter`]s from session creation
448/// (for example, a batch containing a transaction that invokes a program of
449/// interest). The `(slot, batch_index)` pair can be fed directly to
450/// [`BacktestRequest::ContinueTo`] to pause immediately before the batch
451/// executes. After each `Continue` / `ContinueTo`, the session emits the
452/// next `DiscoveryBatchEvent` ahead of the next matching batch, enabling a
453/// reactive "pause on every discovery" driver loop.
454#[serde_with::serde_as]
455#[derive(Debug, Clone, Serialize, Deserialize)]
456#[serde(rename_all = "camelCase")]
457pub struct DiscoveryBatchEvent {
458    pub slot: u64,
459    pub batch_index: u32,
460    /// Filters that matched this batch (always non-empty).
461    pub matched: Vec<DiscoveryFilter>,
462    /// Encoded transactions in this batch that triggered the match. Each
463    /// entry carries the serialized `VersionedTransaction` bytes paired with
464    /// the encoding used.
465    pub transactions: Vec<EncodedBinary>,
466}
467
468/// Arguments used to step an existing session to a precise point.
469#[derive(Debug, Clone, Serialize, Deserialize)]
470#[serde(rename_all = "camelCase")]
471pub struct ContinueToParams {
472    /// Target slot to stop in (or at, if `batch_index` is `None`).
473    pub slot: u64,
474    /// Batch within the target slot at which to pause, **exclusive** — the
475    /// session halts immediately *before* batch `n` executes, so no
476    /// transaction in that batch has been applied yet. `None` runs the
477    /// whole slot, pausing at the block boundary. While paused, RPC reads
478    /// observe partial state up through batch `n - 1`.
479    #[serde(default)]
480    pub batch_index: Option<u32>,
481}
482
483/// Supported binary encodings for account/transaction payloads.
484#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
485#[serde(rename_all = "lowercase")]
486pub enum BinaryEncoding {
487    Base64,
488}
489
490impl BinaryEncoding {
491    pub fn encode(self, bytes: &[u8]) -> String {
492        match self {
493            Self::Base64 => BASE64.encode(bytes),
494        }
495    }
496
497    pub fn decode(self, data: &str) -> Result<Vec<u8>, Base64DecodeError> {
498        match self {
499            Self::Base64 => BASE64.decode(data),
500        }
501    }
502}
503
504/// A blob paired with the encoding needed to decode it.
505#[derive(Debug, Clone, Serialize, Deserialize)]
506#[serde(rename_all = "camelCase")]
507pub struct EncodedBinary {
508    /// Encoded payload.
509    pub data: String,
510    /// Encoding scheme used for the payload.
511    pub encoding: BinaryEncoding,
512}
513
514impl EncodedBinary {
515    pub fn new(data: String, encoding: BinaryEncoding) -> Self {
516        Self { data, encoding }
517    }
518
519    pub fn from_bytes(bytes: &[u8], encoding: BinaryEncoding) -> Self {
520        Self {
521            data: encoding.encode(bytes),
522            encoding,
523        }
524    }
525
526    pub fn decode(&self) -> Result<Vec<u8>, Base64DecodeError> {
527        self.encoding.decode(&self.data)
528    }
529}
530
531/// Account snapshot used to seed or modify state in a session.
532#[serde_with::serde_as]
533#[derive(Debug, Clone, Serialize, Deserialize)]
534#[serde(rename_all = "camelCase")]
535pub struct AccountData {
536    /// Account data bytes and encoding.
537    pub data: EncodedBinary,
538    /// Whether the account is executable.
539    pub executable: bool,
540    /// Lamport balance.
541    pub lamports: u64,
542    #[serde_as(as = "serde_with::DisplayFromStr")]
543    /// Account owner pubkey.
544    pub owner: Address,
545    /// Allocated space in bytes.
546    pub space: u64,
547}
548
549impl AccountData {
550    pub fn to_account(&self) -> Result<solana_account::Account, Base64DecodeError> {
551        Ok(solana_account::Account {
552            data: self.data.decode()?,
553            lamports: self.lamports,
554            owner: self.owner,
555            executable: self.executable,
556            rent_epoch: 0,
557        })
558    }
559}
560
561/// Responses returned over the backtest RPC channel.
562#[derive(Debug, Clone, Serialize, Deserialize)]
563#[serde(tag = "method", content = "params", rename_all = "camelCase")]
564pub enum BacktestResponse {
565    SessionCreated {
566        session_id: String,
567        rpc_endpoint: String,
568        #[serde(default, skip_serializing_if = "Option::is_none")]
569        task_id: Option<String>,
570    },
571    SessionAttached {
572        session_id: String,
573        rpc_endpoint: String,
574        #[serde(default, skip_serializing_if = "Option::is_none")]
575        task_id: Option<String>,
576    },
577    SessionsCreatedV2 {
578        control_session_id: String,
579        session_ids: Vec<String>,
580        #[serde(default)]
581        task_ids: Vec<Option<String>>,
582        /// Per-sub-session start/end slots, parallel to `session_ids`. The
583        /// server's split is authoritative (a mid-bundle request anchors to the
584        /// covering bundle), so the client binds each sub-session to its range
585        /// from here rather than re-deriving the split locally.
586        #[serde(default)]
587        start_slots: Vec<u64>,
588        #[serde(default)]
589        end_slots: Vec<u64>,
590    },
591    ParallelSessionAttachedV2 {
592        control_session_id: String,
593        session_ids: Vec<String>,
594        #[serde(default)]
595        task_ids: Vec<Option<String>>,
596    },
597    ReadyForContinue,
598    SlotNotification(u64),
599    Paused(PausedEvent),
600    DiscoveryBatch(DiscoveryBatchEvent),
601    Error(BacktestError),
602    Success,
603    Completed {
604        /// Session summary with transaction statistics.
605        /// The session always computes this summary, but management may omit it from
606        /// client-facing responses unless `send_summary` was requested at session creation.
607        #[serde(skip_serializing_if = "Option::is_none")]
608        summary: Option<SessionSummary>,
609        #[serde(default, skip_serializing_if = "Option::is_none")]
610        agent_stats: Option<Vec<AgentStatsReport>>,
611    },
612    Status {
613        status: BacktestStatus,
614    },
615    SessionEventV2 {
616        session_id: String,
617        seq_id: u64,
618        event: SessionEventKind,
619    },
620}
621
622impl BacktestResponse {
623    pub fn is_completed(&self) -> bool {
624        matches!(self, BacktestResponse::Completed { .. })
625    }
626
627    pub fn is_terminal(&self) -> bool {
628        match self {
629            BacktestResponse::Completed { .. } => true,
630            BacktestResponse::Error(e) => e.is_terminal(),
631            _ => false,
632        }
633    }
634}
635
636impl From<BacktestStatus> for BacktestResponse {
637    fn from(status: BacktestStatus) -> Self {
638        Self::Status { status }
639    }
640}
641
642impl From<String> for BacktestResponse {
643    fn from(message: String) -> Self {
644        BacktestError::Internal { error: message }.into()
645    }
646}
647
648impl From<&str> for BacktestResponse {
649    fn from(message: &str) -> Self {
650        BacktestError::Internal {
651            error: message.to_string(),
652        }
653        .into()
654    }
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize)]
658#[serde(tag = "method", content = "params", rename_all = "camelCase")]
659pub enum SessionEventKind {
660    ReadyForContinue,
661    SlotNotification(u64),
662    Paused(PausedEvent),
663    DiscoveryBatch(DiscoveryBatchEvent),
664    Error(BacktestError),
665    Success,
666    Completed {
667        #[serde(skip_serializing_if = "Option::is_none")]
668        summary: Option<SessionSummary>,
669    },
670    Status {
671        status: BacktestStatus,
672    },
673}
674
675impl SessionEventKind {
676    pub fn is_terminal(&self) -> bool {
677        match self {
678            Self::Completed { .. } => true,
679            Self::Error(e) => e.is_terminal(),
680            _ => false,
681        }
682    }
683}
684
685/// Wire format wrapper for responses sent over the control websocket.
686/// Sessions with `disconnect_timeout_secs > 0` use this to track client position.
687#[derive(Debug, Clone, Serialize, Deserialize)]
688#[serde(rename_all = "camelCase")]
689pub struct SequencedResponse {
690    pub seq_id: u64,
691    #[serde(flatten)]
692    pub response: BacktestResponse,
693}
694
695/// High-level progress states during a `Continue` call.
696#[derive(Debug, Clone, Serialize, Deserialize)]
697#[serde(rename_all = "camelCase")]
698pub enum BacktestStatus {
699    /// Runtime startup is in progress.
700    StartingRuntime,
701    DecodedTransactions,
702    AppliedAccountModifications,
703    ReadyToExecuteUserTransactions,
704    ExecutedUserTransactions,
705    ExecutingBlockTransactions,
706    ExecutedBlockTransactions,
707    ProgramAccountsLoaded,
708}
709
710impl std::fmt::Display for BacktestStatus {
711    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
712        let s = match self {
713            Self::StartingRuntime => "starting runtime",
714            Self::DecodedTransactions => "decoded transactions",
715            Self::AppliedAccountModifications => "applied account modifications",
716            Self::ReadyToExecuteUserTransactions => "ready to execute user transactions",
717            Self::ExecutedUserTransactions => "executed user transactions",
718            Self::ExecutingBlockTransactions => "executing block transactions",
719            Self::ExecutedBlockTransactions => "executed block transactions",
720            Self::ProgramAccountsLoaded => "program accounts loaded",
721        };
722        f.write_str(s)
723    }
724}
725
726/// Structured stats reported by an agent during a backtest session.
727#[derive(Debug, Clone, Default, Serialize, Deserialize)]
728#[serde(rename_all = "camelCase")]
729pub struct AgentStatsReport {
730    pub name: String,
731    pub slots_processed: u64,
732    pub opportunities_found: u64,
733    pub opportunities_skipped: u64,
734    pub no_routes: u64,
735    pub txs_produced: u64,
736    /// Cumulative expected profit per base mint, keyed by mint address.
737    pub expected_gain_by_mint: BTreeMap<String, i64>,
738    /// Transactions successfully executed by the sidecar.
739    #[serde(default)]
740    pub txs_submitted: u64,
741    /// Transactions that failed execution.
742    #[serde(default)]
743    pub txs_failed: u64,
744    /// Transactions rejected by preflight simulation (unprofitable).
745    #[serde(default)]
746    pub txs_simulation_rejected: u64,
747    /// Preflight simulation RPC calls that errored.
748    #[serde(default)]
749    pub txs_simulation_failed: u64,
750}
751
752/// High-level funnel for a reroute-order-flow session: how many detected swaps made it from
753/// re-quoting through simulation to a non-reverting execution. Per-swap economics and latency
754/// are session-internal and land in the analysis DB.
755#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
756#[serde(rename_all = "camelCase")]
757pub struct RerouteStatsReport {
758    /// Swaps re-quoted through metis and queued for simulation.
759    pub swaps_rerouted: u64,
760    /// Swaps whose routed transaction was simulated.
761    #[serde(default)]
762    pub swaps_simulated: u64,
763    /// Simulated swaps whose routed transaction executed without reverting.
764    #[serde(default)]
765    pub swaps_succeeded: u64,
766    /// Detected swaps metis could not route; nothing was simulated for them.
767    pub requote_failures: u64,
768}
769
770/// Summary of transaction execution statistics for a completed backtest session.
771#[derive(Debug, Clone, Default, Serialize, Deserialize)]
772#[serde(rename_all = "camelCase")]
773pub struct SessionSummary {
774    /// Number of simulations where simulator outcome matched on-chain outcome
775    /// (`true_success + true_failure`).
776    pub correct_simulation: usize,
777    /// Number of simulations where simulator outcome did not match on-chain outcome
778    /// (`false_success + false_failure`).
779    pub incorrect_simulation: usize,
780    /// Number of transactions that had execution errors in simulation.
781    pub execution_errors: usize,
782    /// Number of transactions with different balance diffs.
783    pub balance_diff: usize,
784    /// Number of transactions with different log diffs.
785    pub log_diff: usize,
786    /// Transactions skipped because they could not be sanitized (malformed message or
787    /// unresolvable address lookup table).
788    #[serde(default)]
789    pub unsanitizable: usize,
790    /// Aggregate reroute metrics, populated only for reroute-order-flow sessions. Boxed to
791    /// keep `SessionSummary` small in the many response enums that carry it.
792    #[serde(default, skip_serializing_if = "Option::is_none")]
793    pub reroute_stats: Option<Box<RerouteStatsReport>>,
794}
795
796impl SessionSummary {
797    /// Returns true if there were any execution deviations (errors or mismatched results).
798    pub fn has_deviations(&self) -> bool {
799        self.incorrect_simulation > 0 || self.execution_errors > 0 || self.balance_diff > 0
800    }
801
802    /// Total number of transactions processed.
803    pub fn total_transactions(&self) -> usize {
804        self.correct_simulation
805            + self.incorrect_simulation
806            + self.execution_errors
807            + self.balance_diff
808            + self.log_diff
809    }
810}
811
812impl std::fmt::Display for SessionSummary {
813    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
814        let total = self.total_transactions();
815        write!(
816            f,
817            "Session summary: {total} transactions\n\
818             \x20  - {} correct simulation\n\
819             \x20  - {} incorrect simulation\n\
820             \x20  - {} execution errors\n\
821             \x20  - {} unsanitizable\n\
822             \x20  - {} balance diffs\n\
823             \x20  - {} log diffs",
824            self.correct_simulation,
825            self.incorrect_simulation,
826            self.execution_errors,
827            self.unsanitizable,
828            self.balance_diff,
829            self.log_diff,
830        )
831    }
832}
833
834/// Error variants surfaced to backtest RPC clients.
835#[derive(Debug, Clone, Serialize, Deserialize)]
836#[serde(rename_all = "camelCase")]
837pub enum BacktestError {
838    InvalidTransactionEncoding {
839        index: usize,
840        error: String,
841    },
842    InvalidTransactionFormat {
843        index: usize,
844        error: String,
845    },
846    InvalidAccountEncoding {
847        address: String,
848        encoding: BinaryEncoding,
849        error: String,
850    },
851    InvalidAccountOwner {
852        address: String,
853        error: String,
854    },
855    InvalidAccountPubkey {
856        address: String,
857        error: String,
858    },
859    NoMoreBlocks,
860    AdvanceSlotFailed {
861        slot: u64,
862        error: String,
863    },
864    FinalizeSlotFailed {
865        slot: u64,
866        error: String,
867    },
868    InvalidRequest {
869        error: String,
870    },
871    Internal {
872        error: String,
873    },
874    InvalidBlockhashFormat {
875        slot: u64,
876        error: String,
877    },
878    InitializingSysvarsFailed {
879        slot: u64,
880        error: String,
881    },
882    ClerkError {
883        error: String,
884    },
885    SimulationError {
886        error: String,
887    },
888    SessionNotFound {
889        session_id: String,
890    },
891    SessionOwnerMismatch,
892    /// Session ownership is in transition (e.g. the previous manager is
893    /// shutting down, or another attach raced this one). Clients should retry
894    /// the attach within their reconnect budget; the route is expected to
895    /// become claimable shortly.
896    SessionOwnershipBusy {
897        reason: String,
898    },
899}
900
901impl BacktestError {
902    /// Errors the backtest cannot recover from mid-session — the single source
903    /// of truth for terminal-error classification, shared by the response/event
904    /// wrappers and the managed client.
905    pub fn is_terminal(&self) -> bool {
906        matches!(
907            self,
908            BacktestError::NoMoreBlocks
909                | BacktestError::AdvanceSlotFailed { .. }
910                | BacktestError::FinalizeSlotFailed { .. }
911                | BacktestError::Internal { .. }
912        )
913    }
914}
915
916/// One contiguous block range available on the history clerk.
917#[derive(Debug, Clone, Serialize, Deserialize)]
918pub struct AvailableRange {
919    pub bundle_start_slot: u64,
920    pub bundle_start_slot_utc: Option<String>,
921    pub max_bundle_end_slot: Option<u64>,
922    pub max_bundle_end_slot_utc: Option<String>,
923    pub max_bundle_size: Option<u64>,
924}
925
926/// Request body of `POST /build-bundle`.
927#[derive(Debug, Clone, Serialize, Deserialize)]
928pub struct BundleBuildRequest {
929    pub start_slot: u64,
930    pub end_slot: u64,
931    #[serde(default)]
932    pub bundle_size: Option<u64>,
933    /// Optional repeat key; a resubmit dedupes the quota charge.
934    #[serde(default)]
935    pub idempotency_key: Option<String>,
936}
937
938#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
939#[serde(rename_all = "snake_case")]
940pub enum BundleBuildStatus {
941    /// The Prefect flow run finished successfully; the bundle is built.
942    Completed,
943    /// The Prefect flow run reached a definitive failure state
944    /// (`Failed`/`Crashed`/`Cancelled`). Distinct from [`Self::NeedsInvestigation`]:
945    /// here the pipeline reported a real failure.
946    Failed,
947    /// The Prefect flow run is scheduled or running.
948    InProgress,
949    /// The flow-run state could not be determined (`Unknown` or missing) — the
950    /// outcome is genuinely unclear from Prefect, so a human should check rather
951    /// than assume success or failure.
952    NeedsInvestigation,
953}
954
955impl BundleBuildStatus {
956    /// Stable string form matching the serde `snake_case` wire representation.
957    pub fn as_str(self) -> &'static str {
958        match self {
959            Self::Completed => "completed",
960            Self::Failed => "failed",
961            Self::InProgress => "in_progress",
962            Self::NeedsInvestigation => "needs_investigation",
963        }
964    }
965}
966
967/// Response of the management bundle-build endpoints.
968#[derive(Debug, Clone, Serialize, Deserialize)]
969pub struct BundleBuildStatusResponse {
970    pub request_id: String,
971    pub start_slot: u64,
972    pub end_slot: u64,
973    pub bundle_size: Option<u64>,
974    pub status: BundleBuildStatus,
975    /// When the request id was created (unix ms) — lets clients show a live ETA
976    /// countdown by computing elapsed since this.
977    pub created_at_unix_ms: u64,
978}
979
980/// Response of the management `/snapshot-slot` endpoint. The chosen source and
981/// import state are internal, so only the resolved slot is exposed.
982#[derive(Debug, Clone, Serialize, Deserialize)]
983pub struct SnapshotSlotResponse {
984    pub snapshot_slot: u64,
985}
986
987/// Split a user-requested `[start_slot, end_slot]` range across the available
988/// bundle ranges, returning a list of contiguous, non-overlapping `(start, end)`
989/// pairs — one per bundle that the server can serve as its own session.
990///
991/// Each emitted `start` is a real `bundle_start_slot` and each `end` is within
992/// the `max_bundle_end_slot` of a bundle that begins *exactly* at that start
993/// (the server requires `start_slot == bundle_start_slot` and caps `end_slot` at
994/// that bundle's `max_bundle_end_slot`). Among all such gap-free splits we pick
995/// the one with the most sub-ranges, i.e. the highest parallelism: a fine-grained
996/// (e.g. 10k) bundle grid yields one session per fine bundle instead of
997/// collapsing onto a coarser series that overlaps the same slots — even when the
998/// coarse and fine bundles share a start slot. A coarser bundle is only ridden
999/// where no finer grid continues the walk, which still lets us serve a request
1000/// the coarse data covers even across a hole in the fine grid.
1001///
1002/// Returns an error if the requested start slot is not a bundle start, or if no
1003/// gap-free split reaches `requested_end`.
1004pub fn split_range(
1005    ranges: &[AvailableRange],
1006    requested_start: u64,
1007    requested_end: u64,
1008) -> Result<Vec<(u64, u64)>, String> {
1009    if requested_end < requested_start {
1010        return Err(format!(
1011            "invalid range: start_slot {requested_start} > end_slot {requested_end}"
1012        ));
1013    }
1014
1015    // Every advertised end per bundle start. A start can carry several ends — a
1016    // 50k snapshot bundle and the 10k bundle built on the same snapshot share a
1017    // start slot but reach different ends — so we keep them all and let the walk
1018    // pick whichever maximises parallelism.
1019    let mut ends_by_start: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
1020    for r in ranges {
1021        if let Some(end) = r.max_bundle_end_slot
1022            && end > r.bundle_start_slot
1023        {
1024            ends_by_start
1025                .entry(r.bundle_start_slot)
1026                .or_default()
1027                .insert(end);
1028        }
1029    }
1030
1031    // Anchor a request that lands mid-bundle to the covering bundle's start —
1032    // the latest bundle beginning at or before `requested_start` that still
1033    // covers it. The server's `select_parallel_requests` shares this fn and
1034    // anchors the same way, so the client must too or it would falsely reject a
1035    // start that isn't itself a bundle boundary. An exact bundle-start request
1036    // anchors to itself.
1037    let Some((&anchor_start, _)) = ends_by_start.range(..=requested_start).rfind(|(_, ends)| {
1038        ends.iter()
1039            .next_back()
1040            .is_some_and(|&end| end >= requested_start)
1041    }) else {
1042        return Err(format!(
1043            "start_slot {requested_start} is not covered by any available bundle range"
1044        ));
1045    };
1046
1047    // Walk the bundle starts from `requested_end` backwards, recording for each
1048    // start the gap-free split with the most sub-ranges (highest parallelism).
1049    // A sub-range ends within one of its start's bundles and the next sub-range
1050    // must begin on the very next slot, so a coarse bundle is ridden only when no
1051    // finer bundle continues the walk.
1052    let mut best_from: BTreeMap<u64, Vec<(u64, u64)>> = BTreeMap::new();
1053    for (&start, ends) in ends_by_start.range(anchor_start..=requested_end).rev() {
1054        let mut best: Option<Vec<(u64, u64)>> = None;
1055        for &end in ends {
1056            let candidate = if end >= requested_end {
1057                Some(vec![(start, requested_end)])
1058            } else {
1059                best_from.get(&(end + 1)).map(|rest| {
1060                    std::iter::once((start, end))
1061                        .chain(rest.iter().copied())
1062                        .collect()
1063                })
1064            };
1065            if let Some(candidate) = candidate
1066                && best.as_ref().is_none_or(|b| candidate.len() > b.len())
1067            {
1068                best = Some(candidate);
1069            }
1070        }
1071        if let Some(best) = best {
1072            best_from.insert(start, best);
1073        }
1074    }
1075
1076    best_from.remove(&anchor_start).ok_or_else(|| {
1077        // Point at the first uncovered slot when the bundles leave a hole; fall
1078        // back to a generic message when the range is covered but cannot be
1079        // tiled to bundle boundaries.
1080        let mut covered_to = anchor_start.saturating_sub(1);
1081        for (&start, ends) in ends_by_start.range(anchor_start..=requested_end) {
1082            if start > covered_to.saturating_add(1) {
1083                break;
1084            }
1085            if let Some(&end) = ends.iter().next_back() {
1086                covered_to = covered_to.max(end);
1087            }
1088        }
1089        if covered_to < requested_end {
1090            format!("gap in coverage at slot {}", covered_to + 1)
1091        } else {
1092            format!(
1093                "no gap-free split of [{requested_start}, {requested_end}] aligns with the available bundle ranges"
1094            )
1095        }
1096    })
1097}
1098
1099impl From<BacktestError> for BacktestResponse {
1100    fn from(error: BacktestError) -> Self {
1101        Self::Error(error)
1102    }
1103}
1104
1105impl std::error::Error for BacktestError {}
1106
1107impl fmt::Display for BacktestError {
1108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1109        match self {
1110            BacktestError::InvalidTransactionEncoding { index, error } => {
1111                write!(f, "invalid transaction encoding at index {index}: {error}")
1112            }
1113            BacktestError::InvalidTransactionFormat { index, error } => {
1114                write!(f, "invalid transaction format at index {index}: {error}")
1115            }
1116            BacktestError::InvalidAccountEncoding {
1117                address,
1118                encoding,
1119                error,
1120            } => write!(
1121                f,
1122                "invalid encoding for account {address} ({encoding:?}): {error}"
1123            ),
1124            BacktestError::InvalidAccountOwner { address, error } => {
1125                write!(f, "invalid owner for account {address}: {error}")
1126            }
1127            BacktestError::InvalidAccountPubkey { address, error } => {
1128                write!(f, "invalid account pubkey {address}: {error}")
1129            }
1130            BacktestError::NoMoreBlocks => write!(f, "no more blocks available"),
1131            BacktestError::AdvanceSlotFailed { slot, error } => {
1132                write!(f, "failed to advance to slot {slot}: {error}")
1133            }
1134            BacktestError::FinalizeSlotFailed { slot, error } => {
1135                write!(f, "failed to finalize slot {slot}: {error}")
1136            }
1137            BacktestError::InvalidRequest { error } => write!(f, "invalid request: {error}"),
1138            BacktestError::Internal { error } => write!(f, "internal error: {error}"),
1139            BacktestError::InvalidBlockhashFormat { slot, error } => {
1140                write!(f, "invalid blockhash at slot {slot}: {error}")
1141            }
1142            BacktestError::InitializingSysvarsFailed { slot, error } => {
1143                write!(f, "failed to initialize sysvars at slot {slot}: {error}")
1144            }
1145            BacktestError::ClerkError { error } => write!(f, "clerk error: {error}"),
1146            BacktestError::SimulationError { error } => {
1147                write!(f, "simulation error: {error}")
1148            }
1149            BacktestError::SessionNotFound { session_id } => {
1150                write!(f, "session not found: {session_id}")
1151            }
1152            BacktestError::SessionOwnerMismatch => {
1153                write!(f, "session owner mismatch")
1154            }
1155            BacktestError::SessionOwnershipBusy { reason } => {
1156                write!(f, "session ownership busy: {reason}")
1157            }
1158        }
1159    }
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165
1166    #[test]
1167    fn fail_fast_divergence_kind_str_round_trips() {
1168        for kind in [
1169            FailFastDivergenceKind::AnyNonBenign,
1170            FailFastDivergenceKind::Tracked,
1171        ] {
1172            assert_eq!(
1173                FailFastDivergenceKind::from_str_opt(kind.as_str()),
1174                Some(kind)
1175            );
1176        }
1177        assert_eq!(FailFastDivergenceKind::from_str_opt("nonsense"), None);
1178        assert_eq!(
1179            FailFastDivergenceKind::default(),
1180            FailFastDivergenceKind::AnyNonBenign
1181        );
1182    }
1183
1184    #[test]
1185    fn bundle_build_request_optional_fields_default_to_none() {
1186        let req: BundleBuildRequest =
1187            serde_json::from_str(r#"{"start_slot":1,"end_slot":2}"#).expect("parse");
1188        assert_eq!((req.start_slot, req.end_slot), (1, 2));
1189        assert_eq!(req.bundle_size, None);
1190        assert_eq!(req.idempotency_key, None);
1191    }
1192
1193    #[test]
1194    fn bundle_build_request_parses_optional_fields() {
1195        let req: BundleBuildRequest = serde_json::from_str(
1196            r#"{"start_slot":1,"end_slot":2,"bundle_size":500,"idempotency_key":"abc"}"#,
1197        )
1198        .expect("parse");
1199        assert_eq!(req.bundle_size, Some(500));
1200        assert_eq!(req.idempotency_key.as_deref(), Some("abc"));
1201    }
1202
1203    #[test]
1204    fn bundle_build_status_serde_round_trips_with_snake_case() {
1205        let cases = [
1206            (BundleBuildStatus::Completed, "\"completed\""),
1207            (BundleBuildStatus::Failed, "\"failed\""),
1208            (BundleBuildStatus::InProgress, "\"in_progress\""),
1209            (
1210                BundleBuildStatus::NeedsInvestigation,
1211                "\"needs_investigation\"",
1212            ),
1213        ];
1214        for (status, expected) in cases {
1215            assert_eq!(serde_json::to_string(&status).unwrap(), expected);
1216            assert_eq!(
1217                serde_json::from_str::<BundleBuildStatus>(expected).unwrap(),
1218                status
1219            );
1220            assert_eq!(status.as_str(), expected.trim_matches('"'));
1221        }
1222        assert!(serde_json::from_str::<BundleBuildStatus>("\"queued\"").is_err());
1223    }
1224
1225    #[test]
1226    fn bundle_build_status_response_serializes_request_and_status() {
1227        let response = BundleBuildStatusResponse {
1228            request_id: "r".to_string(),
1229            start_slot: 100,
1230            end_slot: 200,
1231            bundle_size: Some(50),
1232            status: BundleBuildStatus::InProgress,
1233            created_at_unix_ms: 0,
1234        };
1235        let json = serde_json::to_value(&response).unwrap();
1236        assert_eq!(json["request_id"].as_str(), Some("r"));
1237        assert_eq!(json["start_slot"].as_u64(), Some(100));
1238        assert_eq!(json["bundle_size"].as_u64(), Some(50));
1239        assert_eq!(json["status"].as_str(), Some("in_progress"));
1240        assert_eq!(json["created_at_unix_ms"].as_u64(), Some(0));
1241    }
1242
1243    fn range(start: u64, end: u64) -> AvailableRange {
1244        AvailableRange {
1245            bundle_start_slot: start,
1246            bundle_start_slot_utc: None,
1247            max_bundle_end_slot: Some(end),
1248            max_bundle_end_slot_utc: None,
1249            max_bundle_size: None,
1250        }
1251    }
1252
1253    /// Each case lists the available bundles, the requested `[start, end]`, and
1254    /// the expected split — `Some(_)` for an accepted plan, `None` when the range
1255    /// is unservable and `split_range` must error.
1256    #[rstest::rstest]
1257    #[case::single(vec![range(100, 300)], 100, 300, Some(vec![(100, 300)]))]
1258    #[case::multi(
1259        vec![range(100, 200), range(201, 300), range(301, 400)],
1260        100, 300, Some(vec![(100, 200), (201, 300)])
1261    )]
1262    // Smaller bundles nested inside a larger one don't bridge the gap, but the
1263    // range is still coverable by riding the coarse series.
1264    #[case::nested(
1265        vec![range(100, 500), range(110, 150), range(150, 190), range(501, 900)],
1266        100, 900, Some(vec![(100, 500), (501, 900)])
1267    )]
1268    // A fine grid (1k bundles standing in for the 10k grid) overlapped by a
1269    // coarser, differently-aligned series: ride the fine grid, never landing on
1270    // the coarse bundle's drifted start.
1271    #[case::prefers_finer_grid(
1272        vec![range(1_000, 1_999), range(1_500, 3_400), range(2_000, 2_999), range(3_000, 3_999)],
1273        1_000, 3_999, Some(vec![(1_000, 1_999), (2_000, 2_999), (3_000, 3_999)])
1274    )]
1275    // A 50k snapshot bundle and the 10k bundles built on it share start 100: ride
1276    // the 10k grid instead of the coarse end, which would strand the cursor on a
1277    // slot no bundle starts at.
1278    #[case::shared_start_prefers_finer(
1279        vec![range(100, 150), range(100, 120), range(121, 140), range(141, 160)],
1280        100, 160, Some(vec![(100, 120), (121, 140), (141, 160)])
1281    )]
1282    // A coarse bundle (100, 200) overlaps a finer pair covering the same span:
1283    // split_range maximises parallelism, riding the fine pair (two sessions)
1284    // rather than merging onto the coarse max end (one session).
1285    #[case::coarse_overlap_prefers_finer_pair(
1286        vec![range(100, 200), range(100, 150), range(151, 160)],
1287        100, 160, Some(vec![(100, 150), (151, 160)])
1288    )]
1289    // The fine grid has a hole (nothing starts at 141), but a coarse bundle
1290    // sharing the start spans it: serve the request off the coarse bundle rather
1291    // than reporting an unsupported range.
1292    #[case::falls_back_to_coarse(
1293        vec![range(100, 160), range(100, 120), range(121, 140)],
1294        100, 160, Some(vec![(100, 160)])
1295    )]
1296    // The last bundle overshoots the requested end and is clamped.
1297    #[case::clamps_final_bundle(vec![range(100, 199), range(200, 999)], 100, 450, Some(vec![(100, 199), (200, 450)]))]
1298    // A request landing mid-bundle anchors to the covering bundle's start
1299    // (matching the server) instead of erroring as "not covered".
1300    #[case::anchors_mid_bundle(vec![range(150, 350)], 200, 300, Some(vec![(150, 300)]))]
1301    #[case::anchors_then_continues(
1302        vec![range(150, 350), range(351, 600)],
1303        200, 600, Some(vec![(150, 350), (351, 600)])
1304    )]
1305    #[case::start_inside_bundle_anchors(vec![range(200, 400)], 300, 400, Some(vec![(200, 400)]))]
1306    // Errors: a start before any bundle's coverage has no covering bundle...
1307    #[case::start_before_first_bundle(vec![range(200, 400)], 100, 400, None)]
1308    // ...the end must be reachable...
1309    #[case::end_not_covered(vec![range(100, 200)], 100, 300, None)]
1310    #[case::gap_in_coverage(vec![range(100, 200), range(210, 300)], 100, 300, None)]
1311    // ...and the range must not be inverted.
1312    #[case::inverted_range(vec![range(100, 300)], 300, 100, None)]
1313    // Per-API-key isolation: a user's finer bundles and a coarse GLOBAL bundle
1314    // sharing a start are fed as SEPARATE entries (never merged on snapshot_slot,
1315    // which would keep only the max end). split_range set-unions the ends and
1316    // rides the finer user grid — a regression guard against re-merging.
1317    #[case::user_and_global_ranges_not_collapsed(
1318        vec![range(100, 200), range(100, 150), range(151, 200)],
1319        100, 200, Some(vec![(100, 150), (151, 200)])
1320    )]
1321    fn split_range_cases(
1322        #[case] ranges: Vec<AvailableRange>,
1323        #[case] start: u64,
1324        #[case] end: u64,
1325        #[case] expected: Option<Vec<(u64, u64)>>,
1326    ) {
1327        match expected {
1328            Some(expected) => assert_eq!(split_range(&ranges, start, end).unwrap(), expected),
1329            None => assert!(split_range(&ranges, start, end).is_err()),
1330        }
1331    }
1332
1333    /// Servable end per bundle start, as `split_range` sees it — every advertised
1334    /// end with `end > start`, keyed by start.
1335    fn ends_by_start(ranges: &[AvailableRange]) -> BTreeMap<u64, BTreeSet<u64>> {
1336        let mut ends: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
1337        for r in ranges {
1338            if let Some(end) = r.max_bundle_end_slot
1339                && end > r.bundle_start_slot
1340            {
1341                ends.entry(r.bundle_start_slot).or_default().insert(end);
1342            }
1343        }
1344        ends
1345    }
1346
1347    /// Independent spec for the optimum: the gap-free split of `[start, end]` with
1348    /// the most sub-ranges, found by exhaustively trying every advertised end at
1349    /// each cursor. `None` when no split aligns with the bundle starts.
1350    fn reference_max_split(
1351        ends: &BTreeMap<u64, BTreeSet<u64>>,
1352        cursor: u64,
1353        end: u64,
1354    ) -> Option<Vec<(u64, u64)>> {
1355        ends.get(&cursor)?
1356            .iter()
1357            .filter_map(|&bundle_end| {
1358                if bundle_end >= end {
1359                    Some(vec![(cursor, end)])
1360                } else {
1361                    reference_max_split(ends, bundle_end + 1, end).map(|mut rest| {
1362                        rest.insert(0, (cursor, bundle_end));
1363                        rest
1364                    })
1365                }
1366            })
1367            .max_by_key(Vec::len)
1368    }
1369
1370    /// Structure-independent check that a split is one the server can serve: it
1371    /// tiles `[start, end]` gap-free, every start is a real bundle start, and
1372    /// every end is within that start's largest advertised end.
1373    fn is_valid_split(
1374        split: &[(u64, u64)],
1375        ends: &BTreeMap<u64, BTreeSet<u64>>,
1376        start: u64,
1377        end: u64,
1378    ) -> bool {
1379        split.first().is_some_and(|&(s, _)| s == start)
1380            && split.last().is_some_and(|&(_, e)| e == end)
1381            && split.windows(2).all(|w| w[1].0 == w[0].1 + 1)
1382            && split.iter().all(|&(s, e)| {
1383                e >= s
1384                    && ends
1385                        .get(&s)
1386                        .and_then(|bundle_ends| bundle_ends.iter().next_back())
1387                        .is_some_and(|&max_end| e <= max_end)
1388            })
1389    }
1390
1391    /// Property test: across many randomized bundle layouts, `split_range` must
1392    /// return a *valid* split with the *maximum* number of sub-ranges whenever one
1393    /// exists, and error exactly when none does. Deterministic (fixed LCG seed) so
1394    /// a failure is reproducible.
1395    #[test]
1396    fn split_range_matches_reference() {
1397        let mut seed: u64 = 0x9E3779B97F4A7C15;
1398        let mut next = || {
1399            seed = seed
1400                .wrapping_mul(6364136223846793005)
1401                .wrapping_add(1442695040888963407);
1402            seed >> 33
1403        };
1404
1405        for _ in 0..50_000 {
1406            // A small slot universe makes shared starts, overlaps, nesting, and
1407            // gaps all common.
1408            let ranges: Vec<AvailableRange> = (0..next() % 6)
1409                .map(|_| {
1410                    let start = next() % 12;
1411                    range(start, start + next() % 6) // len 0 => filtered out
1412                })
1413                .collect();
1414            let start = next() % 12;
1415            let end = start + next() % 6; // sometimes start == end, sometimes unservable
1416
1417            let got = split_range(&ranges, start, end);
1418            let ends = ends_by_start(&ranges);
1419            // split_range anchors a mid-bundle start to the covering bundle's
1420            // start, so the reference must walk from the same anchor.
1421            let anchor = ends
1422                .range(..=start)
1423                .rfind(|(_, e)| e.iter().next_back().is_some_and(|&x| x >= start))
1424                .map(|(&s, _)| s);
1425            let reference = anchor.and_then(|a| reference_max_split(&ends, a, end));
1426
1427            let layout: Vec<_> = ranges
1428                .iter()
1429                .map(|r| (r.bundle_start_slot, r.max_bundle_end_slot))
1430                .collect();
1431            match (&got, &reference) {
1432                (Ok(split), Some(best)) => {
1433                    assert!(
1434                        is_valid_split(split, &ends, anchor.unwrap(), end),
1435                        "invalid split {split:?} for {layout:?} [{start},{end}]"
1436                    );
1437                    assert_eq!(
1438                        split.len(),
1439                        best.len(),
1440                        "suboptimal split {split:?} vs {best:?} for {layout:?} [{start},{end}]"
1441                    );
1442                }
1443                (Err(_), None) => {}
1444                _ => panic!(
1445                    "disagreement: split_range={got:?}, reference={reference:?} for {layout:?} [{start},{end}]"
1446                ),
1447            }
1448        }
1449    }
1450}