1use std::collections::BTreeSet;
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::replay::{ReplayError, ReplayResult, SlaThresholds};
10
11pub const CURRENT_REPLAY_SPEC_VERSION: u32 = 1;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct ReplaySpec {
19 #[serde(default = "default_spec_version")]
20 pub version: u32,
21 pub topology: ReplayTopology,
22 #[serde(default)]
23 pub engine: Value,
24 #[serde(default)]
25 pub adapters: ReplayAdapters,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub max_sim_time_ms: Option<f64>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub max_in_flight: Option<usize>,
36 #[serde(
41 default = "default_record_per_request",
42 skip_serializing_if = "is_true"
43 )]
44 pub record_per_request: bool,
45 #[serde(default, skip_serializing_if = "SlaThresholds::is_unset")]
47 pub sla: SlaThresholds,
48 pub requests: Vec<ReplayRequest>,
49}
50
51impl ReplaySpec {
52 pub fn validate(&self) -> ReplayResult<()> {
53 if self.version != CURRENT_REPLAY_SPEC_VERSION {
54 return Err(ReplayError::InvalidSpec(format!(
55 "unsupported version {}; expected {}",
56 self.version, CURRENT_REPLAY_SPEC_VERSION
57 )));
58 }
59 self.topology.validate()?;
60 if let Some(max_sim_time_ms) = self.max_sim_time_ms {
61 validate_time("max_sim_time_ms", max_sim_time_ms)?;
62 }
63 if self.max_in_flight == Some(0) {
64 return Err(ReplayError::InvalidSpec(
65 "max_in_flight must be positive".to_string(),
66 ));
67 }
68 self.sla.validate()?;
69
70 let mut ids = BTreeSet::new();
71 for request in &self.requests {
72 request.validate()?;
73 if !ids.insert(request.id.clone()) {
74 return Err(ReplayError::InvalidSpec(format!(
75 "duplicate request id {:?}",
76 request.id
77 )));
78 }
79 }
80 Ok(())
81 }
82}
83
84fn default_spec_version() -> u32 {
85 CURRENT_REPLAY_SPEC_VERSION
86}
87
88fn default_record_per_request() -> bool {
89 true
90}
91
92fn is_true(value: &bool) -> bool {
93 *value
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(tag = "kind", rename_all = "snake_case")]
98pub enum ReplayTopology {
99 Aggregated {
100 workers: WorkerPoolSpec,
101 },
102 Disaggregated {
103 prefill: WorkerPoolSpec,
104 decode: WorkerPoolSpec,
105 #[serde(default)]
108 handoff_latency_ms: f64,
109 },
110}
111
112impl ReplayTopology {
113 pub fn aggregated(workers: usize) -> Self {
114 Self::Aggregated {
115 workers: WorkerPoolSpec {
116 initial_workers: workers,
117 ..WorkerPoolSpec::default()
118 },
119 }
120 }
121
122 pub fn validate(&self) -> ReplayResult<()> {
123 match self {
124 Self::Aggregated { workers } => workers.validate("aggregated"),
125 Self::Disaggregated {
126 prefill,
127 decode,
128 handoff_latency_ms,
129 } => {
130 prefill.validate("prefill")?;
131 decode.validate("decode")?;
132 validate_time("handoff_latency_ms", *handoff_latency_ms)
133 }
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct WorkerPoolSpec {
140 pub initial_workers: usize,
141 #[serde(default)]
142 pub startup_delay_ms: f64,
143}
144
145impl Default for WorkerPoolSpec {
146 fn default() -> Self {
147 Self {
148 initial_workers: 1,
149 startup_delay_ms: 0.0,
150 }
151 }
152}
153
154impl WorkerPoolSpec {
155 fn validate(&self, name: &str) -> ReplayResult<()> {
156 if self.initial_workers == 0 {
157 return Err(ReplayError::InvalidSpec(format!(
158 "{name} pool must start with at least one worker"
159 )));
160 }
161 validate_time(&format!("{name} startup_delay_ms"), self.startup_delay_ms)
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct ReplayRequest {
167 pub id: String,
168 pub arrival_time_ms: f64,
169 pub input_tokens: usize,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub input_token_ids: Option<Vec<u32>>,
174 pub output_tokens: usize,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub output_token_ids: Option<Vec<u32>>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub dp_rank: Option<u32>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub session_id: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub turn_index: Option<usize>,
187 #[serde(default, skip_serializing_if = "Value::is_null")]
189 pub metadata: Value,
190}
191
192impl ReplayRequest {
193 pub fn validate(&self) -> ReplayResult<()> {
194 if self.id.is_empty() {
195 return Err(ReplayError::InvalidSpec(
196 "request id must not be empty".to_string(),
197 ));
198 }
199 validate_time("request arrival_time_ms", self.arrival_time_ms)?;
200 if let Some(input_token_ids) = &self.input_token_ids
201 && input_token_ids.len() != self.input_tokens
202 {
203 return Err(ReplayError::InvalidSpec(format!(
204 "request {:?} declares {} input tokens but materializes {} token IDs",
205 self.id,
206 self.input_tokens,
207 input_token_ids.len()
208 )));
209 }
210 self.routing_metadata()?;
211 Ok(())
212 }
213
214 pub fn routing_metadata(&self) -> ReplayResult<ReplayRoutingMetadata> {
218 if self.metadata.is_null() {
219 return Ok(ReplayRoutingMetadata::default());
220 }
221 serde_json::from_value(self.metadata.clone()).map_err(|error| {
222 ReplayError::InvalidSpec(format!(
223 "request {:?} has invalid routing metadata: {error}",
224 self.id
225 ))
226 })
227 }
228}
229
230#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
233pub struct ReplayRoutingMetadata {
234 #[serde(default)]
235 pub priority: i32,
236 #[serde(default)]
237 pub strict_priority: u32,
238 #[serde(default)]
239 pub policy_class: Option<String>,
240}
241
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
243pub struct ReplayAdapters {
244 #[serde(default = "ProviderSpec::round_robin")]
245 pub placement: ProviderSpec,
246 #[serde(default = "ProviderSpec::no_scaling")]
247 pub scaling: ProviderSpec,
248}
249
250impl Default for ReplayAdapters {
251 fn default() -> Self {
252 Self {
253 placement: ProviderSpec::round_robin(),
254 scaling: ProviderSpec::no_scaling(),
255 }
256 }
257}
258
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct ProviderSpec {
261 pub provider: String,
262 #[serde(default)]
263 pub config: Value,
264}
265
266impl ProviderSpec {
267 pub fn round_robin() -> Self {
268 Self {
269 provider: "round_robin".to_string(),
270 config: Value::Null,
271 }
272 }
273
274 pub fn no_scaling() -> Self {
275 Self {
276 provider: "none".to_string(),
277 config: Value::Null,
278 }
279 }
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
283#[serde(rename_all = "snake_case")]
284pub enum WorkerStage {
285 Aggregated,
286 Prefill,
287 Decode,
288}
289
290fn validate_time(name: &str, value: f64) -> ReplayResult<()> {
291 if !value.is_finite() || value < 0.0 {
292 return Err(ReplayError::InvalidSpec(format!(
293 "{name} must be finite and non-negative, got {value}"
294 )));
295 }
296 Ok(())
297}