Skip to main content

nmbrs_runtime/
opseq.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Op sequencing: maps cycles to op templates via a ratio-based LUT.
5//!
6//! Three sequencing strategies arrange ops within a stanza (one
7//! complete rotation through the sequence):
8//! - **Bucket** (default): interleaved round-robin from ratio buckets
9//! - **Interval**: evenly spaced by frequency across the stanza
10//! - **Concat**: all of first op, then all of second, etc.
11
12use nmbrs_workload::model::ParsedOp;
13
14/// Sequencing strategy for arranging ops within a stanza.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum SequencerType {
17    /// Round-robin from ratio-sized buckets. Default.
18    Bucket,
19    /// Evenly spaced across the stanza by frequency.
20    Interval,
21    /// All of first, then all of second, etc.
22    Concat,
23}
24
25impl SequencerType {
26    /// Parse from string parameter value.
27    pub fn parse(s: &str) -> Result<Self, String> {
28        match s.to_lowercase().as_str() {
29            "bucket" => Ok(Self::Bucket),
30            "interval" => Ok(Self::Interval),
31            "concat" => Ok(Self::Concat),
32            other => Err(format!(
33                "unknown sequencer type: '{other}', use bucket|interval|concat"
34            )),
35        }
36    }
37}
38
39/// Maps cycle numbers to op templates using a pre-computed LUT.
40///
41/// The LUT length equals the sum of all ratios (the "stanza length").
42/// `cycle % stanza_length` indexes into the LUT to select an op.
43pub struct OpSequence {
44    ops: Vec<ParsedOp>,
45    lut: Vec<usize>,
46    sequencer_type: SequencerType,
47}
48
49impl OpSequence {
50    /// Build a sequence from ops with explicit ratios and strategy.
51    pub fn build(ops: Vec<ParsedOp>, ratios: &[u64], strategy: SequencerType) -> Self {
52        assert_eq!(
53            ops.len(),
54            ratios.len(),
55            "ops and ratios must have equal length"
56        );
57        assert!(!ops.is_empty(), "must have at least one op");
58
59        let lut = match strategy {
60            SequencerType::Bucket => build_bucket_lut(ratios),
61            SequencerType::Interval => build_interval_lut(ratios),
62            SequencerType::Concat => build_concat_lut(ratios),
63        };
64
65        Self {
66            ops,
67            lut,
68            sequencer_type: strategy,
69        }
70    }
71
72    /// Build with uniform ratios (1 each) using bucket sequencing.
73    pub fn uniform(ops: Vec<ParsedOp>) -> Self {
74        let ratios = vec![1u64; ops.len()];
75        Self::build(ops, &ratios, SequencerType::Bucket)
76    }
77
78    /// Build from ops, extracting ratios from the `ratio` param
79    /// on each op template (defaults to 1).
80    /// Build from ops, extracting ratios from the `ratio` field.
81    ///
82    /// Checks `params["ratio"]` first (explicit params), then
83    /// `op["ratio"]` (inline with op fields). Defaults to 1.
84    pub fn from_ops(ops: Vec<ParsedOp>, strategy: SequencerType) -> Self {
85        let ratios: Vec<u64> = ops
86            .iter()
87            .map(|op| {
88                op.params
89                    .get("ratio")
90                    .and_then(|v| v.as_u64())
91                    .or_else(|| op.op.get("ratio").and_then(|v| v.as_u64()))
92                    .unwrap_or(1)
93            })
94            .collect();
95        Self::build(ops, &ratios, strategy)
96    }
97
98    /// Get the op template for a given cycle.
99    #[inline]
100    pub fn get(&self, cycle: u64) -> &ParsedOp {
101        let idx = self.lut[(cycle as usize) % self.lut.len()];
102        &self.ops[idx]
103    }
104
105    /// Get the op template and its index for a given cycle.
106    #[inline]
107    pub fn get_with_index(&self, cycle: u64) -> (usize, &ParsedOp) {
108        let idx = self.lut[(cycle as usize) % self.lut.len()];
109        (idx, &self.ops[idx])
110    }
111
112    /// All unique op templates in declaration order.
113    pub fn templates(&self) -> &[ParsedOp] {
114        &self.ops
115    }
116
117    /// Number of distinct op templates.
118    pub fn op_count(&self) -> usize {
119        self.ops.len()
120    }
121
122    /// Stanza length (sum of all ratios = LUT length).
123    pub fn stanza_length(&self) -> usize {
124        self.lut.len()
125    }
126
127    /// Get all ops in a stanza starting at the given cycle.
128    ///
129    /// Returns (op_template, cycle_within_stanza) pairs for each
130    /// position in the stanza. Used by the capture-aware executor
131    /// to process a full stanza as a unit.
132    pub fn stanza_ops(&self, base_cycle: u64) -> Vec<(&ParsedOp, u64)> {
133        (0..self.lut.len())
134            .map(|offset| {
135                let cycle = base_cycle + offset as u64;
136                (self.get(cycle), cycle)
137            })
138            .collect()
139    }
140
141    /// The sequencing strategy used.
142    pub fn sequencer_type(&self) -> SequencerType {
143        self.sequencer_type
144    }
145
146    /// The raw LUT (for inspection/testing).
147    pub fn lut(&self) -> &[usize] {
148        &self.lut
149    }
150}
151
152// =================================================================
153// Sequencer implementations
154// =================================================================
155
156/// Bucket: round-robin from ratio-sized buckets.
157///
158/// Each op gets a "bucket" with `ratio` tokens. We cycle through
159/// all non-empty buckets, drawing one token per pass, until all
160/// buckets are empty.
161///
162/// Example: A:3, B:2, C:1
163///   Pass 1: A, B, C  (all have tokens)
164///   Pass 2: A, B     (C exhausted)
165///   Pass 3: A        (B exhausted)
166///   LUT: [0, 1, 2, 0, 1, 0]
167fn build_bucket_lut(ratios: &[u64]) -> Vec<usize> {
168    let total: u64 = ratios.iter().sum();
169    let mut lut = Vec::with_capacity(total as usize);
170    let mut remaining: Vec<u64> = ratios.to_vec();
171
172    loop {
173        let mut any_drawn = false;
174        for (i, rem) in remaining.iter_mut().enumerate() {
175            if *rem > 0 {
176                lut.push(i);
177                *rem -= 1;
178                any_drawn = true;
179            }
180        }
181        if !any_drawn {
182            break;
183        }
184    }
185
186    lut
187}
188
189/// Interval: evenly spaced across the stanza.
190///
191/// Each op is placed at positions determined by its frequency
192/// (ratio / total). Ops with higher ratios appear more frequently
193/// and are more evenly distributed.
194///
195/// Example: A:4, B:2
196///   Total = 6. A at every 1.5, B at every 3.
197///   Positions sorted: A(0), B(0), A(1.5), A(3.0), B(3.0), A(4.5)
198///   LUT: [0, 1, 0, 0, 1, 0]
199fn build_interval_lut(ratios: &[u64]) -> Vec<usize> {
200    let total: u64 = ratios.iter().sum();
201    if total == 0 {
202        return Vec::new();
203    }
204
205    // Generate (position, op_index) pairs
206    let mut entries: Vec<(f64, usize, usize)> = Vec::new(); // (pos, op_idx, instance)
207    for (i, &ratio) in ratios.iter().enumerate() {
208        if ratio == 0 {
209            continue;
210        }
211        let spacing = total as f64 / ratio as f64;
212        for j in 0..ratio as usize {
213            entries.push((j as f64 * spacing, i, j));
214        }
215    }
216
217    // Sort by position, then by op index for stability
218    entries.sort_by(|a, b| {
219        a.0.partial_cmp(&b.0)
220            .unwrap_or(std::cmp::Ordering::Equal)
221            .then_with(|| a.1.cmp(&b.1))
222    });
223
224    entries.iter().map(|&(_, idx, _)| idx).collect()
225}
226
227/// Concat: all of first op, then all of second, etc.
228///
229/// Example: A:3, B:2, C:1
230///   LUT: [0, 0, 0, 1, 1, 2]
231fn build_concat_lut(ratios: &[u64]) -> Vec<usize> {
232    let mut lut = Vec::new();
233    for (i, &ratio) in ratios.iter().enumerate() {
234        for _ in 0..ratio {
235            lut.push(i);
236        }
237    }
238    lut
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn names(seq: &OpSequence, count: usize) -> Vec<String> {
246        (0..count as u64).map(|c| seq.get(c).name.clone()).collect()
247    }
248
249    // --- Bucket tests ---
250
251    #[test]
252    fn bucket_uniform() {
253        let ops = vec![
254            ParsedOp::simple("A", "a"),
255            ParsedOp::simple("B", "b"),
256            ParsedOp::simple("C", "c"),
257        ];
258        let seq = OpSequence::build(ops, &[1, 1, 1], SequencerType::Bucket);
259        assert_eq!(seq.stanza_length(), 3);
260        assert_eq!(names(&seq, 6), vec!["A", "B", "C", "A", "B", "C"]);
261    }
262
263    #[test]
264    fn bucket_weighted() {
265        let ops = vec![
266            ParsedOp::simple("R", "read"),
267            ParsedOp::simple("W", "write"),
268            ParsedOp::simple("D", "delete"),
269        ];
270        let seq = OpSequence::build(ops, &[3, 2, 1], SequencerType::Bucket);
271        assert_eq!(seq.stanza_length(), 6);
272        let n = names(&seq, 6);
273        assert_eq!(n.iter().filter(|s| *s == "R").count(), 3);
274        assert_eq!(n.iter().filter(|s| *s == "W").count(), 2);
275        assert_eq!(n.iter().filter(|s| *s == "D").count(), 1);
276    }
277
278    #[test]
279    fn bucket_interleaves() {
280        let ops = vec![ParsedOp::simple("A", "a"), ParsedOp::simple("B", "b")];
281        let seq = OpSequence::build(ops, &[3, 1], SequencerType::Bucket);
282        // Bucket draws: Pass1: A,B  Pass2: A  Pass3: A
283        assert_eq!(seq.lut(), &[0, 1, 0, 0]);
284    }
285
286    // --- Interval tests ---
287
288    #[test]
289    fn interval_uniform() {
290        let ops = vec![ParsedOp::simple("A", "a"), ParsedOp::simple("B", "b")];
291        let seq = OpSequence::build(ops, &[1, 1], SequencerType::Interval);
292        assert_eq!(seq.stanza_length(), 2);
293        assert_eq!(names(&seq, 4), vec!["A", "B", "A", "B"]);
294    }
295
296    #[test]
297    fn interval_weighted() {
298        let ops = vec![ParsedOp::simple("A", "a"), ParsedOp::simple("B", "b")];
299        let seq = OpSequence::build(ops, &[4, 2], SequencerType::Interval);
300        assert_eq!(seq.stanza_length(), 6);
301        let n = names(&seq, 6);
302        assert_eq!(n.iter().filter(|s| *s == "A").count(), 4);
303        assert_eq!(n.iter().filter(|s| *s == "B").count(), 2);
304    }
305
306    #[test]
307    fn interval_distributes_evenly() {
308        let ops = vec![ParsedOp::simple("A", "a"), ParsedOp::simple("B", "b")];
309        let seq = OpSequence::build(ops, &[3, 3], SequencerType::Interval);
310        // Both at spacing 2: A at 0,2,4; B at 0,2,4
311        // Sorted by pos then index: A(0), B(0), A(2), B(2), A(4), B(4)
312        assert_eq!(seq.stanza_length(), 6);
313        assert_eq!(names(&seq, 6), vec!["A", "B", "A", "B", "A", "B"]);
314    }
315
316    // --- Concat tests ---
317
318    #[test]
319    fn concat_sequential() {
320        let ops = vec![
321            ParsedOp::simple("A", "a"),
322            ParsedOp::simple("B", "b"),
323            ParsedOp::simple("C", "c"),
324        ];
325        let seq = OpSequence::build(ops, &[3, 2, 1], SequencerType::Concat);
326        assert_eq!(seq.stanza_length(), 6);
327        assert_eq!(names(&seq, 6), vec!["A", "A", "A", "B", "B", "C"]);
328    }
329
330    #[test]
331    fn concat_wraps() {
332        let ops = vec![ParsedOp::simple("X", "x"), ParsedOp::simple("Y", "y")];
333        let seq = OpSequence::build(ops, &[2, 1], SequencerType::Concat);
334        assert_eq!(names(&seq, 6), vec!["X", "X", "Y", "X", "X", "Y"]);
335    }
336
337    // --- from_ops tests ---
338
339    #[test]
340    fn from_ops_extracts_ratios() {
341        let mut op1 = ParsedOp::simple("read", "SELECT");
342        op1.params.insert("ratio".into(), serde_json::json!(5));
343        let op2 = ParsedOp::simple("write", "INSERT"); // default ratio 1
344
345        let seq = OpSequence::from_ops(vec![op1, op2], SequencerType::Bucket);
346        assert_eq!(seq.stanza_length(), 6); // 5 + 1
347    }
348
349    // --- General tests ---
350
351    #[test]
352    fn sequencer_type_parse() {
353        assert_eq!(
354            SequencerType::parse("bucket").unwrap(),
355            SequencerType::Bucket
356        );
357        assert_eq!(
358            SequencerType::parse("INTERVAL").unwrap(),
359            SequencerType::Interval
360        );
361        assert_eq!(
362            SequencerType::parse("Concat").unwrap(),
363            SequencerType::Concat
364        );
365        assert!(SequencerType::parse("bogus").is_err());
366    }
367
368    #[test]
369    fn single_op_any_strategy() {
370        for strategy in [
371            SequencerType::Bucket,
372            SequencerType::Interval,
373            SequencerType::Concat,
374        ] {
375            let ops = vec![ParsedOp::simple("only", "SELECT 1")];
376            let seq = OpSequence::build(ops, &[1], strategy);
377            assert_eq!(seq.get(0).name, "only");
378            assert_eq!(seq.get(999).name, "only");
379        }
380    }
381
382    #[test]
383    fn stanza_repeats_cleanly() {
384        let ops = vec![ParsedOp::simple("A", "a"), ParsedOp::simple("B", "b")];
385        let seq = OpSequence::build(ops, &[2, 1], SequencerType::Bucket);
386        let stanza = seq.stanza_length();
387        // Two full stanzas should produce the same pattern
388        let first: Vec<String> = (0..stanza as u64)
389            .map(|c| seq.get(c).name.clone())
390            .collect();
391        let second: Vec<String> = (stanza as u64..2 * stanza as u64)
392            .map(|c| seq.get(c).name.clone())
393            .collect();
394        assert_eq!(first, second);
395    }
396}