1use core::fmt;
9
10use crate::{snapshot::PermissionBits, units::CostUnits};
11
12pub trait OpIndex {
18 fn index(&self) -> usize;
21}
22
23impl<O: OpIndex + ?Sized> OpIndex for &O {
24 #[inline]
25 fn index(&self) -> usize {
26 (**self).index()
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub struct CostQuote {
34 pub total: CostUnits,
36 pub fixed: CostUnits,
38 pub variable: CostUnits,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub enum QuoteError {
47 EmptyWorkload,
49 UnknownOperation {
51 index: usize,
53 },
54 Overflow,
56}
57
58impl fmt::Display for QuoteError {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 match self {
61 QuoteError::EmptyWorkload => f.write_str("workload is empty"),
62 QuoteError::UnknownOperation { index } => {
63 write!(f, "operation index {index} is not in the cost table")
64 }
65 QuoteError::Overflow => f.write_str("cost quote overflowed"),
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77pub struct CostTable {
78 fixed_request: CostUnits,
79 minimum_charge: CostUnits,
80 weights: Box<[Option<CostUnits>]>,
81 #[cfg_attr(
90 feature = "serde",
91 serde(default, skip_serializing_if = "<[PermissionBits]>::is_empty")
92 )]
93 permissions: Box<[PermissionBits]>,
94}
95
96impl CostTable {
97 #[must_use]
102 pub fn builder(fixed_request: CostUnits, minimum_charge: CostUnits) -> CostTableBuilder {
103 CostTableBuilder {
104 fixed_request,
105 minimum_charge,
106 weights: Vec::new(),
107 permissions: Vec::new(),
108 }
109 }
110
111 #[inline]
130 pub fn quote(&self, op: &impl OpIndex, items: u64) -> Result<CostQuote, QuoteError> {
131 if items == 0 {
132 return Err(QuoteError::EmptyWorkload);
133 }
134 self.quote_weight(self.weight_at(op.index())?, items)
135 }
136
137 #[inline]
140 pub fn quote_workload<O: OpIndex>(
141 &self,
142 workload: &[(O, u64)],
143 ) -> Result<(CostQuote, u64, PermissionBits), QuoteError> {
144 let mut items = 0_u64;
145 let mut variable = CostUnits::ZERO;
146 let mut required = PermissionBits::NONE;
147 for (op, count) in workload {
148 if *count == 0 {
149 continue;
150 }
151 items = items.checked_add(*count).ok_or(QuoteError::Overflow)?;
152 let index = op.index();
153 let per_item = self.weight_at(index)?;
154 let entry = per_item.checked_mul(*count).ok_or(QuoteError::Overflow)?;
155 variable = variable.checked_add(entry).ok_or(QuoteError::Overflow)?;
156 required = required.union(self.required_at(index));
157 }
158 if items == 0 {
159 return Err(QuoteError::EmptyWorkload);
160 }
161 let subtotal = self
162 .fixed_request
163 .checked_add(variable)
164 .ok_or(QuoteError::Overflow)?;
165 Ok((
166 CostQuote {
167 total: subtotal.max(self.minimum_charge),
168 fixed: self.fixed_request,
169 variable,
170 },
171 items,
172 required,
173 ))
174 }
175
176 #[inline]
180 fn weight_at(&self, index: usize) -> Result<CostUnits, QuoteError> {
181 match self.weights.get(index) {
182 Some(Some(weight)) => Ok(*weight),
183 _ => Err(QuoteError::UnknownOperation { index }),
184 }
185 }
186
187 #[inline]
189 fn required_at(&self, index: usize) -> PermissionBits {
190 self.permissions
191 .get(index)
192 .copied()
193 .unwrap_or(PermissionBits::NONE)
194 }
195
196 #[inline]
200 pub(crate) fn quote_weight(
201 &self,
202 per_item: CostUnits,
203 items: u64,
204 ) -> Result<CostQuote, QuoteError> {
205 let variable = per_item.checked_mul(items).ok_or(QuoteError::Overflow)?;
206 let subtotal = self
207 .fixed_request
208 .checked_add(variable)
209 .ok_or(QuoteError::Overflow)?;
210 Ok(CostQuote {
211 total: subtotal.max(self.minimum_charge),
212 fixed: self.fixed_request,
213 variable,
214 })
215 }
216
217 pub(crate) fn maximum_weight(&self) -> Option<(usize, CostUnits)> {
222 let mut maximum = None;
223 for (index, weight) in self.weights.iter().enumerate() {
224 let Some(weight) = *weight else {
225 continue;
226 };
227 if maximum.is_none_or(|(_, current)| weight > current) {
228 maximum = Some((index, weight));
229 }
230 }
231 maximum
232 }
233
234 #[must_use]
236 pub fn fixed_request(&self) -> CostUnits {
237 self.fixed_request
238 }
239
240 #[must_use]
242 pub fn minimum_charge(&self) -> CostUnits {
243 self.minimum_charge
244 }
245}
246
247#[derive(Debug, Clone)]
249pub struct CostTableBuilder {
250 fixed_request: CostUnits,
251 minimum_charge: CostUnits,
252 weights: Vec<Option<CostUnits>>,
253 permissions: Vec<PermissionBits>,
254}
255
256impl CostTableBuilder {
257 #[must_use]
261 pub fn weight(self, op: &impl OpIndex, per_item: CostUnits) -> Self {
262 self.class(op, per_item, PermissionBits::NONE)
263 }
264
265 #[must_use]
271 pub fn class(
272 mut self,
273 op: &impl OpIndex,
274 per_item: CostUnits,
275 required: PermissionBits,
276 ) -> Self {
277 let index = op.index();
278 if index >= self.weights.len() {
279 self.weights.resize(index + 1, None);
280 }
281 if index >= self.permissions.len() {
282 self.permissions.resize(index + 1, PermissionBits::NONE);
283 }
284 self.weights[index] = Some(per_item);
285 self.permissions[index] = required;
286 self
287 }
288
289 #[must_use]
292 pub fn build(mut self) -> CostTable {
293 while self.permissions.last() == Some(&PermissionBits::NONE) {
297 self.permissions.pop();
298 }
299 CostTable {
300 fixed_request: self.fixed_request,
301 minimum_charge: self.minimum_charge,
302 weights: self.weights.into_boxed_slice(),
303 permissions: self.permissions.into_boxed_slice(),
304 }
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[derive(Clone, Copy)]
313 enum Op {
314 Price,
315 Greeks,
316 Unpriced,
317 }
318
319 impl OpIndex for Op {
320 fn index(&self) -> usize {
321 *self as usize
322 }
323 }
324
325 fn table() -> CostTable {
326 CostTable::builder(CostUnits(50), CostUnits(50))
327 .weight(&Op::Price, CostUnits(1))
328 .weight(&Op::Greeks, CostUnits(5))
329 .build()
330 }
331
332 proptest::proptest! {
340 #[test]
341 fn quote_agrees_with_the_one_entry_workload(
342 class in 0_usize..3,
343 items in 0_u64..=u64::MAX,
344 ) {
345 let op = [Op::Price, Op::Greeks, Op::Unpriced][class];
346 let table = table();
347 let folded = table
348 .quote_workload(&[(op, items)])
349 .map(|(quote, _, _)| quote);
350 proptest::prop_assert_eq!(table.quote(&op, items), folded);
351 }
352 }
353
354 #[test]
355 fn quote_is_fixed_plus_weighted_items() {
356 let q = table().quote(&Op::Greeks, 10).unwrap();
357 assert_eq!(q.total, CostUnits(100));
358 assert_eq!(q.fixed, CostUnits(50));
359 assert_eq!(q.variable, CostUnits(50));
360 }
361
362 #[test]
363 fn minimum_charge_applies() {
364 let t = CostTable::builder(CostUnits(0), CostUnits(25))
365 .weight(&Op::Price, CostUnits(1))
366 .build();
367 assert_eq!(t.quote(&Op::Price, 3).unwrap().total, CostUnits(25));
368 }
369
370 #[test]
371 fn workload_applies_fixed_once_and_sums_repeated_classes() {
372 let quote = table()
373 .quote_workload(&[(Op::Price, 2), (Op::Greeks, 3), (Op::Price, 4)])
374 .unwrap();
375 assert_eq!(quote.1, 9);
376 assert_eq!(quote.0.fixed, CostUnits(50));
377 assert_eq!(quote.0.variable, CostUnits(21));
378 assert_eq!(quote.0.total, CostUnits(71));
379 }
380
381 #[test]
382 fn empty_or_all_zero_workload_is_refused() {
383 assert_eq!(
384 table().quote_workload::<Op>(&[]),
385 Err(QuoteError::EmptyWorkload)
386 );
387 assert_eq!(
388 table().quote_workload(&[(Op::Price, 0), (Op::Greeks, 0)]),
389 Err(QuoteError::EmptyWorkload)
390 );
391 assert_eq!(table().quote(&Op::Price, 0), Err(QuoteError::EmptyWorkload));
392 }
393
394 #[test]
395 fn workload_checks_the_item_sum_and_variable_sum() {
396 assert_eq!(
397 table().quote_workload(&[(Op::Price, u64::MAX), (Op::Price, 1)]),
398 Err(QuoteError::Overflow)
399 );
400 let overflowing = CostTable::builder(CostUnits::ZERO, CostUnits::ZERO)
401 .weight(&Op::Price, CostUnits(u64::MAX))
402 .weight(&Op::Greeks, CostUnits(1))
403 .build();
404 assert_eq!(
405 overflowing.quote_workload(&[(Op::Price, 1), (Op::Greeks, 1)]),
406 Err(QuoteError::Overflow)
407 );
408 }
409
410 #[test]
411 fn unregistered_operation_denies() {
412 assert_eq!(
413 table().quote(&Op::Unpriced, 1),
414 Err(QuoteError::UnknownOperation { index: 2 })
415 );
416 }
417
418 #[test]
419 fn overflow_denies_instead_of_wrapping() {
420 let t = CostTable::builder(CostUnits(1), CostUnits(0))
421 .weight(&Op::Price, CostUnits(u64::MAX))
422 .build();
423 assert_eq!(t.quote(&Op::Price, 2), Err(QuoteError::Overflow));
424 assert_eq!(t.quote(&Op::Price, u64::MAX), Err(QuoteError::Overflow));
425 }
426
427 #[test]
436 fn the_accessors_report_the_schedule_a_quote_applies() {
437 let table = CostTable::builder(CostUnits(50), CostUnits(80))
438 .weight(&Op::Price, CostUnits(1))
439 .build();
440 assert_eq!(table.fixed_request(), CostUnits(50));
441 assert_eq!(table.minimum_charge(), CostUnits(80));
442
443 let priced = table.quote(&Op::Price, 100).unwrap();
446 assert_eq!(priced.fixed, table.fixed_request());
447 assert_eq!(priced.total, CostUnits(150));
448
449 let floored = table.quote(&Op::Price, 1).unwrap();
453 assert_eq!(floored.total, table.minimum_charge());
454 }
455
456 #[test]
463 fn a_repeated_class_is_summed_not_quoted_twice() {
464 let table = table();
465
466 let (split, split_items, _) = table
467 .quote_workload(&[(Op::Price, 2), (Op::Price, 3)])
468 .unwrap();
469 let (grouped, grouped_items, _) = table.quote_workload(&[(Op::Price, 5)]).unwrap();
470
471 assert_eq!(split, grouped, "a repeated class changed the quote");
472 assert_eq!(split_items, grouped_items);
473 assert_eq!(split.fixed, table.fixed_request());
476 }
477
478 #[test]
480 fn work_permissions_are_the_union_of_the_classes_quoted() {
481 let price = PermissionBits::bit(1);
482 let greeks = PermissionBits::bit(2);
483 let table = CostTable::builder(CostUnits(50), CostUnits(50))
484 .class(&Op::Price, CostUnits(1), price)
485 .class(&Op::Greeks, CostUnits(5), greeks)
486 .build();
487
488 let (_, _, one) = table.quote_workload(&[(Op::Price, 1)]).unwrap();
489 assert_eq!(one, price);
490
491 let (_, _, both) = table
492 .quote_workload(&[(Op::Price, 1), (Op::Greeks, 1)])
493 .unwrap();
494 assert_eq!(both, price.union(greeks));
495
496 let (_, _, skipped) = table
499 .quote_workload(&[(Op::Price, 1), (Op::Greeks, 0)])
500 .unwrap();
501 assert_eq!(skipped, price);
502 }
503
504 #[test]
506 fn weight_registers_a_class_that_requires_nothing() {
507 let table = table();
508 let (_, _, required) = table.quote_workload(&[(Op::Price, 1)]).unwrap();
509 assert_eq!(required, PermissionBits::NONE);
510 }
511
512 #[cfg(feature = "serde")]
513 #[test]
514 fn legacy_cost_table_round_trips_canonically() {
515 let legacy = r#"{"fixed_request":50,"minimum_charge":50,"weights":[1,5]}"#;
517 let decoded: CostTable = serde_json::from_str(legacy).expect("legacy table decodes");
518
519 assert_eq!(decoded, table());
523
524 let reserialized = serde_json::to_string(&decoded).expect("table serializes");
527 assert_eq!(reserialized, legacy);
528 }
529
530 #[cfg(feature = "serde")]
531 #[test]
532 fn an_all_none_permission_array_is_not_serialized() {
533 let built = table();
536 let rendered = serde_json::to_string(&built).expect("table serializes");
537 assert!(
538 !rendered.contains("permissions"),
539 "an all-NONE array was serialized: {rendered}"
540 );
541 }
542
543 #[cfg(feature = "serde")]
544 #[test]
545 fn a_table_with_permissions_round_trips() {
546 let table = CostTable::builder(CostUnits(50), CostUnits(50))
547 .class(&Op::Price, CostUnits(1), PermissionBits::bit(1))
548 .class(&Op::Greeks, CostUnits(5), PermissionBits::bit(2))
549 .build();
550 let rendered = serde_json::to_string(&table).expect("table serializes");
551 let decoded: CostTable = serde_json::from_str(&rendered).expect("table decodes");
552 assert_eq!(decoded, table);
553 }
554}