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