Skip to main content

bathy_interpret/
interpret.rs

1//! `interpret`: the single pure entry point that turns a [`ProbeCapture`]
2//! into zero or more [`Interpretation`]s.
3
4use std::ops::Range;
5
6use bathy_types::ProbeCapture;
7use bathy_types::event::Observation;
8
9use crate::rules::rules_for;
10
11/// One claim `interpret` made about a capture, and the evidence for it.
12///
13/// Every field here exists to make the claim falsifiable: `rule_id` names
14/// exactly which rule fired (`explain(rule_id)` always resolves it,
15/// AC-4.12), and `matched_span` indexes the *real bytes* of the response
16/// that justified it -- not a paraphrase, the actual slice.
17#[derive(Clone, Debug, PartialEq)]
18pub struct Interpretation {
19    pub observation: Observation,
20    pub rule_id: &'static str,
21    /// Byte range within `capture.response` that justified the claim.
22    /// Always a valid range into that slice: `matched_span.start <=
23    /// matched_span.end <= capture.response.len()`.
24    pub matched_span: Range<usize>,
25    pub rationale: String,
26}
27
28/// Turn one capture into zero or more observations.
29///
30/// PURE. No I/O, no clock, no randomness, no allocation-order dependence.
31/// Given identical bytes this returns an identical vector forever, which is
32/// what lets `bathy` answer "why do you believe this" from stored evidence
33/// and what lets the replay corpus in M4 Task 4 act as a real regression
34/// suite.
35///
36/// Returns an empty vector when nothing matches (AC-4.13). Guessing a
37/// service from bytes that do not structurally confirm it is a bug, not a
38/// feature -- see `tests::unrecognized_bytes_yield_no_observation_rather_than_a_guess`.
39pub fn interpret(capture: &ProbeCapture) -> Vec<Interpretation> {
40    let mut out = Vec::new();
41    for rule in rules_for(capture.probe_id) {
42        if let Some(hit) = (rule.matcher)(&capture.response) {
43            out.push(Interpretation {
44                observation: Observation {
45                    service: rule.doc.service.to_owned(),
46                    product: hit.product,
47                    version: hit.version,
48                    confidence: hit.specificity.confidence(),
49                },
50                rule_id: rule.doc.id,
51                matched_span: hit.span,
52                rationale: rule.doc.rationale.to_owned(),
53            });
54        }
55    }
56    sort_stable(out)
57}
58
59/// Highest confidence first; ties broken by `rule_id` so the ordering is
60/// total and stable rather than dependent on registration order or (were
61/// this ever backed by a hash-based collection) iteration order (AC-4.14).
62/// Factored out of `interpret` so this ordering guarantee is directly unit-
63/// and property-testable without needing a real rule match to exercise it.
64pub(crate) fn sort_stable(mut out: Vec<Interpretation>) -> Vec<Interpretation> {
65    out.sort_by(|a, b| {
66        b.observation
67            .confidence
68            .partial_cmp(&a.observation.confidence)
69            .unwrap_or(std::cmp::Ordering::Equal)
70            .then_with(|| a.rule_id.cmp(b.rule_id))
71    });
72    out
73}
74
75#[cfg(test)]
76mod tests {
77    use bathy_types::Transport;
78    use bathy_types::confidence::Confidence;
79    use proptest::prelude::*;
80
81    use super::*;
82
83    fn cap(id: &'static str, port: u16, response: &[u8]) -> ProbeCapture {
84        ProbeCapture {
85            probe_id: id,
86            transport: Transport::Tcp,
87            port,
88            request: None,
89            response: response.to_vec(),
90            elapsed_micros: 0,
91            truncated: false,
92        }
93    }
94
95    // --- Determinism / ordering (AC-4.14), isolated from any specific
96    // rule's matching logic -- this is `interpret`'s own sort behavior. ---
97
98    fn observation(confidence: f64) -> Observation {
99        Observation {
100            service: "test".to_string(),
101            product: None,
102            version: None,
103            confidence: Confidence::new(confidence).unwrap(),
104        }
105    }
106
107    fn interp(rule_id: &'static str, confidence: f64) -> Interpretation {
108        Interpretation {
109            observation: observation(confidence),
110            rule_id,
111            matched_span: 0..0,
112            rationale: String::new(),
113        }
114    }
115
116    #[test]
117    fn sort_stable_orders_by_confidence_descending() {
118        let out = sort_stable(vec![interp("a", 0.5), interp("b", 0.9), interp("c", 0.7)]);
119        let ids: Vec<&str> = out.iter().map(|i| i.rule_id).collect();
120        assert_eq!(ids, vec!["b", "c", "a"]);
121    }
122
123    #[test]
124    fn tie_break_is_by_rule_id_not_registration_order() {
125        // Three equal-confidence interpretations registered in a
126        // deliberately non-alphabetical order: the sort must still produce
127        // ascending rule_id, proving the tiebreak is the id itself, not
128        // whatever order they happened to be pushed in.
129        let out = sort_stable(vec![
130            interp("zebra", 0.8),
131            interp("apple", 0.8),
132            interp("mango", 0.8),
133        ]);
134        let ids: Vec<&str> = out.iter().map(|i| i.rule_id).collect();
135        assert_eq!(ids, vec!["apple", "mango", "zebra"]);
136    }
137
138    proptest! {
139        // Root-cause fix, M4 Task 4 mutation testing (found while
140        // mutation-testing `sort_stable`'s tie-break for the replay
141        // corpus's own verification -- see that task's report): the
142        // original version of this property drew *continuous* confidences
143        // (`0.0f64..=1.0`) and assigned them to ids in an already-
144        // alphabetically-ascending registration order (`["a","b",...,"h"]`).
145        // Two independent reasons made it unable to actually observe a
146        // broken tie-break: continuous floats tie with probability zero, so
147        // its own `a == b` branch almost never ran; and even on the rare
148        // exact tie, `sort_by` is a *stable* sort, so a mutant that replaced
149        // the real tie-break with a no-op (`Ordering::Equal`) would leave
150        // tied items in registration order -- which this property's
151        // original id assignment already happened to be ascending by id,
152        // so the assertion passed regardless. Reproduced directly: replacing
153        // `sort_stable`'s `.then_with(|| a.rule_id.cmp(b.rule_id))` with
154        // `.then_with(|| std::cmp::Ordering::Equal)` left this property
155        // green while failing this module's own
156        // `tie_break_is_by_rule_id_not_registration_order` immediately.
157        // Fixed with a coarse, 5-rung confidence domain (so genuine ties are
158        // common, not measure-zero) and a *descending* id-to-registration
159        // mapping (so a no-op tiebreak's stable-sort fallback disagrees with
160        // the required ascending-by-id order on every tie this generates).
161        #[test]
162        fn sort_stable_is_deterministic_and_produces_a_total_order(
163            picks in proptest::collection::vec((0u8..5, 0usize..8), 0..8),
164        ) {
165            let ids = ["h", "g", "f", "e", "d", "c", "b", "a"];
166            let items: Vec<Interpretation> = picks
167                .iter()
168                .map(|&(rung, id_ix)| interp(ids[id_ix], f64::from(rung) * 0.2))
169                .collect();
170            let sorted_once = sort_stable(items.clone());
171            let sorted_twice = sort_stable(items);
172            prop_assert_eq!(&sorted_once, &sorted_twice);
173            for w in sorted_once.windows(2) {
174                let a = w[0].observation.confidence.get();
175                let b = w[1].observation.confidence.get();
176                prop_assert!(
177                    a > b || (a == b && w[0].rule_id <= w[1].rule_id),
178                    "not totally ordered: {a} ({}) then {b} ({})",
179                    w[0].rule_id,
180                    w[1].rule_id
181                );
182            }
183        }
184    }
185
186    // --- From the brief (Step 1) ---
187
188    #[test]
189    fn identifies_nginx_with_a_version_at_high_confidence() {
190        let out = interpret(&cap(
191            "http-get-v1",
192            80,
193            b"HTTP/1.1 200 OK\r\nServer: nginx/1.26.0\r\n\r\n",
194        ));
195        let top = &out[0];
196        assert_eq!(top.observation.service, "http");
197        assert_eq!(top.observation.product.as_deref(), Some("nginx"));
198        assert_eq!(top.observation.version.as_deref(), Some("1.26.0"));
199        assert!(top.observation.confidence.get() >= 0.90);
200    }
201
202    #[test]
203    fn a_product_without_a_version_scores_lower_than_one_with() {
204        let with = interpret(&cap(
205            "http-get-v1",
206            80,
207            b"HTTP/1.1 200 OK\r\nServer: nginx/1.26.0\r\n\r\n",
208        ));
209        let without = interpret(&cap(
210            "http-get-v1",
211            80,
212            b"HTTP/1.1 200 OK\r\nServer: nginx\r\n\r\n",
213        ));
214        assert!(without[0].observation.confidence.get() < with[0].observation.confidence.get());
215        assert!(without[0].observation.version.is_none());
216    }
217
218    #[test]
219    fn a_bare_protocol_match_still_reports_the_service_at_low_confidence() {
220        let out = interpret(&cap("http-get-v1", 8080, b"HTTP/1.0 404 Not Found\r\n\r\n"));
221        assert_eq!(out[0].observation.service, "http");
222        assert!(out[0].observation.product.is_none());
223        assert!(out[0].observation.confidence.get() <= 0.75);
224    }
225
226    #[test]
227    fn identifies_openssh_from_its_banner() {
228        let out = interpret(&cap(
229            "ssh-banner-v1",
230            22,
231            b"SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13\r\n",
232        ));
233        assert_eq!(out[0].observation.service, "ssh");
234        assert_eq!(out[0].observation.product.as_deref(), Some("OpenSSH"));
235        assert_eq!(out[0].observation.version.as_deref(), Some("9.6p1"));
236    }
237
238    #[test]
239    fn identifies_postgres_from_its_single_byte_ssl_reply() {
240        let out = interpret(&cap("postgres-startup-v1", 5432, b"S"));
241        assert_eq!(out[0].observation.service, "postgresql");
242    }
243
244    #[test]
245    fn every_interpretation_cites_the_rule_and_the_matched_bytes() {
246        let c = cap(
247            "http-get-v1",
248            80,
249            b"HTTP/1.1 200 OK\r\nServer: nginx/1.26.0\r\n\r\n",
250        );
251        let out = interpret(&c);
252        let i = &out[0];
253        assert!(!i.rule_id.is_empty());
254        let matched = &c.response[i.matched_span.clone()];
255        assert!(
256            String::from_utf8_lossy(matched).contains("nginx"),
257            "matched_span must point at the bytes that justified the claim"
258        );
259        assert!(
260            crate::explain(i.rule_id).is_some(),
261            "every rule must be explainable"
262        );
263    }
264
265    #[test]
266    fn unrecognized_bytes_yield_no_observation_rather_than_a_guess() {
267        let out = interpret(&cap("http-get-v1", 80, b"\x00\x01\x02\x03garbage"));
268        assert!(out.is_empty(), "interpretation must not invent a service");
269    }
270
271    #[test]
272    fn interpretation_is_deterministic() {
273        let c = cap("ssh-banner-v1", 22, b"SSH-2.0-OpenSSH_9.6p1\r\n");
274        assert_eq!(interpret(&c), interpret(&c));
275    }
276
277    #[test]
278    fn interpretation_never_panics_on_arbitrary_bytes() {
279        for len in [0usize, 1, 2, 3, 7, 64, 8192] {
280            for fill in [0x00u8, 0xff, 0x0a, 0x1b] {
281                let _ = interpret(&cap("http-get-v1", 80, &vec![fill; len]));
282                let _ = interpret(&cap("tls-v1", 443, &vec![fill; len]));
283            }
284        }
285    }
286
287    // --- Verification beyond the brief ---
288
289    #[test]
290    fn interpretation_never_panics_on_lone_surrogate_shaped_byte_sequences() {
291        // `0xED 0xA0 0x80` is the WTF-8-style attempted encoding of U+D800
292        // (a lone high surrogate) -- invalid per RFC 3629 (UTF-8 excludes
293        // the surrogate range D800-DFFF), so `std::str::from_utf8` must
294        // reject it. Exercised at several lengths and against every probe
295        // id this crate has rules for, not just http/tls.
296        let surrogate: [u8; 3] = [0xED, 0xA0, 0x80];
297        for probe_id in [
298            "http-get-v1",
299            "tls-v1",
300            "ssh-banner-v1",
301            "smtp-banner-v1",
302            "dns-version-bind-v1",
303            "postgres-startup-v1",
304            "mysql-greeting-v1",
305            "redis-ping-v1",
306        ] {
307            for reps in [1usize, 5, 500] {
308                let bytes: Vec<u8> = surrogate.iter().cycle().take(reps * 3).copied().collect();
309                let _ = interpret(&cap(probe_id, 1, &bytes));
310            }
311        }
312    }
313
314    #[test]
315    fn interpretation_never_panics_across_every_known_probe_id_and_many_byte_shapes() {
316        let probe_ids = [
317            "http-get-v1",
318            "tls-v1",
319            "ssh-banner-v1",
320            "smtp-banner-v1",
321            "dns-version-bind-v1",
322            "postgres-startup-v1",
323            "mysql-greeting-v1",
324            "redis-ping-v1",
325            "totally-unknown-probe-id",
326        ];
327        for probe_id in probe_ids {
328            for len in [0usize, 1, 2, 4, 5, 6, 8, 9, 10, 11, 45, 66, 300] {
329                for fill in [0x00u8, 0xff, 0x0a, 0x16, 0x02, b'S', b'N'] {
330                    let _ = interpret(&cap(probe_id, 1, &vec![fill; len]));
331                }
332            }
333        }
334    }
335
336    // --- Structured strategies for the property tests below ---
337    //
338    // Root-cause fix, M4 Task 3 review round 1: the original version of
339    // both property tests below generated *fully arbitrary* bytes
340    // (`proptest::collection::vec(any::<u8>(), 0..300)`) and picked among
341    // 8 known probe ids uniformly. Reviewed at 4096 cases: only 6 ever
342    // produced a non-empty `interpret` result, and none of those 6
343    // produced a span with `end > 6` -- meaning the property never once
344    // reached the offset arithmetic in `http_nginx`, `ssh_openssh`,
345    // `smtp_postfix`, `dns_bind_version`, or `mysql_handshake_v10`. A
346    // property test that (almost) never exercises the code path it claims
347    // to cover is not evidence for that coverage; citing it as AC-4.12
348    // evidence, as this task's own report did, was not supportable.
349    //
350    // The strategies below generate a *protocol-shaped* response for a
351    // matching probe id most of the time (weight 3 per protocol below,
352    // 24 total), each parameterized by proptest-chosen random fields
353    // (version numbers, hostnames, DNS record content, ...) and passed
354    // through `with_corruption`, which -- itself randomly, per case --
355    // either leaves the bytes alone, truncates them at a random point, or
356    // splices a random extra byte in at a random point. That keeps every
357    // rule's happy path, near-miss path, and truncation/corruption path
358    // all live and reachable, not just the near-certain "matches nothing"
359    // outcome of pure `any::<u8>()` bytes. A residual arm (weight 6) still
360    // generates fully arbitrary bytes against an arbitrary probe id
361    // (including an unknown one), preserving the original "never panics /
362    // never guesses on garbage" coverage the vacuous version did provide.
363
364    /// Randomly leaves `valid` bytes alone, truncates them at a random
365    /// point, or splices one random extra byte in at a random point.
366    fn with_corruption(valid: impl Strategy<Value = Vec<u8>>) -> impl Strategy<Value = Vec<u8>> {
367        (valid, 0u8..3, any::<usize>(), any::<u8>()).prop_map(|(bytes, mode, at, extra)| match mode
368        {
369            0 => bytes,
370            1 => {
371                if bytes.is_empty() {
372                    bytes
373                } else {
374                    let cut = at % (bytes.len() + 1);
375                    bytes[..cut].to_vec()
376                }
377            }
378            _ => {
379                let mut b = bytes;
380                let pos = at % (b.len() + 1);
381                b.insert(pos, extra);
382                b
383            }
384        })
385    }
386
387    fn http_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
388        (1u16..500, 0u16..500, 0u16..500, any::<bool>()).prop_map(
389            |(major, minor, patch, with_version)| {
390                let server = if with_version {
391                    format!("nginx/{major}.{minor}.{patch}")
392                } else {
393                    "nginx".to_string()
394                };
395                format!("HTTP/1.1 200 OK\r\nServer: {server}\r\n\r\n").into_bytes()
396            },
397        )
398    }
399
400    fn ssh_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
401        (1u16..50, 0u16..50, any::<bool>()).prop_map(|(major, minor, with_patch)| {
402            let patch = if with_patch {
403                format!("p{minor}")
404            } else {
405                String::new()
406            };
407            format!("SSH-2.0-OpenSSH_{major}.{minor}{patch}\r\n").into_bytes()
408        })
409    }
410
411    fn smtp_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
412        (0u32..1000, any::<bool>()).prop_map(|(host_n, is_postfix)| {
413            let software = if is_postfix { "Postfix" } else { "Sendmail" };
414            format!("220 host{host_n}.example.com ESMTP {software}\r\n").into_bytes()
415        })
416    }
417
418    fn mysql_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
419        (
420            0u8..30,
421            0u8..30,
422            0u8..30,
423            proptest::collection::vec(any::<u8>(), 0..20),
424        )
425            .prop_map(|(major, minor, patch, trailing)| {
426                let mut bytes = vec![0u8, 0, 0, 0, 0x0a]; // header + protocol_version
427                bytes.extend_from_slice(format!("{major}.{minor}.{patch}").as_bytes());
428                bytes.push(0); // NUL terminator
429                bytes.extend_from_slice(&trailing);
430                bytes
431            })
432    }
433
434    /// Builds a synthetic-but-wire-valid `version.bind`/TXT/CHAOS reply
435    /// (the same shape `dns_bind_version` in `rules.rs` parses, and the
436    /// same shape the real BIND capture in that module's tests has), with
437    /// a proptest-chosen transaction id and version string.
438    fn build_synthetic_dns_reply(id: u16, version: &str) -> Vec<u8> {
439        let mut msg = Vec::new();
440        msg.extend_from_slice(&id.to_be_bytes());
441        msg.extend_from_slice(&0x8400u16.to_be_bytes()); // flags: response
442        msg.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT
443        msg.extend_from_slice(&1u16.to_be_bytes()); // ANCOUNT
444        msg.extend_from_slice(&0u16.to_be_bytes()); // NSCOUNT
445        msg.extend_from_slice(&0u16.to_be_bytes()); // ARCOUNT
446        for label in ["version", "bind"] {
447            msg.push(label.len() as u8);
448            msg.extend_from_slice(label.as_bytes());
449        }
450        msg.push(0); // root label
451        msg.extend_from_slice(&16u16.to_be_bytes()); // QTYPE TXT
452        msg.extend_from_slice(&3u16.to_be_bytes()); // QCLASS CH
453        msg.extend_from_slice(&[0xC0, 0x0C]); // answer name: pointer to offset 12
454        msg.extend_from_slice(&16u16.to_be_bytes()); // TYPE TXT
455        msg.extend_from_slice(&3u16.to_be_bytes()); // CLASS CH
456        msg.extend_from_slice(&0u32.to_be_bytes()); // TTL
457        let rdata_len = 1 + version.len();
458        msg.extend_from_slice(&(rdata_len as u16).to_be_bytes());
459        msg.push(version.len() as u8);
460        msg.extend_from_slice(version.as_bytes());
461
462        let mut framed = Vec::with_capacity(2 + msg.len());
463        framed.extend_from_slice(&(msg.len() as u16).to_be_bytes());
464        framed.extend_from_slice(&msg);
465        framed
466    }
467
468    fn dns_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
469        (any::<u16>(), 1usize..15).prop_map(|(id, version_len)| {
470            let version: String = (0..version_len)
471                .map(|i| (b'0' + (i % 10) as u8) as char)
472                .collect();
473            build_synthetic_dns_reply(id, &version)
474        })
475    }
476
477    fn tls_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
478        proptest::collection::vec(any::<u8>(), 0..50).prop_map(|trailing| {
479            let mut bytes = vec![0x16, 0x03, 0x03, 0x00, 0x02, 0x02];
480            bytes.extend_from_slice(&trailing);
481            bytes
482        })
483    }
484
485    fn redis_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
486        prop_oneof![
487            Just(b"+PONG\r\n".to_vec()),
488            Just(b"-ERR unknown command\r\n".to_vec()),
489            Just(b":1000\r\n".to_vec()),
490            Just(b"$-1\r\n".to_vec()),
491        ]
492    }
493
494    fn postgres_valid_bytes() -> impl Strategy<Value = Vec<u8>> {
495        prop_oneof![Just(b"S".to_vec()), Just(b"N".to_vec())]
496    }
497
498    fn probe_and_response_strategy() -> impl Strategy<Value = (&'static str, Vec<u8>)> {
499        prop_oneof![
500            3 => with_corruption(http_valid_bytes()).prop_map(|b| ("http-get-v1", b)),
501            3 => with_corruption(ssh_valid_bytes()).prop_map(|b| ("ssh-banner-v1", b)),
502            3 => with_corruption(smtp_valid_bytes()).prop_map(|b| ("smtp-banner-v1", b)),
503            3 => with_corruption(mysql_valid_bytes()).prop_map(|b| ("mysql-greeting-v1", b)),
504            3 => with_corruption(dns_valid_bytes()).prop_map(|b| ("dns-version-bind-v1", b)),
505            3 => with_corruption(tls_valid_bytes()).prop_map(|b| ("tls-v1", b)),
506            3 => with_corruption(redis_valid_bytes()).prop_map(|b| ("redis-ping-v1", b)),
507            3 => with_corruption(postgres_valid_bytes()).prop_map(|b| ("postgres-startup-v1", b)),
508            6 => (
509                prop_oneof![
510                    Just("http-get-v1"),
511                    Just("tls-v1"),
512                    Just("ssh-banner-v1"),
513                    Just("smtp-banner-v1"),
514                    Just("dns-version-bind-v1"),
515                    Just("postgres-startup-v1"),
516                    Just("mysql-greeting-v1"),
517                    Just("redis-ping-v1"),
518                    Just("totally-unknown-probe-id"),
519                ],
520                proptest::collection::vec(any::<u8>(), 0..300),
521            ),
522        ]
523    }
524
525    proptest! {
526        #![proptest_config(ProptestConfig::with_cases(2048))]
527
528        // AC-4.12 / verification beyond the brief: every span the rule set
529        // can ever produce is a valid range into the response it was
530        // computed from. Uses `probe_and_response_strategy` (see above)
531        // so this actually exercises the offset arithmetic in every rule,
532        // not just the "nothing matched" path -- see this test module's
533        // note on why the earlier, fully-arbitrary-bytes version of this
534        // property was near-vacuous.
535        #[test]
536        fn matched_span_is_always_a_valid_range_into_the_response(
537            (probe_id, response) in probe_and_response_strategy(),
538        ) {
539            let c = cap(probe_id, 1, &response);
540            let interpretations = interpret(&c);
541            for i in &interpretations {
542                prop_assert!(i.matched_span.start <= i.matched_span.end);
543                prop_assert!(i.matched_span.end <= c.response.len());
544            }
545        }
546
547        // AC-4.14: same bytes in, byte-identical vector out, over inputs
548        // that actually reach real rule matches (not just arbitrary bytes
549        // that almost always produce an empty, trivially-equal vector).
550        #[test]
551        fn interpret_is_deterministic_over_arbitrary_input(
552            (probe_id, response) in probe_and_response_strategy(),
553        ) {
554            let c = cap(probe_id, 1, &response);
555            prop_assert_eq!(interpret(&c), interpret(&c));
556        }
557
558    }
559
560    // Companion to the two properties above, run directly rather than as a
561    // `proptest!` property: measures, with real counts, that
562    // `probe_and_response_strategy` actually reaches matches and deep
563    // spans most of the time -- not just "less vacuous in theory". The
564    // original strategy this replaces was independently audited at 4096
565    // cases: 6 non-empty results, 0 with `matched_span.end > 6` (i.e. it
566    // essentially never reached any rule's offset arithmetic). This test
567    // is what backs the claim that the replacement strategy is different
568    // in kind, not just in case count.
569    #[test]
570    fn structured_strategy_reaches_real_matches_and_deep_spans_most_of_the_time() {
571        use proptest::strategy::ValueTree;
572        use proptest::test_runner::TestRunner;
573        let mut runner = TestRunner::default();
574        let strategy = probe_and_response_strategy();
575        const TOTAL: usize = 2000;
576        let mut non_empty = 0usize;
577        let mut deep_span = 0usize;
578        for _ in 0..TOTAL {
579            let (probe_id, response) = strategy.new_tree(&mut runner).unwrap().current();
580            let interpretations = interpret(&cap(probe_id, 1, &response));
581            if !interpretations.is_empty() {
582                non_empty += 1;
583            }
584            if interpretations.iter().any(|i| i.matched_span.end > 6) {
585                deep_span += 1;
586            }
587        }
588        assert!(
589            non_empty * 100 >= TOTAL * 30,
590            "expected at least 30% of {TOTAL} structured cases to produce a match, got {non_empty}"
591        );
592        assert!(
593            deep_span * 100 >= TOTAL * 20,
594            "expected at least 20% of {TOTAL} structured cases to produce a span past byte 6 \
595             (i.e. actually reach a rule's own offset arithmetic), got {deep_span}"
596        );
597    }
598}