Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-sim/src/checker.rs
Line
Count
Source
1
// Copyright 2026 Trace Machina, Inc. All rights reserved.
2
//
3
// Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//    See LICENSE file for details
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14
15
//! The invariants, checked over a finished trace. Each is a pure function
16
//! of the records and the facts of the run, so a test can hand one a trace
17
//! it altered and see it fire.
18
19
use core::fmt;
20
use std::collections::{BTreeSet, HashMap, HashSet};
21
22
use crate::trace::{Event, Record, Stage};
23
24
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
25
pub enum Invariant {
26
    /// No result lost: a result a worker reported reaches every client still
27
    /// attached to the action.
28
    I1,
29
    /// At most one dispatch of an action at a time: a second needs the first
30
    /// declined, finished, killed or its worker gone in between.
31
    I2,
32
    /// Every dispatch is answered (accepted or declined, when the worker
33
    /// acknowledges), and an accepted one ends in exactly one result, a kill
34
    /// or the worker's loss.
35
    I3,
36
    /// What the scheduler has executing equals what the workers run, at
37
    /// quiescence.
38
    I4,
39
    /// Order: a dispatch never passes over a waiting action of higher
40
    /// priority, or of equal priority submitted earlier.
41
    I5,
42
    /// Liveness bounds: abandoned actions stop being dispatched, and a
43
    /// silent worker is evicted, within their bounds.
44
    I6,
45
    /// Retry budgets: an action is executed no more often than its budgets
46
    /// allow.
47
    I7,
48
    /// No stall: the final settle reaches quiescence inside its budget.
49
    I8,
50
    /// A worker never runs more than its `max_inflight_tasks`.
51
    I9,
52
    /// A client sees stages in order; the only back-edge is Executing to
53
    /// Queued.
54
    I10,
55
    /// Store commands between two matching passes stay inside the declared
56
    /// bound.
57
    I11,
58
}
59
60
impl fmt::Display for Invariant {
61
1
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62
1
        fmt::Debug::fmt(self, f)
63
1
    }
64
}
65
66
#[derive(Clone, Debug, PartialEq, Eq)]
67
pub struct Violation {
68
    pub invariant: Invariant,
69
    pub at_ms: u64,
70
    pub detail: String,
71
}
72
73
impl fmt::Display for Violation {
74
0
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75
0
        write!(
76
0
            f,
77
            "{} at {} ms: {}",
78
            self.invariant, self.at_ms, self.detail
79
        )
80
0
    }
81
}
82
83
/// What the checker needs to know about a run beyond its trace.
84
#[derive(Clone, Debug, Default)]
85
pub struct Facts {
86
    /// `max_inflight_tasks` by worker name.
87
    pub max_inflight: HashMap<String, u64>,
88
    /// Whether each action label is cacheable.
89
    pub cacheable: HashMap<String, bool>,
90
    /// The most executions one action may get; 0 does not check.
91
    pub dispatch_budget: u64,
92
    /// After every client of an action left, how long it may still be
93
    /// dispatched.
94
    pub abandon_bound_ms: u64,
95
    /// How long a silent worker may stay before it is evicted.
96
    pub evict_bound_ms: u64,
97
    /// Silences: worker, start and end.
98
    pub silences: Vec<(String, u64, u64)>,
99
    /// Check I5.
100
    pub ordered: bool,
101
    /// The bound I11 checks, and what the run measured.
102
    pub command_bound: Option<u64>,
103
    pub max_commands_per_pass: u64,
104
}
105
106
/// Every violation in `records`, in invariant order.
107
194
pub fn check(records: &[Record], facts: &Facts) -> Vec<Violation> {
108
194
    let quiet = records
109
194
        .iter()
110
194
        .rev()
111
582
        .
find_map194
(|r| match r.event {
112
194
            Event::Settled { quiet } => Some(quiet),
113
388
            _ => None,
114
582
        })
115
194
        .unwrap_or(false);
116
194
    let end_ms = records.last().map_or(0, |r| r.at_ms);
117
194
    let mut out = Vec::new();
118
194
    check_i1(records, &mut out);
119
194
    check_i2(records, facts, &mut out);
120
194
    check_i3(records, quiet, &mut out);
121
194
    check_i4(records, quiet, &mut out);
122
194
    if facts.ordered {
123
4
        check_i5(records, &mut out);
124
190
    }
125
194
    check_i6(records, facts, end_ms, &mut out);
126
194
    check_i7(records, facts, &mut out);
127
194
    check_i8(records, quiet, end_ms, &mut out);
128
194
    check_i9(records, facts, &mut out);
129
194
    check_i10(records, &mut out);
130
194
    if let Some(
bound4
) = facts.command_bound
131
4
        && facts.max_commands_per_pass > bound
132
2
    {
133
2
        out.push(Violation {
134
2
            invariant: Invariant::I11,
135
2
            at_ms: end_ms,
136
2
            detail: format!(
137
2
                "{} store commands between two passes, bound {bound}",
138
2
                facts.max_commands_per_pass
139
2
            ),
140
2
        });
141
192
    }
142
194
    out.sort_by_key(|v| (
v.invariant44
,
v.at_ms44
));
143
194
    out
144
194
}
145
146
/// The invariants `violations` name, once each.
147
190
pub fn verdict(violations: &[Violation]) -> BTreeSet<Invariant> {
148
190
    violations.iter().map(|v| v.invariant).collect()
149
190
}
150
151
struct Ops {
152
    /// op to action.
153
    action: HashMap<String, String>,
154
    /// op to submit time.
155
    submitted: HashMap<String, u64>,
156
    gone: HashMap<String, u64>,
157
    done: HashSet<String>,
158
}
159
160
399
fn ops(records: &[Record]) -> Ops {
161
399
    let mut ops = Ops {
162
399
        action: HashMap::new(),
163
399
        submitted: HashMap::new(),
164
399
        gone: HashMap::new(),
165
399
        done: HashSet::new(),
166
399
    };
167
278k
    for r in 
records399
{
168
278k
        match &r.event {
169
34.3k
            Event::Submit { op, action, .. } => {
170
34.3k
                ops.action.insert(op.clone(), action.clone());
171
34.3k
                ops.submitted.entry(op.clone()).or_insert(r.at_ms);
172
34.3k
            }
173
8.02k
            Event::ClientGone { op } => {
174
8.02k
                ops.gone.entry(op.clone()).or_insert(r.at_ms);
175
8.02k
            }
176
20.3k
            Event::ClientDone { op, .. } => {
177
20.3k
                ops.done.insert(op.clone());
178
20.3k
            }
179
216k
            _ => {}
180
        }
181
    }
182
399
    ops
183
399
}
184
185
194
fn check_i1(records: &[Record], out: &mut Vec<Violation>) {
186
194
    let ops = ops(records);
187
194
    let mut first_result: HashMap<&str, u64> = HashMap::new();
188
135k
    for r in 
records194
{
189
        if let Event::Result {
190
10.1k
            action, ok: true, ..
191
135k
        } = &r.event
192
10.1k
        {
193
10.1k
            first_result.entry(action.as_str()).or_insert(r.at_ms);
194
125k
        }
195
    }
196
194
    let mut lost: Vec<_> = ops
197
194
        .action
198
194
        .iter()
199
16.0k
        .
filter194
(|(op, action)| {
200
16.0k
            first_result
201
16.0k
                .get(action.as_str())
202
16.0k
                .is_some_and(|at| 
ops.submitted10.1k
.
get10.1k
(
*op10.1k
).
is_some_and10.1k
(|s|
s10.1k
<=
at10.1k
))
203
10.1k
                && !ops.gone.contains_key(*op)
204
10.0k
                && !ops.done.contains(*op)
205
16.0k
        })
206
194
        .map(|(op, action)| (
first_result10
[action.as_str()],
op10
.
clone10
(),
action10
.
clone10
()))
207
194
        .collect();
208
194
    lost.sort();
209
194
    for (
at_ms10
,
op10
,
action10
) in lost {
210
10
        out.push(Violation {
211
10
            invariant: Invariant::I1,
212
10
            at_ms,
213
10
            detail: format!(
214
10
                "a worker reported {action}'s result but attached client {op} never got an answer"
215
10
            ),
216
10
        });
217
10
    }
218
194
}
219
220
21.5k
fn dispatch_key(facts: &Facts, action: &str, key: &str) -> String {
221
21.5k
    if facts.cacheable.get(action).copied().unwrap_or(true) {
222
20.7k
        action.to_string()
223
    } else {
224
788
        key.to_string()
225
    }
226
21.5k
}
227
228
194
fn check_i2(records: &[Record], facts: &Facts, out: &mut Vec<Violation>) {
229
    // Dispatch key to the worker holding it.
230
194
    let mut holder: HashMap<String, String> = HashMap::new();
231
135k
    for r in 
records194
{
232
135k
        match &r.event {
233
            Event::Dispatch {
234
10.7k
                worker,
235
10.7k
                action,
236
10.7k
                key,
237
            } => {
238
10.7k
                let dkey = dispatch_key(facts, action, key);
239
10.7k
                if let Some(
first2
) = holder.get(&dkey) {
240
2
                    out.push(Violation {
241
2
                        invariant: Invariant::I2,
242
2
                        at_ms: r.at_ms,
243
2
                        detail: format!(
244
2
                            "{action} dispatched to {worker} while {first} still held it"
245
2
                        ),
246
2
                    });
247
10.7k
                }
248
10.7k
                holder.insert(dkey, worker.clone());
249
            }
250
0
            Event::Decline { worker, action }
251
10.3k
            | Event::Result { worker, action, .. }
252
2
            | Event::Kill { worker, action } => {
253
603k
                
holder10.3k
.
retain10.3k
(|k, w| !(w == worker &&
key_is_action68.5k
(
facts68.5k
,
k68.5k
,
action68.5k
,
records68.5k
)));
254
            }
255
890
            Event::WorkerDisconnected { worker } | Event::WorkerStopped { 
worker77
} => {
256
21.1k
                
holder967
.
retain967
(|_, w| w != worker);
257
            }
258
113k
            _ => {}
259
        }
260
    }
261
194
}
262
263
194
fn check_i7(records: &[Record], facts: &Facts, out: &mut Vec<Violation>) {
264
194
    if facts.dispatch_budget == 0 {
265
0
        return;
266
194
    }
267
194
    let mut executions: HashMap<String, u64> = HashMap::new();
268
135k
    for r in 
records194
{
269
135k
        if let Event::ExecStart { 
action10.7k
,
key10.7k
, .. } = &r.event {
270
10.7k
            let count = executions
271
10.7k
                .entry(dispatch_key(facts, action, key))
272
10.7k
                .or_default();
273
10.7k
            *count += 1;
274
10.7k
            if *count == facts.dispatch_budget + 1 {
275
4
                out.push(Violation {
276
4
                    invariant: Invariant::I7,
277
4
                    at_ms: r.at_ms,
278
4
                    detail: format!(
279
4
                        "{action} executed {count} times, budget {}",
280
4
                        facts.dispatch_budget
281
4
                    ),
282
4
                });
283
10.7k
            }
284
125k
        }
285
    }
286
194
}
287
288
/// Whether dispatch key `key` belongs to `action`.
289
68.5k
fn key_is_action(facts: &Facts, key: &str, action: &str, records: &[Record]) -> bool {
290
68.5k
    if facts.cacheable.get(action).copied().unwrap_or(true) {
291
67.9k
        return key == action;
292
520
    }
293
109k
    
records.iter()520
.
any520
(|r| {
294
10.6k
        
matches!384
(&r.event, Event::Dispatch { action: a, key: k, .. } if a == action &&
k558
== ke
y384
)
295
109k
    })
296
68.5k
}
297
298
#[derive(Clone, Copy, PartialEq, Eq)]
299
enum DispatchState {
300
    Sent,
301
    Accepted,
302
    /// Ended by a kill; the worker may still report the killed attempt.
303
    Killed,
304
}
305
306
194
fn check_i3(records: &[Record], quiet: bool, out: &mut Vec<Violation>) {
307
194
    let mut open: HashMap<(&str, &str), (DispatchState, u64)> = HashMap::new();
308
135k
    for r in 
records194
{
309
135k
        match &r.event {
310
10.7k
            Event::Dispatch { worker, action, .. } => {
311
10.7k
                open.insert((worker, action), (DispatchState::Sent, r.at_ms));
312
10.7k
            }
313
10.7k
            Event::Accept { worker, action } => {
314
10.7k
                match open.get_mut(&(worker.as_str(), action.as_str())) {
315
10.7k
                    Some((state @ DispatchState::Sent, _)) => *state = DispatchState::Accepted,
316
0
                    _ => out.push(Violation {
317
0
                        invariant: Invariant::I3,
318
0
                        at_ms: r.at_ms,
319
0
                        detail: format!("{worker} accepted {action}, which it was not sent"),
320
0
                    }),
321
                }
322
            }
323
0
            Event::Decline { worker, action } => {
324
0
                if open.remove(&(worker.as_str(), action.as_str())).is_none() {
325
0
                    out.push(Violation {
326
0
                        invariant: Invariant::I3,
327
0
                        at_ms: r.at_ms,
328
0
                        detail: format!("{worker} declined {action}, which it was not sent"),
329
0
                    });
330
0
                }
331
            }
332
10.3k
            Event::Result { worker, action, .. } => {
333
                // A result answers the dispatch too: an accept the network
334
                // lost is the ack timeout's business, not this one's.
335
10.3k
                match open.remove(&(worker.as_str(), action.as_str())) {
336
10.3k
                    Some(_) => {}
337
0
                    None => out.push(Violation {
338
0
                        invariant: Invariant::I3,
339
0
                        at_ms: r.at_ms,
340
0
                        detail: format!(
341
0
                            "{worker} reported a result for {action} with no open dispatch"
342
0
                        ),
343
0
                    }),
344
                }
345
            }
346
2
            Event::Kill { worker, action } => {
347
2
                if let Some((
state0
, _)) = open.get_mut(&(worker.as_str(), action.as_str())) {
348
0
                    *state = DispatchState::Killed;
349
2
                }
350
            }
351
890
            Event::WorkerDisconnected { worker } | Event::WorkerStopped { 
worker77
} => {
352
21.1k
                
open967
.
retain967
(|(w, _), _| w != worker);
353
            }
354
102k
            _ => {}
355
        }
356
    }
357
194
    if quiet {
358
187
        let mut left: Vec<_> = open
359
187
            .into_iter()
360
187
            .filter(|(_, (state, _))| 
*state1
!=
DispatchState::Killed1
)
361
187
            .collect();
362
187
        left.sort_by_key(|((w, a), (_, at))| (
*at0
,
*w0
,
*a0
));
363
187
        for ((
worker1
,
action1
), (_,
at_ms1
)) in left {
364
1
            out.push(Violation {
365
1
                invariant: Invariant::I3,
366
1
                at_ms,
367
1
                detail: format!("dispatch of {action} to {worker} never ended"),
368
1
            });
369
1
        }
370
7
    }
371
194
}
372
373
194
fn check_i4(records: &[Record], quiet: bool, out: &mut Vec<Violation>) {
374
194
    if !quiet {
375
7
        return;
376
187
    }
377
128k
    for r in 
records187
{
378
        if let Event::Ledger {
379
187
            scheduler_executing,
380
187
            workers_running,
381
128k
        } = r.event
382
187
            && scheduler_executing != workers_running
383
1
        {
384
1
            out.push(Violation {
385
1
                invariant: Invariant::I4,
386
1
                at_ms: r.at_ms,
387
1
                detail: format!(
388
1
                    "the scheduler has {scheduler_executing} executing, the workers run {workers_running}"
389
1
                ),
390
1
            });
391
128k
        }
392
    }
393
194
}
394
395
4
fn check_i5(records: &[Record], out: &mut Vec<Violation>) {
396
4
    let ops = ops(records);
397
    // Per action: priority, first submit, ops.
398
4
    let mut priority: HashMap<&str, i32> = HashMap::new();
399
4
    let mut submitted: HashMap<&str, u64> = HashMap::new();
400
265
    for r in 
records4
{
401
        if let Event::Submit {
402
22
            action,
403
22
            priority: p,
404
            ..
405
265
        } = &r.event
406
22
        {
407
22
            priority.insert(action, *p);
408
22
            submitted.entry(action).or_insert(r.at_ms);
409
243
        }
410
    }
411
4
    let mut dispatched: HashSet<&str> = HashSet::new();
412
265
    for r in 
records4
{
413
265
        let Event::Dispatch { 
action22
, .. } = &r.event else {
414
243
            continue;
415
        };
416
22
        if !dispatched.insert(action.as_str()) {
417
0
            continue;
418
22
        }
419
22
        let (Some(&p), Some(&s)) = (
420
22
            priority.get(action.as_str()),
421
22
            submitted.get(action.as_str()),
422
        ) else {
423
0
            continue;
424
        };
425
130
        for (&other, &op_priority) in 
&priority22
{
426
130
            if other == action || 
dispatched108
.
contains108
(
other108
) {
427
76
                continue;
428
54
            }
429
54
            let other_submit = submitted[other];
430
54
            if other_submit > r.at_ms {
431
18
                continue;
432
36
            }
433
36
            let attached = ops
434
36
                .action
435
36
                .iter()
436
150
                .
any36
(|(op, a)| a == other &&
ops.gone36
.
get36
(
op36
).
is_none_or36
(|g|
*g0
>
r.at_ms0
));
437
36
            if attached && (op_priority > p || (
op_priority == p34
&&
other_submit < s11
)) {
438
2
                out.push(Violation {
439
2
                    invariant: Invariant::I5,
440
2
                    at_ms: r.at_ms,
441
2
                    detail: format!(
442
2
                        "{action} (p{p}, at {s} ms) dispatched ahead of waiting {other} (p{op_priority}, at {other_submit} ms)"
443
2
                    ),
444
2
                });
445
34
            }
446
        }
447
    }
448
4
}
449
450
194
fn check_i6(records: &[Record], facts: &Facts, end_ms: u64, out: &mut Vec<Violation>) {
451
194
    let ops = ops(records);
452
194
    let mut abandoned: HashMap<&str, u64> = HashMap::new();
453
194
    let mut by_action: HashMap<&str, Vec<&str>> = HashMap::new();
454
16.0k
    for (op, action) in 
&ops.action194
{
455
16.0k
        by_action.entry(action).or_default().push(op);
456
16.0k
    }
457
16.0k
    for (action, action_ops) in 
by_action194
{
458
16.0k
        let all_gone = action_ops.iter().all(|op| ops.gone.contains_key(*op));
459
16.0k
        if all_gone && let Some(
last4.01k
) =
action_ops.iter()4.01k
.
filter_map4.01k
(|op|
ops.gone4.01k
.
get4.01k
(
*op4.01k
)).
max4.01k
() {
460
4.01k
            abandoned.insert(action, *last);
461
12.0k
        }
462
    }
463
135k
    for r in 
records194
{
464
135k
        if let Event::Dispatch { 
worker10.7k
,
action10.7k
, .. } = &r.event
465
10.7k
            && let Some(&
gone72
) = abandoned.get(action.as_str())
466
72
            && r.at_ms > gone + facts.abandon_bound_ms
467
1
        {
468
1
            out.push(Violation {
469
1
                invariant: Invariant::I6,
470
1
                at_ms: r.at_ms,
471
1
                detail: format!(
472
1
                    "{action} dispatched to {worker} {} ms after its last client left",
473
1
                    r.at_ms - gone
474
1
                ),
475
1
            });
476
135k
        }
477
    }
478
194
    for (
worker51
,
start51
,
end51
) in &facts.silences {
479
51
        let deadline = start + facts.evict_bound_ms;
480
51
        if end.min(&end_ms) < &deadline {
481
21
            continue;
482
30
        }
483
        // Only a worker with a connection when the silence began has
484
        // anything to evict.
485
30
        let connected = records
486
30
            .iter()
487
2.65k
            .
take_while30
(|r| r.at_ms <= *start)
488
115
            .
filter_map30
(|r| match &r.event {
489
115
                Event::WorkerConnected { worker: w, .. } if w == worke
r32
=>
Some(true)32
,
490
11
                Event::WorkerDisconnected { worker: w } | Event::WorkerStopped { worker: 
w6
}
491
11
                    if w == worke
r5
=>
492
                {
493
8
                    Some(false)
494
                }
495
2.58k
                _ => None,
496
2.62k
            })
497
30
            .last()
498
30
            .unwrap_or(false);
499
30
        if !connected {
500
3
            continue;
501
27
        }
502
2.89k
        let 
evicted27
=
records.iter()27
.
any27
(|r| {
503
2.89k
            r.at_ms >= *start
504
853
                && r.at_ms <= deadline
505
32
                && 
matches!27
(&r.event, Event::WorkerDisconnected { worker: w } if w == worke
r27
)
506
2.89k
        });
507
27
        if !evicted {
508
0
            out.push(Violation {
509
0
                invariant: Invariant::I6,
510
0
                at_ms: deadline,
511
0
                detail: format!(
512
0
                    "{worker} went silent at {start} ms and was not evicted within {} ms",
513
0
                    facts.evict_bound_ms
514
0
                ),
515
0
            });
516
27
        }
517
    }
518
194
}
519
520
194
fn check_i8(records: &[Record], quiet: bool, end_ms: u64, out: &mut Vec<Violation>) {
521
194
    if quiet {
522
187
        return;
523
7
    }
524
7
    let ops = ops(records);
525
7
    let mut waiting: Vec<_> = ops
526
7
        .action
527
7
        .keys()
528
2.21k
        .
filter7
(|op| !ops.gone.contains_key(*op) && !ops.done.contains(*op))
529
7
        .cloned()
530
7
        .collect();
531
7
    waiting.sort();
532
7
    out.push(Violation {
533
7
        invariant: Invariant::I8,
534
7
        at_ms: end_ms,
535
7
        detail: if waiting.is_empty() {
536
1
            "the settle budget ran out with executions still running".to_string()
537
        } else {
538
6
            let shown: Vec<_> = waiting.iter().take(10).cloned().collect();
539
6
            format!(
540
                "the settle budget ran out with {} waiting ({}{})",
541
6
                waiting.len(),
542
6
                shown.join(", "),
543
6
                if waiting.len() > shown.len() {
544
1
                    ", ..."
545
                } else {
546
5
                    ""
547
                }
548
            )
549
        },
550
    });
551
194
}
552
553
194
fn check_i9(records: &[Record], facts: &Facts, out: &mut Vec<Violation>) {
554
194
    let mut running: HashMap<&str, HashSet<&str>> = HashMap::new();
555
135k
    for r in 
records194
{
556
135k
        match &r.event {
557
10.7k
            Event::ExecStart { worker, key, .. } => {
558
10.7k
                let set = running.entry(worker).or_default();
559
10.7k
                set.insert(key);
560
10.7k
                let limit = facts
561
10.7k
                    .max_inflight
562
10.7k
                    .get(worker.as_str())
563
10.7k
                    .copied()
564
10.7k
                    .unwrap_or(1);
565
10.7k
                if set.len() as u64 > limit {
566
9
                    out.push(Violation {
567
9
                        invariant: Invariant::I9,
568
9
                        at_ms: r.at_ms,
569
9
                        detail: format!("{worker} runs {} actions, limit {limit}", set.len()),
570
9
                    });
571
10.7k
                }
572
            }
573
10.7k
            Event::ExecEnd { worker, key, .. } => {
574
10.7k
                if let Some(
set10.6k
) = running.get_mut(worker.as_str()) {
575
10.6k
                    set.remove(key.as_str());
576
10.6k
                
}95
577
            }
578
77
            Event::WorkerStopped { worker } => {
579
77
                running.remove(worker.as_str());
580
77
            }
581
114k
            _ => {}
582
        }
583
    }
584
194
}
585
586
92.2k
const fn rank(stage: Stage) -> Option<u8> {
587
92.2k
    match stage {
588
0
        Stage::Unknown => None,
589
0
        Stage::CacheCheck => Some(0),
590
40.3k
        Stage::Queued => Some(1),
591
31.8k
        Stage::Executing => Some(2),
592
20.0k
        Stage::Completed => Some(3),
593
    }
594
92.2k
}
595
596
194
fn check_i10(records: &[Record], out: &mut Vec<Violation>) {
597
194
    let mut last: HashMap<&str, Stage> = HashMap::new();
598
194
    let mut done: HashSet<&str> = HashSet::new();
599
135k
    for r in 
records194
{
600
135k
        match &r.event {
601
35.7k
            Event::ClientStage { op, stage } => {
602
35.7k
                if done.contains(op.as_str()) {
603
1
                    out.push(Violation {
604
1
                        invariant: Invariant::I10,
605
1
                        at_ms: r.at_ms,
606
1
                        detail: format!("{op} saw {stage:?} after it was done"),
607
1
                    });
608
1
                    continue;
609
35.7k
                }
610
20.8k
                if let (Some(prev), Some(now)) =
611
35.7k
                    (last.get(op.as_str()).and_then(|s| 
rank20.8k
(
*s20.8k
)), rank(*stage))
612
20.8k
                    && now < prev
613
430
                    && !(prev == 2 && now == 1)
614
0
                {
615
0
                    out.push(Violation {
616
0
                        invariant: Invariant::I10,
617
0
                        at_ms: r.at_ms,
618
0
                        detail: format!("{op} went from {:?} back to {stage:?}", last[op.as_str()]),
619
0
                    });
620
35.7k
                }
621
35.7k
                if rank(*stage).is_some() {
622
35.7k
                    last.insert(op, *stage);
623
35.7k
                
}0
624
            }
625
615
            Event::ClientReconnect { 
op409
, .. } | Event::ClientResubmit {
op206
} => {
626
615
                last.remove(op.as_str());
627
615
            }
628
10.0k
            Event::ClientDone { op, .. } => {
629
10.0k
                done.insert(op);
630
10.0k
            }
631
89.4k
            _ => {}
632
        }
633
    }
634
194
}