Skip to main content

tollgate_core/
cost_table.rs

1//! Direct index-addressed cost table, compiled once at startup.
2//!
3//! Consumers define an operation enum, implement [`OpIndex`] for it, and build
4//! a [`CostTable`] from their pricing schedule during service startup. From
5//! then on a quote is two array loads and checked integer arithmetic — no
6//! search, no strings, no hashing.
7
8use core::fmt;
9
10use crate::{snapshot::PermissionBits, units::CostUnits};
11
12/// Maps a consumer operation onto a dense table index.
13///
14/// Implementations must be a pure function of `self` and return stable, small
15/// indices (typically `enum as usize`). The table is sized to the largest
16/// index registered at build time; quoting an unregistered index denies.
17pub trait OpIndex {
18    /// This operation's index into the cost table. Must be stable for the
19    /// life of the process and small, because the table is a dense array.
20    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/// One quoted request: the committed price if execution starts.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub struct CostQuote {
34    /// Total units to reserve: `max(fixed + per_item * items, minimum)`.
35    pub total: CostUnits,
36    /// The fixed component actually applied (charged once per request).
37    pub fixed: CostUnits,
38    /// The variable component (`per_item * items`).
39    pub variable: CostUnits,
40}
41
42/// Why a quote was refused. Refusals happen before any reservation, so they
43/// always charge zero.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub enum QuoteError {
47    /// The caller supplied no nonzero work.
48    EmptyWorkload,
49    /// The operation's index was never registered in this table.
50    UnknownOperation {
51        /// The unregistered index the workload named.
52        index: usize,
53    },
54    /// `fixed + per_item * items` exceeded `u64` (INVARIANTS.md GL-11).
55    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/// Immutable, densely indexed pricing schedule.
71///
72/// `weights[op.index()]` is the per-item cost; `None` marks an index inside
73/// the table's bounds that was never registered. The struct is built once and
74/// shared (`Arc<CostTable>`) for the life of a policy generation.
75#[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    /// Work permissions, parallel to `weights` and index-addressed the same way.
82    ///
83    /// Held in canonical form: trailing `NONE` entries are trimmed at build
84    /// time and an entirely-`NONE` array is empty. A missing index therefore
85    /// means `PermissionBits::NONE`, which is what makes a table decoded from
86    /// JSON written before this field existed compare equal to the same table
87    /// built today — a stored snapshot must not start denying work because the
88    /// binary that reads it learned a new field.
89    #[cfg_attr(
90        feature = "serde",
91        serde(default, skip_serializing_if = "<[PermissionBits]>::is_empty")
92    )]
93    permissions: Box<[PermissionBits]>,
94}
95
96impl CostTable {
97    /// Start a table. `fixed_request` is charged once per request whatever
98    /// its workload; `minimum_charge` is the floor a quote never goes below.
99    /// Register each operation with [`CostTableBuilder::weight`] or
100    /// [`CostTableBuilder::class`], then [`CostTableBuilder::build`].
101    #[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    /// Quote `items` items of `op` as the homogeneous one-entry workload.
112    ///
113    /// Answers the one-class case by direct index, which is what the module
114    /// header promises: two array loads and checked integer arithmetic.
115    ///
116    /// GL-91 replaced that with a delegation to [`CostTable::quote_workload`],
117    /// and `cost_table/quote` went 1.70 → 2.25 ns (+32%). Nothing caught it:
118    /// the row's recorded baseline had been taken one commit earlier in the
119    /// same issue and was never re-recorded, so the gate compared the new code
120    /// against a number the new code no longer described (GL-114). Embedders
121    /// pricing a single class call this; the admission engine folds its own
122    /// workload and needs the permission bits only the fold returns, so it
123    /// keeps [`CostTable::quote_workload`] and is unaffected either way.
124    ///
125    /// The arithmetic stays shared — `CostTable::weight_at` and
126    /// `CostTable::quote_weight` are the same two steps the fold takes — and
127    /// `quote_agrees_with_the_one_entry_workload` holds the two paths to the
128    /// same answer rather than trusting that argument.
129    #[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    /// Quote caller-owned class aggregates, applying the fixed and minimum
138    /// terms exactly once. Pure, allocation-free, and O(entries).
139    #[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    /// The per-item weight of a class, or `UnknownOperation` for a class the
177    /// table does not price. Shared so the one-class path and the workload
178    /// fold cannot disagree about which indices exist.
179    #[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    /// Work permissions for a class, or `NONE` past the canonical array's end.
188    #[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    /// Quote one already-resolved weight. Keeping the arithmetic here makes
197    /// publication-time validation and request-time quoting share the exact
198    /// checked formula rather than maintaining parallel implementations.
199    #[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    /// The largest registered per-item weight and its operation index.
218    ///
219    /// This O(n) scan is used only while validating a compiled snapshot for
220    /// publication. Request-time lookup remains direct-indexed and O(1).
221    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    /// The units charged once per request, whatever its workload.
235    #[must_use]
236    pub fn fixed_request(&self) -> CostUnits {
237        self.fixed_request
238    }
239
240    /// The floor no quote goes below.
241    #[must_use]
242    pub fn minimum_charge(&self) -> CostUnits {
243        self.minimum_charge
244    }
245}
246
247/// Startup-time builder; the only path to a [`CostTable`].
248#[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    /// Register the per-item weight for one operation. Registering the same
258    /// index twice keeps the last value; that is a configuration authoring
259    /// concern, not a runtime one.
260    #[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    /// Register a class: what it costs per item and what it requires.
266    ///
267    /// One growth path with [`Self::weight`], which is this call with no
268    /// requirement. Registering the same index twice keeps the last pair; that
269    /// is a configuration authoring concern, not a runtime one.
270    #[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    /// Finish the table. It is immutable from here on and is shared by every
290    /// snapshot compiled from the same schedule.
291    #[must_use]
292    pub fn build(mut self) -> CostTable {
293        // Canonical form: trailing `NONE` carries no information, and leaving
294        // it in would make a table built with `.weight` unequal to the same
295        // table decoded from JSON that predates the field.
296        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    // The one-class path and the workload fold answer identically, including
333    // which error they answer with.
334    //
335    // `quote` takes the direct index rather than folding a one-element
336    // workload, because delegating cost it 32% (GL-114). That is only safe while
337    // the two cannot diverge, and "they share `weight_at` and `quote_weight`"
338    // is an argument, not a check — this is the check.
339    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    /// The two accessors are how an embedder reads a compiled schedule back —
428    /// to display pricing, or to reconcile a charge — and nothing asserted
429    /// they report the schedule that is actually applied. Both mutated to
430    /// `CostUnits(0)` without a single failure (GL-43).
431    ///
432    /// So this asserts agreement rather than the stored values alone: what
433    /// `fixed_request()` reports is the fixed component a quote charges, and
434    /// what `minimum_charge()` reports is the floor a quote is raised to.
435    #[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        // Above the floor: the quote's fixed component is exactly what
444        // `fixed_request()` advertises.
445        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        // Below the floor: the total is exactly what `minimum_charge()`
450        // advertises, so an embedder quoting from the accessor and the engine
451        // charging from the table cannot disagree.
452        let floored = table.quote(&Op::Price, 1).unwrap();
453        assert_eq!(floored.total, table.minimum_charge());
454    }
455
456    /// A repeated class is summed, and the fixed term still applies once.
457    ///
458    /// The alternative — rejecting a duplicate — was available and not taken.
459    /// Summing means a caller that groups its own workload and a caller that
460    /// does not are charged identically, so grouping stays an optimisation
461    /// rather than a correctness obligation the caller can get wrong.
462    #[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        // The load-bearing half: the fixed term is a property of the request,
474        // so it cannot arrive once per entry.
475        assert_eq!(split.fixed, table.fixed_request());
476    }
477
478    /// The fold reports the union of what its classes require.
479    #[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        // A zero count is not work, so it cannot contribute a requirement —
497        // otherwise a caller could be denied for a class it did not ask for.
498        let (_, _, skipped) = table
499            .quote_workload(&[(Op::Price, 1), (Op::Greeks, 0)])
500            .unwrap();
501        assert_eq!(skipped, price);
502    }
503
504    /// A class registered without a requirement requires nothing.
505    #[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        // A table stored before work permissions existed has no such field.
516        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        // It must equal the same table built today, or a control plane that has
520        // not been redeployed would start publishing tables that compare
521        // unequal to what instances already hold.
522        assert_eq!(decoded, table());
523
524        // And it must serialize back without inventing the field, so a
525        // round trip through a newer binary does not rewrite stored bytes.
526        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        // `.weight` sets NONE, so a table built entirely from it must be
534        // byte-identical to the legacy form; trailing NONE is trimmed at build.
535        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}