Skip to main content

simulator_api/
lib.rs

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