Skip to main content

supercov_engine/
python_evidence.rs

1//! Validation and normalization of the Python runtime's evidence records.
2//!
3//! Each Supercov-hooked interpreter publishes commit-framed JSON records into
4//! its own mmap: the process identity, every phase it entered (with the exact
5//! test identity that phase stands for), runner outcomes, hits, decision
6//! vectors and any measurement limitation the runtime detected. The one-byte
7//! commit marker is written last, so records completed before a hard kill
8//! remain readable while a torn tail stays inert.
9//!
10//! Probes do not write records: they store bytes into their context's slot, a
11//! small mapped file beside the transport, and the runtime harvests a slot into
12//! records when a phase ends. A process that dies between harvests leaves its
13//! slots behind; `layout.json`, written once per run, says which obligation or
14//! decision vector each byte stands for, and the slot's bytes are read as the
15//! last records of its process. Rust joins those records into the shared
16//! frontend protocol; the runtime never computes a verdict.
17
18use std::{
19    collections::{BTreeMap, BTreeSet},
20    fs::{self, File},
21    path::{Component, Path, PathBuf},
22};
23
24use memmap2::{Mmap, MmapOptions};
25use serde::Deserialize;
26use serde_json::json;
27use sha2::{Digest, Sha256};
28use supercov_contracts::{
29    AttributionPrecision, ExecutionModel, FrontendAttribution, FrontendLimitation,
30    FrontendLimitationScope, FrontendRunDeclaration, FrontendRunnerDeclaration,
31    LANGUAGE_FRONTEND_PROTOCOL_VERSION, StructuralSource,
32};
33
34use crate::{
35    coverage_analysis::McdcVector,
36    coverage_report::{
37        CoverageManifest, CoverageModelDeclaration, CoveragePhase, CoverageReportRequest,
38        DecisionMeta, DecisionSnapshot, ExecutionScope, ExitCodeInput, PersistedCoverageModel,
39        RawTestResult, RuntimeSnapshot, TestProvenance,
40    },
41    evidence_archive::EvidenceArchiveEntry,
42};
43
44pub const PYTHON_EVIDENCE_VERSION: u32 = 1;
45pub const PYTHON_FRONTEND_VERSION: &str = "python-probes-v1";
46pub const PYTEST_RUNNER: &str = "pytest";
47pub const UNITTEST_RUNNER: &str = "unittest";
48
49const TRANSPORT_MAGIC: &[u8; 8] = b"SCVPYTH1";
50/// Version 2 checksums records with CRC-32; version 1 used FNV-1a, and the
51/// runtime computed it a byte at a time in Python -- two microseconds of every
52/// record's five. Evidence kept from a failed run by an older runtime still
53/// reads, by its own checksum.
54const TRANSPORT_VERSION: u32 = 2;
55const TRANSPORT_VERSION_FNV: u32 = 1;
56const TRANSPORT_HEADER_SIZE: usize = 64;
57const TRANSPORT_RECORD_HEADER_SIZE: usize = 16;
58const TRANSPORT_MAX_RECORD_SIZE: usize = 4 * 1024 * 1024;
59
60fn default_runner() -> String {
61    PYTEST_RUNNER.into()
62}
63
64fn python_runner(runner: &str) -> bool {
65    matches!(runner, PYTEST_RUNNER | UNITTEST_RUNNER)
66}
67
68/// Every field the runtime writes is named so `deny_unknown_fields` keeps
69/// the record shape frozen, even where Rust does not read the value yet.
70#[derive(Debug, Deserialize)]
71#[allow(dead_code)]
72#[serde(tag = "t", rename_all = "lowercase", deny_unknown_fields)]
73enum Record {
74    Process {
75        v: u32,
76        run: String,
77        pid: u64,
78        worker: String,
79        python: String,
80        executable: String,
81        argv: Vec<String>,
82        /// Which observer measured the process. Probes are the only one
83        /// now; the field stays so evidence kept by an earlier runtime reads.
84        #[serde(default)]
85        frontend: Option<String>,
86    },
87    Worker {
88        worker: String,
89    },
90    Phase {
91        ctx: u64,
92        at: i64,
93        worker: String,
94        test: String,
95        retry: usize,
96        phase: String,
97    },
98    Outcome {
99        worker: String,
100        test: String,
101        retry: usize,
102        phase: String,
103        outcome: String,
104        xfail: bool,
105        #[serde(default = "default_runner")]
106        runner: String,
107        /// Where the runner says the test is defined. Absent for adapters or
108        /// synthesised tests that cannot name a file.
109        #[serde(default)]
110        file: Option<String>,
111        /// The test's markers, as pytest's `-m` reads them.
112        #[serde(default)]
113        tags: Vec<String>,
114    },
115    Hit {
116        ctx: u64,
117        id: String,
118    },
119    /// Many hits of one context in one record: the probe frontend harvests
120    /// a context's array at the phase switch and writes what it found at
121    /// once, instead of a record per first hit.
122    Hits {
123        ctx: u64,
124        ids: Vec<String>,
125    },
126    /// Hits read out of a slot, by position in the layout's ids. Made here,
127    /// never written by the runtime.
128    #[serde(skip)]
129    SlotHits {
130        ctx: u64,
131        positions: Vec<u32>,
132    },
133    Dec {
134        ctx: u64,
135        id: String,
136        v: String,
137        o: u8,
138    },
139    /// Many decision vectors of one context: (id, digits, outcome).
140    Decs {
141        ctx: u64,
142        v: Vec<(String, String, u8)>,
143    },
144    /// A harvest of one context's slot: its set bytes as runs, each a first
145    /// byte and a length, read through the run's slot layout exactly as a
146    /// slot a killed process left behind is read. The runtime names bytes,
147    /// not ids, so a harvest costs it a few dozen runs rather than a string
148    /// per hit.
149    Runs {
150        ctx: u64,
151        r: Vec<usize>,
152    },
153    /// The first assertion of a call phase: what the context recorded before
154    /// this record is the assertion's evidence too.
155    Assert {
156        ctx: u64,
157    },
158    /// One assertion site a call phase reached, once per site per test.
159    /// `unittest` reports the caller's frame and pytest reports the line its
160    /// rewriter recorded; both are resolved against the syntax inventory, and
161    /// a frame naming no inventoried site witnesses nothing.
162    Limitation {
163        id: String,
164        reason: String,
165        #[serde(default)]
166        file: Option<String>,
167        #[serde(default)]
168        obligation: Option<String>,
169    },
170    Exit {
171        at: i64,
172    },
173}
174
175#[derive(Debug)]
176pub enum PythonEvidenceError {
177    Io(String),
178    UnsafeEntry(String),
179    InvalidRecord {
180        file: String,
181        line: usize,
182        reason: String,
183    },
184    InvalidTransport {
185        file: String,
186        reason: String,
187    },
188    DroppedRecords {
189        file: String,
190        count: u64,
191    },
192    RunMismatch {
193        expected: String,
194        actual: String,
195    },
196    UnsupportedVersion(u32),
197    UnknownContext {
198        file: String,
199        line: usize,
200        context: u64,
201    },
202    UnknownObligation(String),
203    InvalidVector {
204        id: String,
205        expected: usize,
206        actual: usize,
207    },
208    NoInterpreter,
209    NoTests,
210    UnsupportedPython(String),
211}
212
213impl std::fmt::Display for PythonEvidenceError {
214    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        match self {
216            Self::Io(reason) => write!(formatter, "could not read Python evidence: {reason}"),
217            Self::UnsafeEntry(name) => write!(formatter, "unsafe Python evidence entry: {name}"),
218            Self::InvalidRecord { file, line, reason } => {
219                write!(formatter, "invalid Python evidence record {file}:{line}: {reason}")
220            }
221            Self::InvalidTransport { file, reason } => {
222                write!(formatter, "invalid Python evidence transport {file}: {reason}")
223            }
224            Self::DroppedRecords { file, count } => write!(
225                formatter,
226                "Python evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
227            ),
228            Self::RunMismatch { expected, actual } => write!(
229                formatter,
230                "Python evidence belongs to run {actual}, expected {expected}"
231            ),
232            Self::UnsupportedVersion(version) => {
233                write!(formatter, "unsupported Python evidence version {version}")
234            }
235            Self::UnknownContext { file, line, context } => write!(
236                formatter,
237                "Python evidence {file}:{line} references undeclared context {context}"
238            ),
239            Self::UnknownObligation(id) => {
240                write!(formatter, "Python runtime reported an unknown obligation: {id}")
241            }
242            Self::InvalidVector {
243                id,
244                expected,
245                actual,
246            } => write!(
247                formatter,
248                "Python decision {id} reported {actual} condition values, expected {expected}"
249            ),
250            Self::NoInterpreter => formatter.write_str(
251                "no Supercov-hooked Python interpreter ran: the test command did not start CPython 3.9+ with Supercov's start-up hook (PYTHONPATH may be ignored by -I/-E/-S, or the runner is not Python)",
252            ),
253            Self::NoTests => formatter.write_str(
254                "the Python run produced no test outcomes; Supercov measures Python through pytest and unittest",
255            ),
256            Self::UnsupportedPython(version) => write!(
257                formatter,
258                "Supercov measures CPython 3.9 or newer; the test command ran Python {version}"
259            ),
260        }
261    }
262}
263
264impl std::error::Error for PythonEvidenceError {}
265
266fn stable_id(prefix: &str, values: &[&str]) -> String {
267    let mut hash = Sha256::new();
268    for value in values {
269        hash.update(value.as_bytes());
270        hash.update([0]);
271    }
272    let digest = hash.finalize();
273    let mut encoded = String::with_capacity(prefix.len() + 25);
274    encoded.push_str(prefix);
275    encoded.push(':');
276    for byte in &digest[..12] {
277        use std::fmt::Write as _;
278        write!(&mut encoded, "{byte:02x}").expect("string formatting");
279    }
280    encoded
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
284struct Identity {
285    worker: String,
286    test: String,
287    retry: usize,
288    phase: String,
289}
290
291type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
292/// (worker, test, retry) -> [(phase, outcome, xfail)]
293type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
294/// (worker, test, retry) -> runner that reported the attempt
295type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
296/// (worker, test, retry) -> the source file the runner named for the test
297type TestFilesByAttempt = BTreeMap<(String, String, usize), String>;
298/// (worker, test, retry) -> the tags the runner named for the test
299type TestTagsByAttempt = BTreeMap<(String, String, usize), Vec<String>>;
300
301/// A runtime path, project-relative. Python reports both forms: a frame's
302/// `co_filename` is whatever the interpreter loaded, absolute or relative. A
303/// path outside the project names nothing.
304fn relative(root: &Path, path: &str) -> Option<String> {
305    let candidate = Path::new(path);
306    let relative = if candidate.is_absolute() {
307        candidate.strip_prefix(root).ok()?
308    } else {
309        candidate.strip_prefix("./").unwrap_or(candidate)
310    };
311    let text = relative.to_string_lossy().replace('\\', "/");
312    (!text.is_empty() && !text.starts_with("../")).then_some(text)
313}
314
315#[derive(Debug, Default)]
316struct Observations {
317    hits: BTreeSet<String>,
318    /// Hits a harvest named by slot byte, as positions in the layout's ids,
319    /// repeats included: turned into ids once, when the phase's snapshot is
320    /// built. A subprocess-heavy run harvests millions, and an owned id per
321    /// hit was most of reading its evidence.
322    slot_hits: Vec<u32>,
323    vectors: BTreeMap<String, ObservedVectors>,
324}
325
326impl Observations {
327    fn is_empty(&self) -> bool {
328        self.hits.is_empty() && self.slot_hits.is_empty() && self.vectors.is_empty()
329    }
330}
331
332#[derive(Debug, Clone)]
333struct RuntimeLimitation {
334    id: String,
335    reason: String,
336    file: Option<String>,
337    obligation: Option<String>,
338}
339
340#[derive(Debug, Default)]
341struct Evidence {
342    interpreters: usize,
343    python_versions: BTreeSet<String>,
344    per_identity: BTreeMap<Identity, Observations>,
345    background: BTreeMap<String, Observations>,
346    outcomes: OutcomesByAttempt,
347    runners: RunnersByAttempt,
348    test_files: TestFilesByAttempt,
349    test_tags: TestTagsByAttempt,
350    limitations: BTreeMap<(String, Option<String>, Option<String>), RuntimeLimitation>,
351    /// The layout's ids, which slot hits are positions in.
352    slot_ids: Vec<String>,
353    /// Each request a server answered that named no test: its identity, when
354    /// it came, where in its process's records, that process's transport, and
355    /// its worker.
356    requests: Vec<(Identity, i64, usize, String, String)>,
357    /// By transport, the browser test attempts a process answered a request
358    /// of.
359    served_by: BTreeMap<String, BTreeSet<(String, String, usize)>>,
360    /// When each of the suite's own test phases started, and in which
361    /// process.
362    phase_starts: Vec<crate::served_tests::PhaseStart>,
363}
364
365use crate::served_tests::REQUEST_WORKER;
366
367fn read_evidence_directory(
368    directory: &Path,
369    run_id: &str,
370) -> Result<Evidence, PythonEvidenceError> {
371    let mut evidence = Evidence::default();
372    let mut files = match fs::read_dir(directory) {
373        Ok(entries) => entries
374            .collect::<Result<Vec<_>, _>>()
375            .map_err(|error| PythonEvidenceError::Io(error.to_string()))?,
376        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
377        Err(error) => return Err(PythonEvidenceError::Io(error.to_string())),
378    };
379    files.sort_by_key(|entry| entry.file_name());
380    let mut transports = Vec::new();
381    let mut slots = BTreeMap::<String, Vec<(String, PathBuf)>>::new();
382    let mut layout_path = None;
383    for entry in files {
384        let name = entry
385            .file_name()
386            .into_string()
387            .map_err(|_| PythonEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
388        if Path::new(&name)
389            .components()
390            .any(|component| !matches!(component, Component::Normal(_)))
391        {
392            return Err(PythonEvidenceError::UnsafeEntry(name));
393        }
394        // A layout write cut short by the process dying; another process of
395        // the run, or none, published the whole file.
396        if name.starts_with(SLOT_LAYOUT_NAME) && name.ends_with(".partial") {
397            continue;
398        }
399        let Some(metadata) = entry_metadata(&name, &entry.path())? else {
400            continue;
401        };
402        if !metadata.file_type().is_file() {
403            return Err(PythonEvidenceError::UnsafeEntry(name));
404        }
405        if name == SLOT_LAYOUT_NAME {
406            layout_path = Some(entry.path());
407        } else if name.ends_with(".mmap") {
408            transports.push((name, entry.path()));
409        } else if let Some((slot_name, transport)) = name.strip_suffix(".shm").and_then(|stem| {
410            let (transport, number) = stem.rsplit_once('.')?;
411            number
412                .parse::<u64>()
413                .ok()
414                .map(|_| (format!("{stem}.slot"), format!("{transport}.mmap")))
415        }) {
416            if let Some(path) = materialize_shared_slot(&entry.path(), &slot_name)? {
417                slots.entry(transport).or_default().push((slot_name, path));
418            }
419        } else if let Some(transport) = name
420            .strip_suffix(".slot")
421            .and_then(|stem| stem.rsplit_once('.'))
422            .filter(|(_, number)| number.parse::<u64>().is_ok())
423            .map(|(transport, _)| format!("{transport}.mmap"))
424        {
425            // Created and killed before it was sized: nothing was written.
426            if metadata.len() > 0 {
427                slots
428                    .entry(transport)
429                    .or_default()
430                    .push((name, entry.path()));
431            }
432        } else {
433            return Err(PythonEvidenceError::UnsafeEntry(name));
434        }
435    }
436    let layout = match layout_path {
437        Some(path) => Some(SlotLayout::read(&path)?),
438        None => None,
439    };
440    // Each process's transport and slots are read and parsed on their own --
441    // a subprocess-heavy suite leaves tens of thousands -- and applied to the
442    // run's evidence in the order they are listed, as they were one by one.
443    let work = transports
444        .into_iter()
445        .map(|(name, path)| {
446            let owned = slots.remove(&name).unwrap_or_default();
447            (name, path, owned)
448        })
449        .collect::<Vec<_>>();
450    let parse = |(name, path, owned): &(String, PathBuf, Vec<(String, PathBuf)>)| {
451        let contents = map_evidence(path)?;
452        let slot_contents = owned
453            .iter()
454            .filter_map(|(slot, path)| match map_slot(path) {
455                Ok(Some(contents)) => Some(Ok((slot.clone(), contents))),
456                Ok(None) => None,
457                Err(error) => Some(Err(error)),
458            })
459            .collect::<Result<Vec<_>, _>>()?;
460        parse_transport(name, &contents, &slot_contents, layout.as_ref())
461    };
462    let parsed = parallel_map(&work, parse);
463    for ((name, _, _), parsed) in work.iter().zip(parsed) {
464        apply_transport(name, parsed?, run_id, &mut evidence)?;
465    }
466    evidence.slot_ids = layout.map(|layout| layout.ids).unwrap_or_default();
467    if let Some((_, orphans)) = slots.into_iter().next() {
468        return Err(PythonEvidenceError::InvalidTransport {
469            file: orphans[0].0.clone(),
470            reason: "a slot was left without its process's transport".into(),
471        });
472    }
473    Ok(evidence)
474}
475
476/// An evidence entry's metadata, or None for a slot gone since the listing.
477///
478/// A process that outlived the test command -- multiprocessing's resource
479/// tracker is one -- can close between the listing and this read. It harvested
480/// its slot into its transport first, so a slot that is gone holds nothing to
481/// miss. Anything else missing is still an error.
482fn entry_metadata(name: &str, path: &Path) -> Result<Option<fs::Metadata>, PythonEvidenceError> {
483    match fs::symlink_metadata(path) {
484        Ok(metadata) => Ok(Some(metadata)),
485        Err(error) if error.kind() == std::io::ErrorKind::NotFound && name.ends_with(".slot") => {
486            Ok(None)
487        }
488        Err(error) => Err(PythonEvidenceError::Io(error.to_string())),
489    }
490}
491
492/// A slot a macOS process kept in POSIX shared memory, which `marker` names,
493/// copied into an ordinary slot file beside it and then removed: evidence kept
494/// after a failed publication must hold what it held, and nothing else frees
495/// an object whose process was killed. None when there is nothing to read --
496/// the process closed and removed it, died before creating it, or its bytes
497/// were copied out by an earlier read, whose slot file the listing holds.
498fn materialize_shared_slot(
499    marker: &Path,
500    slot_name: &str,
501) -> Result<Option<PathBuf>, PythonEvidenceError> {
502    let slot = marker.with_file_name(slot_name);
503    if fs::symlink_metadata(&slot).is_ok() {
504        return Ok(None);
505    }
506    let contents = match fs::read(marker) {
507        Ok(contents) => contents,
508        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
509        Err(error) => return Err(PythonEvidenceError::Io(error.to_string())),
510    };
511    // Killed between creating the marker and writing the name into it.
512    if contents.is_empty() {
513        return Ok(None);
514    }
515    // `<name> <slot bytes>`: macOS rounds the object itself up to a page.
516    let unsafe_marker = || PythonEvidenceError::UnsafeEntry(marker.to_string_lossy().into_owned());
517    let (name, size) = std::str::from_utf8(&contents)
518        .ok()
519        .and_then(|text| text.split_once(' '))
520        .and_then(|(name, size)| Some((name.as_bytes(), size.parse::<usize>().ok()?)))
521        .ok_or_else(unsafe_marker)?;
522    if name.len() > 31
523        || name.first() != Some(&b'/')
524        || !name[1..]
525            .iter()
526            .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'.')
527    {
528        return Err(unsafe_marker());
529    }
530    shared_slot_bytes(name)?
531        // Created and killed before it was sized: nothing was written.
532        .filter(|bytes| !bytes.is_empty())
533        .map(|mut bytes| {
534            if bytes.len() < size {
535                return Err(PythonEvidenceError::InvalidTransport {
536                    file: slot_name.into(),
537                    reason: "a shared slot is smaller than its process sized it".into(),
538                });
539            }
540            bytes.truncate(size);
541            let partial = marker.with_file_name(format!("{slot_name}.partial"));
542            fs::write(&partial, bytes)
543                .and_then(|()| fs::rename(&partial, &slot))
544                .map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
545            Ok(slot)
546        })
547        .transpose()
548}
549
550/// Only the macOS runtime keeps slots in shared memory.
551#[cfg(target_os = "macos")]
552fn shared_slot_bytes(name: &[u8]) -> Result<Option<Vec<u8>>, PythonEvidenceError> {
553    use std::os::fd::FromRawFd;
554    let name = std::ffi::CString::new(name)
555        .map_err(|_| PythonEvidenceError::UnsafeEntry("<shared slot name>".into()))?;
556    // SAFETY: a NUL-terminated name; the descriptor is owned by `file` below.
557    let descriptor = unsafe { libc::shm_open(name.as_ptr(), libc::O_RDONLY) };
558    if descriptor < 0 {
559        let error = std::io::Error::last_os_error();
560        return if error.kind() == std::io::ErrorKind::NotFound {
561            Ok(None)
562        } else {
563            Err(PythonEvidenceError::Io(error.to_string()))
564        };
565    }
566    // SAFETY: shm_open returned a fresh descriptor this function owns.
567    let file = unsafe { File::from_raw_fd(descriptor) };
568    let length = file
569        .metadata()
570        .map_err(|error| PythonEvidenceError::Io(error.to_string()))?
571        .len();
572    let bytes = if length == 0 {
573        Vec::new()
574    } else {
575        // SAFETY: read-only; the process that wrote it has stopped measuring.
576        unsafe { MmapOptions::new().map(&file) }
577            .map_err(|error| PythonEvidenceError::Io(error.to_string()))?
578            .to_vec()
579    };
580    // SAFETY: the same NUL-terminated name.
581    unsafe { libc::shm_unlink(name.as_ptr()) };
582    Ok(Some(bytes))
583}
584
585#[cfg(not(target_os = "macos"))]
586fn shared_slot_bytes(_name: &[u8]) -> Result<Option<Vec<u8>>, PythonEvidenceError> {
587    Ok(None)
588}
589
590/// A slot, or None when it is gone by the time it is opened: taken back by a
591/// process that closed after the directory was listed, and so already
592/// harvested into its transport.
593fn map_slot(path: &Path) -> Result<Option<Mmap>, PythonEvidenceError> {
594    match File::open(path) {
595        Ok(file) => unsafe { MmapOptions::new().map(&file) }
596            .map(Some)
597            .map_err(|error| PythonEvidenceError::Io(error.to_string())),
598        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
599        Err(error) => Err(PythonEvidenceError::Io(error.to_string())),
600    }
601}
602
603fn map_evidence(path: &Path) -> Result<Mmap, PythonEvidenceError> {
604    let file = File::open(path).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
605    // The file is immutable from Supercov's perspective after the wrapped
606    // interpreter has exited. No mutable alias is created while this map is
607    // alive.
608    unsafe { MmapOptions::new().map(&file) }
609        .map_err(|error| PythonEvidenceError::Io(error.to_string()))
610}
611
612const SLOT_LAYOUT_NAME: &str = "layout.json";
613/// The widest decision the runtime gives a region, as `supercov_probes`
614/// declares it; a wider one records its vectors directly.
615const SLOT_MAX_REGION_WIDTH: usize = 6;
616
617/// What each byte of a slot stands for: `SlotLayout::header` bytes naming the
618/// context and the layout, one byte per obligation, then a region per decision.
619#[derive(Debug, Deserialize)]
620#[serde(rename_all = "camelCase", deny_unknown_fields)]
621struct SlotLayout {
622    #[allow(dead_code)]
623    version: u32,
624    digest: String,
625    header: usize,
626    bytes: usize,
627    ids: Vec<String>,
628    decisions: Vec<SlotDecision>,
629    /// A byte that stands for others besides its own: the probe of a
630    /// statement heading a function, loop body or one-condition branch is
631    /// the only store its entry, `entered` or outcome gets.
632    #[serde(default)]
633    implied: Vec<(usize, Vec<usize>)>,
634    #[serde(skip)]
635    implied_by: std::collections::HashMap<usize, Vec<usize>>,
636}
637
638#[derive(Debug, Deserialize)]
639#[serde(rename_all = "camelCase", deny_unknown_fields)]
640struct SlotDecision {
641    start: usize,
642    width: usize,
643    id: String,
644    outcome_true: String,
645    outcome_false: String,
646    logical: Vec<SlotLogical>,
647}
648
649#[derive(Debug, Deserialize)]
650#[serde(rename_all = "camelCase", deny_unknown_fields)]
651struct SlotLogical {
652    evaluated: String,
653    short_circuit: String,
654    previous_leaves: Vec<usize>,
655    operand_leaves: Vec<usize>,
656}
657
658impl SlotDecision {
659    /// A single condition has a byte per truth; a wider decision a byte per
660    /// evaluation mask and outcome, the mask one base-3 digit per condition.
661    fn region_bytes(&self) -> usize {
662        if self.width == 1 {
663            2
664        } else {
665            2 * 3_usize.pow(self.width as u32)
666        }
667    }
668
669    /// The vector a region byte stands for, as the evidence spells it.
670    fn vector(&self, offset: usize) -> (String, bool) {
671        if self.width == 1 {
672            let outcome = offset == 1;
673            return ((if outcome { "2" } else { "1" }).into(), outcome);
674        }
675        let mut mask = offset >> 1;
676        let digits = (0..self.width)
677            .map(|_| {
678                let digit = b"012"[mask % 3] as char;
679                mask /= 3;
680                digit
681            })
682            .collect();
683        (digits, offset & 1 == 1)
684    }
685
686    /// What the runtime's harvest adds for a vector: the outcome hit, and
687    /// each logical operator's alternative the vector decided.
688    fn implied(&self, digits: &str, outcome: bool, into: &mut Vec<String>) {
689        into.push(
690            if outcome {
691                &self.outcome_true
692            } else {
693                &self.outcome_false
694            }
695            .clone(),
696        );
697        let evaluated = |leaves: &[usize]| {
698            leaves.iter().any(|index| {
699                digits
700                    .as_bytes()
701                    .get(*index)
702                    .is_some_and(|digit| *digit != b'0')
703            })
704        };
705        for logical in &self.logical {
706            if evaluated(&logical.operand_leaves) {
707                into.push(logical.evaluated.clone());
708            } else if evaluated(&logical.previous_leaves) {
709                into.push(logical.short_circuit.clone());
710            }
711        }
712    }
713}
714
715impl SlotLayout {
716    fn read(path: &Path) -> Result<Self, PythonEvidenceError> {
717        let invalid = |reason: String| PythonEvidenceError::InvalidTransport {
718            file: SLOT_LAYOUT_NAME.into(),
719            reason,
720        };
721        let bytes = fs::read(path).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
722        let mut layout: Self =
723            serde_json::from_slice(&bytes).map_err(|error| invalid(error.to_string()))?;
724        if layout.digest.len() < 16 || !layout.digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
725            return Err(invalid("the layout digest is not hex".into()));
726        }
727        // Regions follow the obligations back to back, in slot order, and end
728        // where the slot does: a byte stands for exactly one thing.
729        let mut cursor = layout.header + layout.ids.len();
730        for decision in &layout.decisions {
731            if decision.start != cursor
732                || decision.width == 0
733                || decision.width > SLOT_MAX_REGION_WIDTH
734            {
735                return Err(invalid(format!(
736                    "decision {} has no valid region",
737                    decision.id
738                )));
739            }
740            if decision
741                .logical
742                .iter()
743                .flat_map(|logical| {
744                    logical
745                        .previous_leaves
746                        .iter()
747                        .chain(&logical.operand_leaves)
748                })
749                .any(|leaf| *leaf >= decision.width)
750            {
751                return Err(invalid(format!(
752                    "decision {} names a condition it lacks",
753                    decision.id
754                )));
755            }
756            cursor += decision.region_bytes();
757        }
758        if cursor != layout.bytes || layout.header < 16 {
759            return Err(invalid("the regions do not fill the slot".into()));
760        }
761        // A head is a statement's byte; what it implies is an obligation's
762        // byte or a region's, never another head.
763        let id_end = layout.header + layout.ids.len();
764        for (head, implied) in &layout.implied {
765            if *head < layout.header
766                || *head >= id_end
767                || implied
768                    .iter()
769                    .any(|byte| *byte < layout.header || *byte >= layout.bytes || *byte == *head)
770            {
771                return Err(invalid(format!(
772                    "byte {head} implies bytes outside the slot"
773                )));
774            }
775        }
776        layout.implied_by = layout.implied.iter().cloned().collect();
777        if layout
778            .implied_by
779            .values()
780            .flatten()
781            .any(|byte| layout.implied_by.contains_key(byte))
782        {
783            return Err(invalid("an implied byte implies others".into()));
784        }
785        Ok(layout)
786    }
787
788    fn tag(&self) -> Vec<u8> {
789        (0..8)
790            .map(|index| {
791                u8::from_str_radix(&self.digest[index * 2..index * 2 + 2], 16).unwrap_or(0)
792            })
793            .collect()
794    }
795
796    /// A slot's bytes as the records a harvest would have written from them.
797    fn records(&self, name: &str, contents: &[u8]) -> Result<Vec<Record>, PythonEvidenceError> {
798        let invalid = |reason: &str| PythonEvidenceError::InvalidTransport {
799            file: name.into(),
800            reason: reason.into(),
801        };
802        let tag = contents
803            .get(8..16)
804            .ok_or_else(|| invalid("slot header is missing"))?;
805        // Its process closed it after a final harvest: whatever a thread wrote
806        // after that belongs to no test.
807        if tag.iter().all(|byte| *byte == 0) {
808            return Ok(Vec::new());
809        }
810        if tag != self.tag().as_slice() {
811            return Err(invalid("slot was written for another layout"));
812        }
813        if contents.len() != self.bytes {
814            return Err(invalid("slot size does not match the layout"));
815        }
816        let context =
817            transport_u64(contents, 0).ok_or_else(|| invalid("slot context is missing"))?;
818        let mut decoded = Decoded::default();
819        for (index, byte) in contents.iter().enumerate().skip(self.header) {
820            match byte {
821                0 => continue,
822                1 => self.decode(index, &mut decoded),
823                _ => return Err(invalid("slot byte is neither 0 nor 1")),
824            }
825        }
826        Ok(decoded.into_records(context))
827    }
828
829    /// A harvest's runs as the records a slot holding those bytes reads as.
830    fn runs(&self, context: u64, runs: &[usize]) -> Result<Vec<Record>, &'static str> {
831        if !runs.len().is_multiple_of(2) {
832            return Err("slot runs must pair a first byte with a length");
833        }
834        let mut decoded = Decoded::default();
835        for run in runs.chunks_exact(2) {
836            let (start, length) = (run[0], run[1]);
837            let end = start
838                .checked_add(length)
839                .filter(|end| length > 0 && start >= self.header && *end <= self.bytes)
840                .ok_or("a slot run lies outside the layout")?;
841            for index in start..end {
842                self.decode(index, &mut decoded);
843            }
844        }
845        Ok(decoded.into_records(context))
846    }
847
848    /// What one set byte stands for, with what the layout says it implies.
849    fn decode(&self, index: usize, into: &mut Decoded) {
850        self.decode_one(index, into);
851        if let Some(implied) = self.implied_by.get(&index) {
852            for byte in implied {
853                self.decode_one(*byte, into);
854            }
855        }
856    }
857
858    /// What one byte stands for: an obligation, or a decision vector and
859    /// what the vector implies.
860    fn decode_one(&self, index: usize, into: &mut Decoded) {
861        let id_end = self.header + self.ids.len();
862        if index < id_end {
863            into.positions.push((index - self.header) as u32);
864            return;
865        }
866        let decision = &self.decisions[self
867            .decisions
868            .partition_point(|decision| decision.start <= index)
869            - 1];
870        let (digits, outcome) = decision.vector(index - decision.start);
871        decision.implied(&digits, outcome, &mut into.ids);
872        into.vectors
873            .push((decision.id.clone(), digits, u8::from(outcome)));
874    }
875}
876
877#[derive(Default)]
878struct Decoded {
879    ids: Vec<String>,
880    positions: Vec<u32>,
881    vectors: Vec<(String, String, u8)>,
882}
883
884impl Decoded {
885    fn into_records(self, context: u64) -> Vec<Record> {
886        let mut records = Vec::new();
887        if !self.positions.is_empty() {
888            records.push(Record::SlotHits {
889                ctx: context,
890                positions: self.positions,
891            });
892        }
893        if !self.ids.is_empty() {
894            records.push(Record::Hits {
895                ctx: context,
896                ids: self.ids,
897            });
898        }
899        if !self.vectors.is_empty() {
900            records.push(Record::Decs {
901                ctx: context,
902                v: self.vectors,
903            });
904        }
905        records
906    }
907}
908
909/// `items.iter().map(work)`, spread over the machine's cores, in order.
910fn parallel_map<T: Sync, R: Send>(items: &[T], work: impl Fn(&T) -> R + Sync) -> Vec<R> {
911    let threads = std::thread::available_parallelism()
912        .map_or(1, std::num::NonZeroUsize::get)
913        .min(items.len().div_ceil(64).max(1));
914    if threads <= 1 {
915        return items.iter().map(work).collect();
916    }
917    let next = std::sync::atomic::AtomicUsize::new(0);
918    let mut done = std::thread::scope(|scope| {
919        let workers = (0..threads)
920            .map(|_| {
921                scope.spawn(|| {
922                    let mut done = Vec::new();
923                    loop {
924                        let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
925                        let Some(item) = items.get(index) else {
926                            break done;
927                        };
928                        done.push((index, work(item)));
929                    }
930                })
931            })
932            .collect::<Vec<_>>();
933        workers
934            .into_iter()
935            .flat_map(|worker| {
936                worker
937                    .join()
938                    .unwrap_or_else(|panic| std::panic::resume_unwind(panic))
939            })
940            .collect::<Vec<_>>()
941    });
942    done.sort_unstable_by_key(|(index, _)| *index);
943    done.into_iter().map(|(_, result)| result).collect()
944}
945
946fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
947    bytes
948        .get(offset..offset + 4)
949        .and_then(|value| value.try_into().ok())
950        .map(u32::from_le_bytes)
951}
952
953fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
954    bytes
955        .get(offset..offset + 8)
956        .and_then(|value| value.try_into().ok())
957        .map(u64::from_le_bytes)
958}
959
960/// A field of a header whose length was checked: it cannot run past it.
961fn checked_u32(bytes: &[u8], offset: usize) -> u32 {
962    transport_u32(bytes, offset).expect("the header was checked to hold this field")
963}
964
965fn checked_u64(bytes: &[u8], offset: usize) -> u64 {
966    transport_u64(bytes, offset).expect("the header was checked to hold this field")
967}
968
969fn transport_checksum(payload: &[u8], version: u32) -> u32 {
970    if version == TRANSPORT_VERSION_FNV {
971        return payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
972            (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
973        });
974    }
975    crc32fast::hash(payload)
976}
977
978fn align_transport(value: usize) -> Option<usize> {
979    value.checked_add(7).map(|value| value & !7)
980}
981
982/// A transport's records, in order, with its slots' leftovers after them:
983/// what reading one process's evidence needs nothing else of the run for,
984/// so many are parsed at once.
985struct ParsedTransport {
986    pid: u64,
987    records: Vec<(usize, Record)>,
988}
989
990fn parse_transport(
991    name: &str,
992    contents: &[u8],
993    slots: &[(String, Mmap)],
994    layout: Option<&SlotLayout>,
995) -> Result<ParsedTransport, PythonEvidenceError> {
996    let invalid_transport = |reason: &str| PythonEvidenceError::InvalidTransport {
997        file: name.into(),
998        reason: reason.into(),
999    };
1000    let version = transport_u32(contents, 8);
1001    if contents.len() < TRANSPORT_HEADER_SIZE
1002        || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
1003        || !matches!(version, Some(TRANSPORT_VERSION | TRANSPORT_VERSION_FNV))
1004        || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
1005    {
1006        return Err(invalid_transport("header or version does not match"));
1007    }
1008    let version = version.unwrap_or(TRANSPORT_VERSION);
1009    let declared_capacity = checked_u64(contents, 16);
1010    if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
1011    {
1012        return Err(invalid_transport(
1013            "declared capacity is outside the mapped file",
1014        ));
1015    }
1016    let dropped = checked_u64(contents, 24);
1017    if dropped != 0 {
1018        return Err(PythonEvidenceError::DroppedRecords {
1019            file: name.into(),
1020            count: dropped,
1021        });
1022    }
1023    let transport_pid = checked_u64(contents, 32);
1024    if transport_pid == 0 {
1025        return Err(invalid_transport("process id is missing"));
1026    }
1027    let mut records = Vec::new();
1028    let mut cursor = TRANSPORT_HEADER_SIZE;
1029    let mut record_index = 0;
1030    while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
1031        let commit = contents[cursor];
1032        if commit == 0 {
1033            // Payload bytes can exist after a killed writer, but an absent
1034            // commit byte makes that frame and every later zeroed frame inert.
1035            break;
1036        }
1037        record_index += 1;
1038        let line_number = record_index;
1039        let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
1040            file: name.into(),
1041            line: line_number,
1042            reason: reason.into(),
1043        };
1044        if commit != 1
1045            || contents[cursor + 1..cursor + 4] != [0, 0, 0]
1046            || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
1047        {
1048            return Err(invalid("commit marker or reserved bytes are invalid"));
1049        }
1050        let length = checked_u32(contents, cursor + 4) as usize;
1051        if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
1052            return Err(invalid("payload length is outside the transport bound"));
1053        }
1054        let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
1055        let payload_end = payload_start
1056            .checked_add(length)
1057            .filter(|end| *end <= contents.len())
1058            .ok_or_else(|| invalid("payload extends past the mapped file"))?;
1059        let next_cursor = align_transport(payload_end)
1060            .filter(|end| *end <= contents.len())
1061            .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
1062        if contents[payload_end..next_cursor]
1063            .iter()
1064            .any(|byte| *byte != 0)
1065        {
1066            return Err(invalid("frame padding is not zero"));
1067        }
1068        let payload = &contents[payload_start..payload_end];
1069        let expected_checksum = checked_u32(contents, cursor + 8);
1070        if transport_checksum(payload, version) != expected_checksum {
1071            return Err(invalid("payload checksum does not match"));
1072        }
1073        let record: Record = serde_json::from_slice(payload).map_err(|error| {
1074            PythonEvidenceError::InvalidRecord {
1075                file: name.into(),
1076                line: line_number,
1077                reason: error.to_string(),
1078            }
1079        })?;
1080        if let Record::Runs { ctx, r } = record {
1081            let layout =
1082                layout.ok_or_else(|| invalid("slot runs arrived without the run's slot layout"))?;
1083            for record in layout.runs(ctx, &r).map_err(invalid)? {
1084                records.push((line_number, record));
1085            }
1086        } else {
1087            records.push((line_number, record));
1088        }
1089        cursor = next_cursor;
1090    }
1091    // What the process's slots still held when it ended: after every record
1092    // of its transport, as the harvest it never reached would have been.
1093    for (slot, slot_contents) in slots {
1094        let layout = layout.ok_or_else(|| PythonEvidenceError::InvalidTransport {
1095            file: slot.clone(),
1096            reason: "a slot was left without the run's slot layout".into(),
1097        })?;
1098        for record in layout.records(slot, slot_contents)? {
1099            record_index += 1;
1100            records.push((record_index, record));
1101        }
1102    }
1103    Ok(ParsedTransport {
1104        pid: transport_pid,
1105        records,
1106    })
1107}
1108
1109/// A parsed transport's records applied to the run's evidence, in order.
1110fn apply_transport(
1111    name: &str,
1112    parsed: ParsedTransport,
1113    run_id: &str,
1114    evidence: &mut Evidence,
1115) -> Result<(), PythonEvidenceError> {
1116    let transport_pid = parsed.pid;
1117    let mut contexts = BTreeMap::<u64, Identity>::new();
1118    // What each call phase recorded so far, kept until its first assertion
1119    // marker moves it to the phase's assertion identity.
1120    let mut before_assertion = BTreeMap::<u64, Observations>::new();
1121    let mut process_worker: Option<String> = None;
1122    let mut apply = |line_number: usize, record: Record| -> Result<(), PythonEvidenceError> {
1123        let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
1124            file: name.into(),
1125            line: line_number,
1126            reason: reason.into(),
1127        };
1128        match record {
1129            Record::Process {
1130                v,
1131                run,
1132                pid,
1133                worker,
1134                python,
1135                ..
1136            } => {
1137                if v != PYTHON_EVIDENCE_VERSION {
1138                    return Err(PythonEvidenceError::UnsupportedVersion(v));
1139                }
1140                if run != run_id {
1141                    return Err(PythonEvidenceError::RunMismatch {
1142                        expected: run_id.into(),
1143                        actual: run,
1144                    });
1145                }
1146                if pid != transport_pid {
1147                    return Err(invalid("process record does not match the transport owner"));
1148                }
1149                let supported = python
1150                    .split('.')
1151                    .take(2)
1152                    .map(|part| part.parse::<u32>().ok())
1153                    .collect::<Option<Vec<_>>>()
1154                    // The runtime measures 3.9 and newer and refuses to
1155                    // install on anything older.
1156                    .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 9));
1157                if !supported {
1158                    return Err(PythonEvidenceError::UnsupportedPython(python));
1159                }
1160                evidence.interpreters += 1;
1161                evidence.python_versions.insert(python);
1162                process_worker = Some(worker);
1163            }
1164            Record::Worker { worker } => process_worker = Some(worker),
1165            Record::Phase {
1166                ctx,
1167                at,
1168                worker,
1169                test,
1170                retry,
1171                phase,
1172            } => {
1173                if ctx == 0 {
1174                    return Err(invalid("phase context 0 is reserved for background"));
1175                }
1176                if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
1177                    return Err(invalid("unknown pytest phase"));
1178                }
1179                if test.trim().is_empty() || worker.trim().is_empty() {
1180                    return Err(invalid("phase identity must name a worker and test"));
1181                }
1182                // A request is numbered within its process, which its
1183                // transport names.
1184                let worker = if worker == REQUEST_WORKER {
1185                    format!("{REQUEST_WORKER}:{name}")
1186                } else {
1187                    worker
1188                };
1189                let identity = Identity {
1190                    worker,
1191                    test,
1192                    retry,
1193                    phase,
1194                };
1195                if identity.worker.starts_with(REQUEST_WORKER) {
1196                    evidence.requests.push((
1197                        identity.clone(),
1198                        at,
1199                        line_number,
1200                        name.to_owned(),
1201                        process_worker.clone().unwrap_or_else(|| "main".into()),
1202                    ));
1203                } else {
1204                    if identity.phase == "call" {
1205                        before_assertion.insert(ctx, Observations::default());
1206                    }
1207                    let attempt = (
1208                        identity.worker.clone(),
1209                        identity.test.clone(),
1210                        identity.retry,
1211                    );
1212                    evidence
1213                        .phase_starts
1214                        .push((name.to_owned(), line_number, attempt.clone()));
1215                    evidence
1216                        .served_by
1217                        .entry(name.to_owned())
1218                        .or_default()
1219                        .insert(attempt);
1220                }
1221                contexts.insert(ctx, identity);
1222            }
1223            Record::Outcome {
1224                worker,
1225                test,
1226                retry,
1227                phase,
1228                outcome,
1229                xfail,
1230                runner,
1231                file,
1232                tags,
1233            } => {
1234                if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
1235                    return Err(invalid("unknown test outcome phase"));
1236                }
1237                if !matches!(
1238                    outcome.as_str(),
1239                    "passed" | "failed" | "skipped" | "rerun" | "error"
1240                ) {
1241                    return Err(invalid("unknown test outcome"));
1242                }
1243                if !matches!(runner.as_str(), PYTEST_RUNNER | UNITTEST_RUNNER) {
1244                    return Err(invalid("unknown Python test runner"));
1245                }
1246                let key = (worker, test, retry);
1247                if let Some(previous) = evidence.runners.get(&key)
1248                    && previous != &runner
1249                {
1250                    return Err(invalid("one attempt was reported by two runners"));
1251                }
1252                evidence.runners.insert(key.clone(), runner);
1253                if let Some(file) = file.filter(|path| !path.is_empty()) {
1254                    evidence.test_files.entry(key.clone()).or_insert(file);
1255                }
1256                if !tags.is_empty() {
1257                    let known = evidence.test_tags.entry(key.clone()).or_default();
1258                    for tag in tags {
1259                        if !known.contains(&tag) {
1260                            known.push(tag);
1261                        }
1262                    }
1263                }
1264                fold_outcome(
1265                    evidence.outcomes.entry(key).or_default(),
1266                    phase,
1267                    outcome,
1268                    xfail,
1269                );
1270            }
1271            Record::Hit { ctx, id } => {
1272                if let Some(before) = before_assertion.get_mut(&ctx) {
1273                    before.hits.insert(id.clone());
1274                }
1275                observations(
1276                    evidence,
1277                    &contexts,
1278                    process_worker.as_deref(),
1279                    ctx,
1280                    name,
1281                    line_number,
1282                )?
1283                .hits
1284                .insert(id);
1285            }
1286            // An empty record names no phase evidence: it must not make one.
1287            Record::Hits { ids, .. } if ids.is_empty() => {}
1288            Record::SlotHits { positions, .. } if positions.is_empty() => {}
1289            Record::SlotHits { ctx, positions } => {
1290                if let Some(before) = before_assertion.get_mut(&ctx) {
1291                    before.slot_hits.extend(&positions);
1292                }
1293                observations(
1294                    evidence,
1295                    &contexts,
1296                    process_worker.as_deref(),
1297                    ctx,
1298                    name,
1299                    line_number,
1300                )?
1301                .slot_hits
1302                .extend(positions);
1303            }
1304            Record::Decs { v, .. } if v.is_empty() => {}
1305            Record::Hits { ctx, ids } => {
1306                if let Some(before) = before_assertion.get_mut(&ctx) {
1307                    before.hits.extend(ids.iter().cloned());
1308                }
1309                // One lookup of the phase for the record, not one per hit.
1310                observations(
1311                    evidence,
1312                    &contexts,
1313                    process_worker.as_deref(),
1314                    ctx,
1315                    name,
1316                    line_number,
1317                )?
1318                .hits
1319                .extend(ids);
1320            }
1321            Record::Dec { ctx, id, v, o } => {
1322                if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
1323                    return Err(invalid("decision vector digits must be 0, 1 or 2"));
1324                }
1325                if o > 1 {
1326                    return Err(invalid("decision outcome must be 0 or 1"));
1327                }
1328                let values = v
1329                    .bytes()
1330                    .map(|digit| match digit {
1331                        b'0' => None,
1332                        b'1' => Some(false),
1333                        _ => Some(true),
1334                    })
1335                    .collect::<Vec<_>>();
1336                if let Some(before) = before_assertion.get_mut(&ctx) {
1337                    before
1338                        .vectors
1339                        .entry(id.clone())
1340                        .or_default()
1341                        .insert((values.clone(), o == 1));
1342                }
1343                observations(
1344                    evidence,
1345                    &contexts,
1346                    process_worker.as_deref(),
1347                    ctx,
1348                    name,
1349                    line_number,
1350                )?
1351                .vectors
1352                .entry(id)
1353                .or_default()
1354                .insert((values, o == 1));
1355            }
1356            Record::Decs { ctx, v: vectors } => {
1357                let mut before = before_assertion.get_mut(&ctx);
1358                let observed = observations(
1359                    evidence,
1360                    &contexts,
1361                    process_worker.as_deref(),
1362                    ctx,
1363                    name,
1364                    line_number,
1365                )?;
1366                for (id, v, o) in vectors {
1367                    if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
1368                        return Err(invalid("decision vector digits must be 0, 1 or 2"));
1369                    }
1370                    if o > 1 {
1371                        return Err(invalid("decision outcome must be 0 or 1"));
1372                    }
1373                    let values = v
1374                        .bytes()
1375                        .map(|digit| match digit {
1376                            b'0' => None,
1377                            b'1' => Some(false),
1378                            _ => Some(true),
1379                        })
1380                        .collect::<Vec<_>>();
1381                    if let Some(before) = before.as_mut() {
1382                        before
1383                            .vectors
1384                            .entry(id.clone())
1385                            .or_default()
1386                            .insert((values.clone(), o == 1));
1387                    }
1388                    observed
1389                        .vectors
1390                        .entry(id)
1391                        .or_default()
1392                        .insert((values, o == 1));
1393                }
1394            }
1395            Record::Assert { ctx } => {
1396                // Only the first marker of a call phase moves anything; a
1397                // later one, or one outside a call phase, is inert.
1398                if let Some(before) = before_assertion.remove(&ctx) {
1399                    let identity =
1400                        contexts
1401                            .get(&ctx)
1402                            .ok_or(PythonEvidenceError::UnknownContext {
1403                                file: name.into(),
1404                                line: line_number,
1405                                context: ctx,
1406                            })?;
1407                    let asserted = evidence
1408                        .per_identity
1409                        .entry(Identity {
1410                            phase: "assertion".into(),
1411                            ..identity.clone()
1412                        })
1413                        .or_default();
1414                    asserted.hits.extend(before.hits);
1415                    asserted.slot_hits.extend(before.slot_hits);
1416                    for (id, vectors) in before.vectors {
1417                        asserted.vectors.entry(id).or_default().extend(vectors);
1418                    }
1419                }
1420            }
1421            Record::Limitation {
1422                id,
1423                reason,
1424                file,
1425                obligation,
1426            } => {
1427                // Thousands of interpreters may report the same limitation.
1428                // Deduplicate before expanding file-wide obligations, while
1429                // retaining each affected file and obligation independently.
1430                evidence
1431                    .limitations
1432                    .entry((id.clone(), file.clone(), obligation.clone()))
1433                    .or_insert(RuntimeLimitation {
1434                        id,
1435                        reason,
1436                        file,
1437                        obligation,
1438                    });
1439            }
1440            // Expanded through the layout before it is applied.
1441            Record::Runs { .. } => return Err(invalid("slot runs were not expanded")),
1442            Record::Exit { .. } => {}
1443        }
1444        Ok(())
1445    };
1446    for (line_number, record) in parsed.records {
1447        apply(line_number, record)?;
1448    }
1449    Ok(())
1450}
1451
1452fn observations<'a>(
1453    evidence: &'a mut Evidence,
1454    contexts: &BTreeMap<u64, Identity>,
1455    process_worker: Option<&str>,
1456    context: u64,
1457    file: &str,
1458    line: usize,
1459) -> Result<&'a mut Observations, PythonEvidenceError> {
1460    if context == 0 {
1461        return Ok(evidence
1462            .background
1463            .entry(process_worker.unwrap_or("main").to_owned())
1464            .or_default());
1465    }
1466    let identity = contexts
1467        .get(&context)
1468        .ok_or(PythonEvidenceError::UnknownContext {
1469            file: file.into(),
1470            line,
1471            context,
1472        })?;
1473    Ok(evidence.per_identity.entry(identity.clone()).or_default())
1474}
1475
1476/// Python is observed through probes compiled into each measured module as it
1477/// is imported, from the obligations the plan derived from the source.
1478pub fn python_coverage_model() -> CoverageModelDeclaration {
1479    CoverageModelDeclaration {
1480        language: "python".into(),
1481        variant: "python-owned-probes".into(),
1482        name: PYTHON_FRONTEND_VERSION.into(),
1483        completeness_meaning: "Every statement, function, decision vector, loop, short-circuit, match and exception-flow obligation Supercov derived from the source was observed through probes compiled into the code at import, with exact test identity; the declared runtime limitations remain separate.".into(),
1484        measured: vec![
1485            "executable statements, each by a probe placed before it".into(),
1486            "function and lambda entry".into(),
1487            "boolean decision vectors with masking MC/DC, each condition's value taken as the program evaluated it".into(),
1488            "for-loop and comprehension zero-versus-entered iteration".into(),
1489            "logical and/or short-circuit alternatives".into(),
1490            "match case selection and guards".into(),
1491            "try completion, handler selection and exception propagation".into(),
1492            "pytest and unittest worker, test, retry and setup/call/teardown phase identity".into(),
1493            "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
1494        ],
1495        not_measured: vec![
1496            "causal linkage to individual actions, or to any assertion after a test's first".into(),
1497            "code compiled from strings at runtime".into(),
1498            "measured modules imported before Supercov installed, or compiled past the import system; the run names each as a limitation".into(),
1499            "causal test context for raw _thread or native-extension-created threads".into(),
1500            "child coverage outside subprocess.Popen and multiprocessing adapters".into(),
1501            "all input values, semantic partitions, paths, or concurrency interleavings".into(),
1502            "mutation score or assertion fault-detection strength".into(),
1503        ],
1504    }
1505}
1506
1507fn phase_id(run: &str, identity: &Identity) -> String {
1508    stable_id(
1509        "python-phase",
1510        &[
1511            run,
1512            &identity.worker,
1513            &identity.test,
1514            &identity.retry.to_string(),
1515            &identity.phase,
1516        ],
1517    )
1518}
1519
1520fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
1521    ExecutionScope {
1522        version: 1,
1523        run_id: run.into(),
1524        worker_id: worker.into(),
1525        test_id: test.into(),
1526        test_key: stable_id("python-test", &[worker, test]),
1527        retry,
1528        attempt_id: stable_id("python-attempt", &[run, worker, test, &retry.to_string()]),
1529    }
1530}
1531
1532struct ManifestIndex<'a> {
1533    points: crate::interned::FastSet<&'a str>,
1534    alternatives: crate::interned::FastSet<&'a str>,
1535    decisions: crate::interned::FastMap<&'a str, &'a DecisionMeta>,
1536    lines: BTreeMap<&'a str, (&'a str, usize)>,
1537    files: BTreeMap<&'a str, Vec<&'a str>>,
1538}
1539
1540impl<'a> ManifestIndex<'a> {
1541    fn new(manifest: &'a CoverageManifest) -> Self {
1542        let mut lines = BTreeMap::new();
1543        for point in &manifest.points {
1544            lines.insert(point.id.as_str(), (point.file.as_str(), point.line));
1545        }
1546        for decision in &manifest.decisions {
1547            lines.insert(
1548                decision.id.as_str(),
1549                (decision.file.as_str(), decision.line),
1550            );
1551        }
1552        for branch in &manifest.branches {
1553            lines.insert(branch.id.as_str(), (branch.file.as_str(), branch.line));
1554        }
1555        let mut files = BTreeMap::<&str, Vec<&str>>::new();
1556        for (id, (file, _)) in &lines {
1557            files.entry(*file).or_default().push(*id);
1558        }
1559        Self {
1560            points: manifest
1561                .points
1562                .iter()
1563                .map(|point| point.id.as_str())
1564                .collect(),
1565            alternatives: manifest
1566                .branches
1567                .iter()
1568                .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
1569                .collect(),
1570            decisions: manifest
1571                .decisions
1572                .iter()
1573                .map(|decision| (decision.id.as_str(), decision))
1574                .collect(),
1575            lines,
1576            files,
1577        }
1578    }
1579}
1580
1581fn snapshot(
1582    index: &ManifestIndex<'_>,
1583    observations: &Observations,
1584    slot_ids: &[String],
1585    phase: &str,
1586) -> Result<RuntimeSnapshot, PythonEvidenceError> {
1587    let mut positions = observations.slot_hits.clone();
1588    positions.sort_unstable();
1589    positions.dedup();
1590    let mut hits = BTreeSet::new();
1591    for id in observations.hits.iter().map(String::as_str).chain(
1592        positions
1593            .iter()
1594            .map(|position| slot_ids[*position as usize].as_str()),
1595    ) {
1596        if !index.points.contains(id) && !index.alternatives.contains(id) {
1597            return Err(PythonEvidenceError::UnknownObligation(id.to_owned()));
1598        }
1599        hits.insert(id);
1600    }
1601    let hits = hits.into_iter().map(str::to_owned).collect::<BTreeSet<_>>();
1602    let mut decisions = Vec::new();
1603    for (id, vectors) in &observations.vectors {
1604        let Some(meta) = index.decisions.get(id.as_str()) else {
1605            return Err(PythonEvidenceError::UnknownObligation(id.clone()));
1606        };
1607        let mut observed = Vec::new();
1608        for (values, outcome) in vectors {
1609            if values.len() != meta.conditions.len() {
1610                return Err(PythonEvidenceError::InvalidVector {
1611                    id: id.clone(),
1612                    expected: meta.conditions.len(),
1613                    actual: values.len(),
1614                });
1615            }
1616            observed.push(McdcVector {
1617                values: values.clone(),
1618                outcome: *outcome,
1619            });
1620        }
1621        decisions.push(DecisionSnapshot {
1622            meta: (*meta).clone(),
1623            vectors: observed,
1624        });
1625    }
1626    // One snapshot per phase: everything in it is that phase's, which the
1627    // snapshot says once rather than with an event per hit and vector.
1628    Ok(RuntimeSnapshot {
1629        decisions,
1630        hits: hits.into_iter().collect(),
1631        events: Vec::new(),
1632        logicals: Vec::new(),
1633        phase_id: Some(phase.into()),
1634    })
1635}
1636
1637/// One phase, however many times its runner reported it. pytest-subtests
1638/// reports every subtest under the same node id and `call` phase, so an
1639/// attempt carries several outcomes for the identity a phase id is derived
1640/// from; each became a phase of its own with the same id, and the run was
1641/// refused whole. The outcomes fold the way an attempt's do -- a failure
1642/// anywhere is the phase's, then a skip -- because that is what the phase
1643/// ended up doing, and the coverage recorded under that identity is one
1644/// phase's however the runner chose to narrate it.
1645fn fold_outcome(
1646    outcomes: &mut Vec<(String, String, bool)>,
1647    phase: String,
1648    outcome: String,
1649    xfail: bool,
1650) {
1651    let Some(reported) = outcomes.iter_mut().find(|(name, _, _)| *name == phase) else {
1652        outcomes.push((phase, outcome, xfail));
1653        return;
1654    };
1655    if outcome_severity(&outcome) > outcome_severity(&reported.1) {
1656        reported.1 = outcome;
1657    }
1658    reported.2 |= xfail;
1659}
1660
1661/// The order `attempt_status` reads outcomes in, as one value.
1662fn outcome_severity(outcome: &str) -> u8 {
1663    match outcome {
1664        "failed" | "rerun" | "error" => 2,
1665        "skipped" => 1,
1666        _ => 0,
1667    }
1668}
1669
1670fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
1671    if outcomes
1672        .iter()
1673        .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
1674    {
1675        "failed"
1676    } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
1677        "skipped"
1678    } else {
1679        "passed"
1680    }
1681    .into()
1682}
1683
1684#[derive(Debug, Clone, PartialEq)]
1685pub struct PythonFrontendRun {
1686    pub declaration: FrontendRunDeclaration,
1687    pub request: CoverageReportRequest,
1688    pub tests: usize,
1689    pub interpreters: usize,
1690    pub python_versions: Vec<String>,
1691}
1692
1693impl PythonFrontendRun {
1694    pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
1695        let model = PersistedCoverageModel::from_declaration(
1696            self.request
1697                .coverage_model
1698                .as_ref()
1699                .expect("Python frontend always declares a coverage model"),
1700        )
1701        .expect("Python coverage model is contract-valid");
1702        let mut entries = vec![
1703            EvidenceArchiveEntry {
1704                path: "coverage-model.json".into(),
1705                contents: serde_json::to_vec(&model)?,
1706            },
1707            EvidenceArchiveEntry {
1708                path: "frontend.json".into(),
1709                contents: serde_json::to_vec(&self.declaration)?,
1710            },
1711            EvidenceArchiveEntry {
1712                path: "manifest.json".into(),
1713                contents: serde_json::to_vec(&self.request.manifest)?,
1714            },
1715        ];
1716        for (index, result) in self.request.raw_results.iter().enumerate() {
1717            entries.push(EvidenceArchiveEntry {
1718                path: format!("results/{index:08}/mcdc.json"),
1719                contents: serde_json::to_vec(result)?,
1720            });
1721        }
1722        Ok(entries)
1723    }
1724}
1725
1726/// Join the runtime's evidence directory with the ahead-of-run manifest into
1727/// a protocol-conformant frontend run.
1728pub fn build_python_frontend_run(
1729    manifest: &CoverageManifest,
1730    evidence_directory: &Path,
1731    run_id: &str,
1732    generated_at: &str,
1733    test_exit_code: i32,
1734    root: &Path,
1735) -> Result<PythonFrontendRun, PythonEvidenceError> {
1736    let mut evidence = read_evidence_directory(evidence_directory, run_id)?;
1737    // A server the suite runs in its own process -- a live server a browser
1738    // or the test itself calls -- numbers the requests no test named: each
1739    // is the suite's test running then in that process, if only one was.
1740    let order = crate::served_tests::InProcess::new(&evidence.phase_starts);
1741    let by_time = credit_requests_by_time(&mut evidence, |_, transport, _, seq| {
1742        order.owner(transport, seq).cloned()
1743    });
1744    let mut run = build_from_evidence(
1745        evidence,
1746        manifest,
1747        run_id,
1748        generated_at,
1749        test_exit_code,
1750        root,
1751    )?;
1752    mark_by_time(&mut run, &by_time);
1753    Ok(run)
1754}
1755
1756pub use crate::served_tests::ServedTest;
1757
1758/// The Python run of a server a browser suite drove: the server's evidence,
1759/// with each browser test's outcome as its runner reported it, under the
1760/// names that runner gave them.
1761pub fn build_python_server_run(
1762    manifest: &CoverageManifest,
1763    evidence_directory: &Path,
1764    run_id: &str,
1765    generated_at: &str,
1766    test_exit_code: i32,
1767    root: &Path,
1768    served: &[ServedTest],
1769) -> Result<PythonFrontendRun, PythonEvidenceError> {
1770    let mut evidence = read_evidence_directory(evidence_directory, run_id)?;
1771    let mut by_attempt = BTreeMap::new();
1772    for test in served {
1773        let key = (test.worker.clone(), test.test_id.clone(), test.retry);
1774        let outcome = match test.status.as_str() {
1775            "passed" => "passed",
1776            "skipped" => "skipped",
1777            _ => "failed",
1778        };
1779        evidence.outcomes.entry(key.clone()).or_default().push((
1780            "call".into(),
1781            outcome.into(),
1782            test.expected_status == "failed",
1783        ));
1784        evidence
1785            .runners
1786            .insert(key.clone(), test.provenance.runner.clone());
1787        by_attempt.insert(key, test);
1788    }
1789    let by_time = credit_requests_by_time(&mut evidence, |evidence, transport, at, _| {
1790        crate::served_tests::owner(served, evidence.served_by.get(transport), at)
1791            .map(|test| (test.worker.clone(), test.test_id.clone(), test.retry))
1792    });
1793    let mut run = build_from_evidence(
1794        evidence,
1795        manifest,
1796        run_id,
1797        generated_at,
1798        test_exit_code,
1799        root,
1800    )?;
1801    for raw in &mut run.request.raw_results {
1802        let Some(test) = raw.scope.as_ref().and_then(|scope| {
1803            by_attempt.get(&(
1804                scope.worker_id.clone(),
1805                raw.test.clone(),
1806                raw.retry.unwrap_or(0),
1807            ))
1808        }) else {
1809            continue;
1810        };
1811        raw.test = test.name.clone();
1812        raw.title = Some(test.title.clone());
1813        raw.test_file = Some(test.file.clone());
1814        raw.status = Some(test.status.clone());
1815        raw.expected_status = Some(test.expected_status.clone());
1816        raw.provenance = test.provenance.clone();
1817        if by_time.contains(&(test.worker.clone(), test.test_id.clone(), test.retry)) {
1818            raw.attribution = crate::coverage_report::ATTRIBUTION_BY_TIME.into();
1819        }
1820    }
1821    run.tests = run
1822        .request
1823        .raw_results
1824        .iter()
1825        .filter(|raw| raw.role == "test")
1826        .map(|raw| raw.test.as_str())
1827        .collect::<BTreeSet<_>>()
1828        .len();
1829    Ok(run)
1830}
1831
1832/// Say of each attempt credited with a request by time that it was.
1833fn mark_by_time(run: &mut PythonFrontendRun, by_time: &BTreeSet<(String, String, usize)>) {
1834    for raw in &mut run.request.raw_results {
1835        let Some(scope) = &raw.scope else { continue };
1836        let key = (
1837            scope.worker_id.clone(),
1838            scope.test_id.clone(),
1839            raw.retry.unwrap_or(0),
1840        );
1841        if by_time.contains(&key) && raw.attribution == crate::coverage_report::ATTRIBUTION_EXACT {
1842            raw.attribution = crate::coverage_report::ATTRIBUTION_BY_TIME.into();
1843        }
1844    }
1845}
1846
1847/// Give each request that named no test to the test attempt `owner` names
1848/// for it -- from its transport, when it came and where in its process's
1849/// records -- or, where none, to its process's background. Returns the
1850/// attempts credited so.
1851fn credit_requests_by_time(
1852    evidence: &mut Evidence,
1853    owner: impl Fn(&Evidence, &str, i64, usize) -> Option<(String, String, usize)>,
1854) -> BTreeSet<(String, String, usize)> {
1855    let requests = std::mem::take(&mut evidence.requests);
1856    let owners = requests
1857        .iter()
1858        .map(|(_, at, seq, transport, _)| owner(evidence, transport, *at, *seq))
1859        .collect::<Vec<_>>();
1860    let mut credited = BTreeSet::new();
1861    for ((identity, _, _, _, process_worker), owner) in requests.into_iter().zip(owners) {
1862        let Some(observations) = evidence.per_identity.remove(&identity) else {
1863            continue;
1864        };
1865        let into = match owner {
1866            Some((worker, test, retry)) => {
1867                credited.insert((worker.clone(), test.clone(), retry));
1868                evidence
1869                    .per_identity
1870                    .entry(Identity {
1871                        worker,
1872                        test,
1873                        retry,
1874                        phase: "call".into(),
1875                    })
1876                    .or_default()
1877            }
1878            None => evidence.background.entry(process_worker).or_default(),
1879        };
1880        into.hits.extend(observations.hits);
1881        into.slot_hits.extend(observations.slot_hits);
1882        for (id, vectors) in observations.vectors {
1883            into.vectors.entry(id).or_default().extend(vectors);
1884        }
1885    }
1886    credited
1887}
1888
1889fn build_from_evidence(
1890    evidence: Evidence,
1891    manifest: &CoverageManifest,
1892    run_id: &str,
1893    generated_at: &str,
1894    test_exit_code: i32,
1895    root: &Path,
1896) -> Result<PythonFrontendRun, PythonEvidenceError> {
1897    if evidence.interpreters == 0 {
1898        return Err(PythonEvidenceError::NoInterpreter);
1899    }
1900    if evidence.outcomes.is_empty() {
1901        return Err(PythonEvidenceError::NoTests);
1902    }
1903    let Evidence {
1904        interpreters,
1905        python_versions,
1906        per_identity,
1907        background,
1908        outcomes,
1909        runners,
1910        test_files,
1911        test_tags,
1912        limitations,
1913        slot_ids,
1914        ..
1915    } = evidence;
1916    let mut manifest = manifest.clone();
1917    let index = ManifestIndex::new(&manifest);
1918
1919    let mut raw_results = Vec::new();
1920    let mut observed_runners = BTreeSet::new();
1921    let mut identities_by_attempt =
1922        BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1923    for (identity, observations) in &per_identity {
1924        identities_by_attempt
1925            .entry((
1926                identity.worker.clone(),
1927                identity.test.clone(),
1928                identity.retry,
1929            ))
1930            .or_default()
1931            .push((identity, observations));
1932    }
1933    // Each phase's snapshot stands alone: built side by side, taken in the
1934    // order below, and any error raised where the phase is reached.
1935    let phases_observed = per_identity.iter().collect::<Vec<_>>();
1936    let mut snapshots = phases_observed
1937        .iter()
1938        .map(|(identity, _)| *identity)
1939        .zip(parallel_map(
1940            &phases_observed,
1941            |(identity, observations)| {
1942                snapshot(&index, observations, &slot_ids, &phase_id(run_id, identity))
1943            },
1944        ))
1945        .collect::<BTreeMap<_, _>>();
1946    let mut take = |identity: &Identity| {
1947        snapshots
1948            .remove(identity)
1949            .expect("each phase's snapshot is taken once")
1950    };
1951    for ((worker, test, retry), mut outcomes) in outcomes {
1952        let runner = runners
1953            .get(&(worker.clone(), test.clone(), retry))
1954            .cloned()
1955            .unwrap_or_else(default_runner);
1956        let attempt_identities = identities_by_attempt
1957            .remove(&(worker.clone(), test.clone(), retry))
1958            .unwrap_or_default();
1959        observed_runners.insert(runner.clone());
1960        outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1961            "setup" => 0,
1962            "call" => 1,
1963            _ => 2,
1964        });
1965        let mut phases = Vec::new();
1966        let mut runtime = Vec::new();
1967        let mut observed_phases = BTreeSet::new();
1968        for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1969            observed_phases.insert(phase_name.clone());
1970            let identity = Identity {
1971                worker: worker.clone(),
1972                test: test.clone(),
1973                retry,
1974                phase: phase_name.clone(),
1975            };
1976            let id = phase_id(run_id, &identity);
1977            phases.push(CoveragePhase {
1978                id: id.clone(),
1979                kind: match phase_name.as_str() {
1980                    "call" => "test",
1981                    value => value,
1982                }
1983                .into(),
1984                operation: format!("{runner} {phase_name}"),
1985                source: Some(test.clone()),
1986                caused_by_phase_id: None,
1987                started_at_ms: position as i64 * 2 + 1,
1988                ended_at_ms: Some(position as i64 * 2 + 2),
1989                status: Some(match outcome.as_str() {
1990                    "rerun" | "error" => "failed".into(),
1991                    value => value.into(),
1992                }),
1993                error: None,
1994            });
1995            if let Some((observed, _)) = attempt_identities
1996                .iter()
1997                .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1998            {
1999                runtime.push(take(observed)?);
2000            }
2001            if phase_name != "call" {
2002                continue;
2003            }
2004            // What the test recorded before its first assertion is that
2005            // assertion's evidence, linked when the phase passed outright:
2006            // a failed, skipped or expected-to-fail phase witnessed nothing.
2007            if let Some((identity, _)) = attempt_identities
2008                .iter()
2009                .find(|(candidate, _)| candidate.phase == "assertion")
2010            {
2011                observed_phases.insert("assertion".to_owned());
2012                let id = phase_id(run_id, identity);
2013                phases.push(CoveragePhase {
2014                    id: id.clone(),
2015                    kind: "assertion".into(),
2016                    operation: format!("{runner} assertion"),
2017                    source: Some(test.clone()),
2018                    caused_by_phase_id: None,
2019                    started_at_ms: position as i64 * 2 + 1,
2020                    ended_at_ms: Some(position as i64 * 2 + 2),
2021                    status: Some(
2022                        if outcome == "passed" && !*xfail {
2023                            "passed"
2024                        } else {
2025                            "failed"
2026                        }
2027                        .into(),
2028                    ),
2029                    error: None,
2030                });
2031                runtime.push(take(identity)?);
2032            }
2033        }
2034        // A phase the runtime entered but pytest never reported (the worker
2035        // died inside it) is a failed phase with its evidence kept.
2036        for (identity, _) in attempt_identities {
2037            if !observed_phases.contains(&identity.phase) {
2038                let id = phase_id(run_id, identity);
2039                phases.push(CoveragePhase {
2040                    id: id.clone(),
2041                    kind: match identity.phase.as_str() {
2042                        "call" => "test",
2043                        value => value,
2044                    }
2045                    .into(),
2046                    operation: format!("{runner} {}", identity.phase),
2047                    source: Some(test.clone()),
2048                    caused_by_phase_id: None,
2049                    started_at_ms: phases.len() as i64 * 2 + 1,
2050                    ended_at_ms: None,
2051                    status: Some("failed".into()),
2052                    error: Some("the phase started but the runner reported no outcome".into()),
2053                });
2054                runtime.push(take(identity)?);
2055            }
2056        }
2057        let status = if phases.iter().any(|phase| phase.error.is_some()) {
2058            "failed".into()
2059        } else {
2060            attempt_status(&outcomes)
2061        };
2062        let test_file = test_files
2063            .get(&(worker.clone(), test.clone(), retry))
2064            .and_then(|path| relative(root, path))
2065            .or_else(|| test.split("::").next().map(str::to_owned));
2066        raw_results.push(RawTestResult {
2067            test_id: Some(test.clone()),
2068            scope: Some(scope(run_id, &worker, &test, retry)),
2069            test: test.clone(),
2070            // What the runner named, as the project names it. A dotted
2071            // module path or a pytest node id is not a path, so the old
2072            // derivation stays only as a fallback.
2073            test_file: test_file.clone(),
2074            title: test.rsplit("::").next().map(str::to_owned),
2075            retry: Some(retry),
2076            status: Some(status),
2077            // pytest's xfail is expected to fail, and passing it unexpectedly
2078            // is still a pass unless the mark is strict -- in which case pytest
2079            // reports the phase as failed and drops the mark. So only a phase
2080            // that did not pass carries the expectation; one that passed with
2081            // the mark on it (XPASS) is a passing test, as pytest counts it.
2082            expected_status: Some(
2083                if outcomes
2084                    .iter()
2085                    .any(|(_, outcome, xfail)| *xfail && outcome != "passed")
2086                {
2087                    "failed"
2088                } else {
2089                    "passed"
2090                }
2091                .into(),
2092            ),
2093            flaky: false,
2094            provenance: {
2095                let tags = test_tags
2096                    .get(&(worker.clone(), test.clone(), retry))
2097                    .cloned()
2098                    .unwrap_or_default();
2099                TestProvenance {
2100                    runner: runner.clone(),
2101                    kind: crate::test_kind::with_tags(test_file.as_deref(), &tags).into(),
2102                    project: None,
2103                    source: PYTHON_FRONTEND_VERSION.into(),
2104                    tags,
2105                }
2106            },
2107            role: "test".into(),
2108            attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
2109            phases,
2110            runtime,
2111            browser: Vec::new(),
2112            server: Vec::new(),
2113        });
2114    }
2115    // Phases with observations whose test never produced any outcome at all
2116    // (for example a worker killed during its first phase).
2117    let default_observed = observed_runners
2118        .iter()
2119        .next()
2120        .cloned()
2121        .unwrap_or_else(default_runner);
2122    for ((worker, test, retry), identities) in identities_by_attempt {
2123        let runner = default_observed.clone();
2124        let mut phases = Vec::new();
2125        let mut runtime = Vec::new();
2126        for (position, (identity, _)) in identities.iter().enumerate() {
2127            let id = phase_id(run_id, identity);
2128            phases.push(CoveragePhase {
2129                id: id.clone(),
2130                kind: match identity.phase.as_str() {
2131                    "call" => "test",
2132                    value => value,
2133                }
2134                .into(),
2135                operation: format!("{runner} {}", identity.phase),
2136                source: Some(test.clone()),
2137                caused_by_phase_id: None,
2138                started_at_ms: position as i64 * 2 + 1,
2139                ended_at_ms: None,
2140                status: Some("failed".into()),
2141                error: Some("the phase started but the runner reported no outcome".into()),
2142            });
2143            runtime.push(take(identity)?);
2144        }
2145        let test_file = test_files
2146            .get(&(worker.clone(), test.clone(), retry))
2147            .and_then(|path| relative(root, path))
2148            .or_else(|| test.split("::").next().map(str::to_owned));
2149        raw_results.push(RawTestResult {
2150            test_id: Some(test.clone()),
2151            scope: Some(scope(run_id, &worker, &test, retry)),
2152            test: test.clone(),
2153            // What the runner named, as the project names it. A dotted
2154            // module path or a pytest node id is not a path, so the old
2155            // derivation stays only as a fallback.
2156            test_file: test_file.clone(),
2157            title: test.rsplit("::").next().map(str::to_owned),
2158            retry: Some(retry),
2159            status: Some("failed".into()),
2160            expected_status: Some("passed".into()),
2161            flaky: false,
2162            provenance: {
2163                let tags = test_tags
2164                    .get(&(worker.clone(), test.clone(), retry))
2165                    .cloned()
2166                    .unwrap_or_default();
2167                TestProvenance {
2168                    runner: runner.clone(),
2169                    kind: crate::test_kind::with_tags(test_file.as_deref(), &tags).into(),
2170                    project: None,
2171                    source: PYTHON_FRONTEND_VERSION.into(),
2172                    tags,
2173                }
2174            },
2175            role: "test".into(),
2176            attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
2177            phases,
2178            runtime,
2179            browser: Vec::new(),
2180            server: Vec::new(),
2181        });
2182    }
2183    for (worker, observations) in &background {
2184        if observations.is_empty() {
2185            continue;
2186        }
2187        let test = format!("__supercov_background__:{worker}");
2188        let identity = Identity {
2189            worker: worker.clone(),
2190            test: test.clone(),
2191            retry: 0,
2192            phase: "background".into(),
2193        };
2194        let phase = phase_id(run_id, &identity);
2195        raw_results.push(RawTestResult {
2196            test_id: Some(test.clone()),
2197            scope: Some(scope(run_id, worker, &test, 0)),
2198            test: "Python import, collection and background execution".into(),
2199            test_file: None,
2200            title: None,
2201            retry: Some(0),
2202            status: Some("unknown".into()),
2203            expected_status: None,
2204            flaky: false,
2205            provenance: TestProvenance {
2206                runner: default_observed.clone(),
2207                kind: "background".into(),
2208                project: None,
2209                source: PYTHON_FRONTEND_VERSION.into(),
2210                tags: Vec::new(),
2211            },
2212            role: "background".into(),
2213            attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
2214            phases: vec![CoveragePhase {
2215                id: phase.clone(),
2216                kind: "background".into(),
2217                operation: "Python import and collection background".into(),
2218                source: None,
2219                caused_by_phase_id: None,
2220                started_at_ms: 0,
2221                ended_at_ms: Some(0),
2222                status: Some("passed".into()),
2223                error: None,
2224            }],
2225            runtime: vec![snapshot(&index, observations, &slot_ids, &phase)?],
2226            browser: Vec::new(),
2227            server: Vec::new(),
2228        });
2229    }
2230
2231    // Runtime-detected limitations: obligations the runtime could not map
2232    // become unmeasured, and every limitation ID joins the manifest so the
2233    // declaration and manifest agree.
2234    let mut limitation_ids = manifest
2235        .limitations
2236        .iter()
2237        .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
2238        .map(str::to_owned)
2239        .collect::<BTreeSet<_>>();
2240    let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
2241    let mut new_limitations = Vec::new();
2242    // What any process was seen to run, to tell a file some process ran
2243    // unobserved from a file nothing observed at all.
2244    let observed = raw_results
2245        .iter()
2246        .flat_map(|raw| raw.runtime.iter())
2247        .flat_map(|snapshot| snapshot.hits.iter().cloned())
2248        .collect::<std::collections::HashSet<String>>();
2249    for limitation in limitations.values() {
2250        if let Some(obligation) = &limitation.obligation {
2251            if !index.lines.contains_key(obligation.as_str()) {
2252                return Err(PythonEvidenceError::UnknownObligation(obligation.clone()));
2253            }
2254            unmeasured.insert(obligation.clone());
2255        } else if let Some(file) = &limitation.file {
2256            // A code-object mapping failure or missing debug ranges prevents
2257            // every obligation in that source file from being observed. Mark
2258            // the whole file unmeasured instead of presenting its denominator
2259            // as ordinary uncovered code.
2260            //
2261            // An unobserved module is narrower: one process ran the file
2262            // without probes -- most often `python path/script.py`, which
2263            // CPython compiles outside the import system -- while every
2264            // process that imported it was measured. A project whose tests
2265            // both import its scripts and run them lost 43 files and six
2266            // points of line coverage when the direct run discarded the
2267            // imports' evidence. Such a file stays measured, a lower bound
2268            // the limitation still flags, unless nothing it ran was observed.
2269            let observed_elsewhere = limitation.id == "python-probes-unobserved-module"
2270                && index
2271                    .files
2272                    .get(file.as_str())
2273                    .is_some_and(|obligations| obligations.iter().any(|id| observed.contains(*id)));
2274            if !observed_elsewhere && let Some(obligations) = index.files.get(file.as_str()) {
2275                unmeasured.extend(obligations.iter().map(|id| (*id).to_owned()));
2276            }
2277        }
2278        let id = if limitation.file.is_some() || limitation.obligation.is_some() {
2279            stable_id(
2280                &limitation.id,
2281                &[
2282                    limitation.file.as_deref().unwrap_or_default(),
2283                    limitation.obligation.as_deref().unwrap_or_default(),
2284                ],
2285            )
2286        } else {
2287            limitation.id.clone()
2288        };
2289        if limitation_ids.insert(id.clone()) {
2290            let (file, line) = limitation
2291                .obligation
2292                .as_deref()
2293                .and_then(|id| {
2294                    index
2295                        .lines
2296                        .get(id)
2297                        .map(|(file, line)| ((*file).to_owned(), *line))
2298                })
2299                .unwrap_or_else(|| {
2300                    (
2301                        limitation.file.clone().unwrap_or_else(|| {
2302                            manifest
2303                                .points
2304                                .first()
2305                                .map_or(".".into(), |point| point.file.clone())
2306                        }),
2307                        1,
2308                    )
2309                });
2310            new_limitations.push(json!({
2311                "id": id,
2312                "code": limitation.id,
2313                "kind": "semantic-safety",
2314                "file": file,
2315                "line": line,
2316                "column": 0,
2317                "source": "",
2318                "reason": limitation.reason
2319            }));
2320        }
2321    }
2322    manifest.limitations.extend(new_limitations);
2323    manifest.unmeasured = unmeasured.into_iter().collect();
2324    let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
2325
2326    // Retries are separate raw results so their coverage remains attempt
2327    // exact, but the public lifecycle diagnostic reports logical tests rather
2328    // than inflating the count when a flaky test is rerun.
2329    let tests = raw_results
2330        .iter()
2331        .filter(|raw| raw.role == "test")
2332        .map(|raw| raw.test.as_str())
2333        .collect::<BTreeSet<_>>()
2334        .len();
2335    Ok(PythonFrontendRun {
2336        declaration: FrontendRunDeclaration {
2337            protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
2338            frontend_id: "python".into(),
2339            frontend_version: PYTHON_FRONTEND_VERSION.into(),
2340            language: "python".into(),
2341            structural_source: StructuralSource::OwnedProbes,
2342            runners: observed_runners
2343                .iter()
2344                .map(|runner| FrontendRunnerDeclaration {
2345                    runner: runner.clone(),
2346                    execution_model: if runner == UNITTEST_RUNNER {
2347                        ExecutionModel::SerialInProcess
2348                    } else {
2349                        ExecutionModel::ParallelContextPropagated
2350                    },
2351                    attribution: FrontendAttribution {
2352                        run: AttributionPrecision::Exact,
2353                        worker: AttributionPrecision::Exact,
2354                        test: AttributionPrecision::Exact,
2355                        retry: AttributionPrecision::Exact,
2356                        phase: AttributionPrecision::Exact,
2357                        action: AttributionPrecision::Unavailable,
2358                        // A server sees a browser test's requests, never
2359                        // its assertions, which run in the browser runner.
2360                        assertion: if python_runner(runner) {
2361                            AttributionPrecision::Exact
2362                        } else {
2363                            AttributionPrecision::Unavailable
2364                        },
2365                    },
2366                    limitations: if python_runner(runner) {
2367                        vec![FrontendLimitation {
2368                            id: format!("python-{runner}-action-linkage"),
2369                            scopes: vec![FrontendLimitationScope::Action],
2370                            reason: format!("{runner} exposes no general action lifecycle"),
2371                        }]
2372                    } else {
2373                        vec![FrontendLimitation {
2374                            id: format!("python-server-{runner}-linkage"),
2375                            scopes: vec![
2376                                FrontendLimitationScope::Action,
2377                                FrontendLimitationScope::Assertion,
2378                            ],
2379                            reason: format!(
2380                                "the server sees each {runner} test's requests, not its actions or assertions"
2381                            ),
2382                        }]
2383                    },
2384                })
2385                .collect(),
2386            structural_limitations,
2387        },
2388        request: CoverageReportRequest {
2389            run_id: run_id.into(),
2390            manifest,
2391            raw_results,
2392            generated_at: generated_at.into(),
2393            coverage_model: Some(python_coverage_model()),
2394            integrity: None,
2395            test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
2396        },
2397        tests,
2398        interpreters,
2399        python_versions: python_versions.into_iter().collect(),
2400    })
2401}
2402
2403#[cfg(test)]
2404mod tests {
2405    use std::time::{SystemTime, UNIX_EPOCH};
2406
2407    use super::*;
2408    use crate::{
2409        frontend_protocol::validate_frontend_report_request,
2410        python_instrumenter::build_python_obligations,
2411    };
2412
2413    // A request that named no test is the test's that was running when it
2414    // came, if only one was: among those its process answered a request of,
2415    // where it answered any, and among every test otherwise.
2416    #[test]
2417    fn a_request_naming_no_test_is_the_only_running_tests() {
2418        let identity = |worker: &str, test: &str| Identity {
2419            worker: worker.into(),
2420            test: test.into(),
2421            retry: 0,
2422            phase: "call".into(),
2423        };
2424        let observed = |hit: &str| Observations {
2425            hits: BTreeSet::from([hit.to_owned()]),
2426            ..Observations::default()
2427        };
2428        let served = |worker: &str, test: &str, window: (i64, i64)| ServedTest {
2429            worker: worker.into(),
2430            test_id: test.into(),
2431            retry: 0,
2432            name: test.into(),
2433            title: test.into(),
2434            file: "tests/a.spec.js".into(),
2435            status: "passed".into(),
2436            expected_status: "passed".into(),
2437            provenance: TestProvenance::default(),
2438            window: Some(window),
2439        };
2440        // Two workers' tests at once; one server per worker, and one server
2441        // only a mock calls.
2442        let tests = [
2443            served("playwright:w0", "one", (100, 200)),
2444            served("playwright:w1", "two", (100, 200)),
2445            served("playwright:w1", "three", (300, 400)),
2446        ];
2447        let first = identity("supercov-request:a.mmap", "1");
2448        let late = identity("supercov-request:a.mmap", "2");
2449        let mocked = identity("supercov-request:b.mmap", "1");
2450        let alone = identity("supercov-request:b.mmap", "2");
2451        let mut evidence = Evidence {
2452            per_identity: BTreeMap::from([
2453                (first.clone(), observed("first")),
2454                (late.clone(), observed("late")),
2455                (mocked.clone(), observed("mocked")),
2456                (alone.clone(), observed("alone")),
2457            ]),
2458            requests: vec![
2459                (first, 150, 1, "a.mmap".into(), "main".into()),
2460                (late, 250, 2, "a.mmap".into(), "main".into()),
2461                (mocked, 150, 1, "b.mmap".into(), "main".into()),
2462                (alone, 350, 2, "b.mmap".into(), "main".into()),
2463            ],
2464            served_by: BTreeMap::from([(
2465                "a.mmap".to_owned(),
2466                BTreeSet::from([("playwright:w0".to_owned(), "one".to_owned(), 0)]),
2467            )]),
2468            ..Evidence::default()
2469        };
2470        let credited = credit_requests_by_time(&mut evidence, |evidence, transport, at, _| {
2471            crate::served_tests::owner(&tests, evidence.served_by.get(transport), at)
2472                .map(|test| (test.worker.clone(), test.test_id.clone(), test.retry))
2473        });
2474        assert_eq!(
2475            credited,
2476            BTreeSet::from([
2477                ("playwright:w0".to_owned(), "one".to_owned(), 0),
2478                ("playwright:w1".to_owned(), "three".to_owned(), 0),
2479            ])
2480        );
2481        let hits = |worker: &str, test: &str| {
2482            evidence
2483                .per_identity
2484                .get(&identity(worker, test))
2485                .map(|observations| observations.hits.clone())
2486                .unwrap_or_default()
2487        };
2488        // The worker's own test, though another ran meanwhile.
2489        assert_eq!(
2490            hits("playwright:w0", "one"),
2491            BTreeSet::from(["first".to_owned()])
2492        );
2493        assert_eq!(
2494            hits("playwright:w1", "three"),
2495            BTreeSet::from(["alone".to_owned()])
2496        );
2497        // Between its tests, and while two ran with no process to choose by,
2498        // no test's: the background, and no request identity remains.
2499        assert_eq!(
2500            evidence.background["main"].hits,
2501            BTreeSet::from(["late".to_owned(), "mocked".to_owned()])
2502        );
2503        assert!(
2504            evidence
2505                .per_identity
2506                .keys()
2507                .all(|identity| !identity.worker.starts_with(REQUEST_WORKER))
2508        );
2509    }
2510
2511    fn temporary(name: &str) -> std::path::PathBuf {
2512        let nonce = SystemTime::now()
2513            .duration_since(UNIX_EPOCH)
2514            .unwrap()
2515            .as_nanos();
2516        let path = std::env::temp_dir().join(format!(
2517            "supercov-python-evidence-{}-{nonce}-{name}",
2518            std::process::id()
2519        ));
2520        fs::create_dir_all(&path).unwrap();
2521        path
2522    }
2523
2524    /// One transport in memory, a frame per payload.
2525    fn transport_bytes(payloads: &[&[u8]]) -> Vec<u8> {
2526        let capacity = payloads
2527            .iter()
2528            .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
2529                align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
2530            });
2531        let mut bytes = vec![0_u8; capacity];
2532        bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
2533        bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
2534        bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
2535        bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
2536        bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
2537        let mut cursor = TRANSPORT_HEADER_SIZE;
2538        for payload in payloads {
2539            let start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
2540            bytes[start..start + payload.len()].copy_from_slice(payload);
2541            bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
2542            bytes[cursor + 8..cursor + 12].copy_from_slice(&crc32fast::hash(payload).to_le_bytes());
2543            bytes[cursor] = 1;
2544            cursor = align_transport(start + payload.len()).unwrap();
2545        }
2546        bytes
2547    }
2548
2549    #[test]
2550    fn a_damaged_transport_is_refused_with_what_is_wrong() {
2551        // A killed writer, a full disk or another process writing into the
2552        // evidence directory each leave a transport like one of these. Each
2553        // is refused, never read as evidence.
2554        let exit: &[u8] = br#"{"t":"exit","at":9}"#;
2555        let valid = transport_bytes(&[exit]);
2556        let read = |bytes: &[u8]| match parse_transport("main.1.mmap", bytes, &[], None) {
2557            Ok(_) => "accepted".to_owned(),
2558            Err(error) => error.to_string(),
2559        };
2560        assert_eq!(read(&valid), "accepted");
2561        // The first frame's header starts at 64, its payload at 80 and ends at
2562        // 99, and its padding runs to 104.
2563        let set = |at: usize, value: &[u8]| {
2564            let mut bytes = valid.clone();
2565            bytes[at..at + value.len()].copy_from_slice(value);
2566            bytes
2567        };
2568        let mut truncated = valid[..99].to_vec();
2569        truncated[16..24].copy_from_slice(&99_u64.to_le_bytes());
2570        for (bytes, reason) in [
2571            (set(0, b"X"), "header or version does not match"),
2572            (
2573                set(8, &99_u32.to_le_bytes()),
2574                "header or version does not match",
2575            ),
2576            (
2577                set(16, &8_u64.to_le_bytes()),
2578                "declared capacity is outside the mapped file",
2579            ),
2580            (set(24, &3_u64.to_le_bytes()), "3"),
2581            (set(32, &0_u64.to_le_bytes()), "process id is missing"),
2582            (set(64, &[2]), "commit marker or reserved bytes are invalid"),
2583            (
2584                set(68, &0_u32.to_le_bytes()),
2585                "payload length is outside the transport bound",
2586            ),
2587            (
2588                set(68, &64_u32.to_le_bytes()),
2589                "payload extends past the mapped file",
2590            ),
2591            (truncated, "aligned frame extends past the mapped file"),
2592            (set(100, &[1]), "frame padding is not zero"),
2593            (
2594                set(72, &0_u32.to_le_bytes()),
2595                "payload checksum does not match",
2596            ),
2597            (transport_bytes(&[b"{not json"]), "key must be a string"),
2598            (
2599                transport_bytes(&[br#"{"t":"runs","ctx":1,"r":[]}"#]),
2600                "slot runs arrived without the run's slot layout",
2601            ),
2602        ] {
2603            let said = read(&bytes);
2604            assert!(said.contains(reason), "{reason}: {said}");
2605        }
2606        // A frame whose commit byte never landed ends the transport there,
2607        // as a killed writer leaves it.
2608        let mut uncommitted = transport_bytes(&[exit, exit]);
2609        uncommitted[104] = 0;
2610        assert_eq!(read(&uncommitted), "accepted");
2611    }
2612
2613    fn write_transport(path: &Path, records: &[serde_json::Value], dropped: u64) {
2614        write_transport_version(path, records, dropped, TRANSPORT_VERSION, None);
2615    }
2616
2617    /// A transport of any version, checksummed as that version does -- or,
2618    /// with `corrupt`, with one payload's checksum deliberately wrong.
2619    fn write_transport_version(
2620        path: &Path,
2621        records: &[serde_json::Value],
2622        dropped: u64,
2623        version: u32,
2624        corrupt: Option<usize>,
2625    ) {
2626        let payloads = records
2627            .iter()
2628            .map(|record| serde_json::to_vec(record).unwrap())
2629            .collect::<Vec<_>>();
2630        let capacity = payloads
2631            .iter()
2632            .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
2633                align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
2634            })
2635            + 64;
2636        let mut bytes = vec![0_u8; capacity];
2637        bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
2638        bytes[8..12].copy_from_slice(&version.to_le_bytes());
2639        bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
2640        bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
2641        bytes[24..32].copy_from_slice(&dropped.to_le_bytes());
2642        bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
2643        let mut cursor = TRANSPORT_HEADER_SIZE;
2644        for (index, payload) in payloads.into_iter().enumerate() {
2645            let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
2646            let payload_end = payload_start + payload.len();
2647            bytes[payload_start..payload_end].copy_from_slice(&payload);
2648            bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
2649            // Computed here, not through the reader's own function: a test of
2650            // which checksum a version means must not share the code under
2651            // test with the transport it writes.
2652            let expected = if version == TRANSPORT_VERSION_FNV {
2653                payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
2654                    (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
2655                })
2656            } else {
2657                crc32fast::hash(&payload)
2658            };
2659            let checksum = expected ^ u32::from(corrupt == Some(index));
2660            bytes[cursor + 8..cursor + 12].copy_from_slice(&checksum.to_le_bytes());
2661            bytes[cursor] = 1;
2662            cursor = align_transport(payload_end).unwrap();
2663        }
2664        fs::write(path, bytes).unwrap();
2665    }
2666
2667    // A path is only absolute in the platform's own spelling: "/project" is a
2668    // relative path on Windows, where an absolute one needs a drive. The
2669    // runtimes report whatever the interpreter loaded, so these fixtures have
2670    // to speak the host's dialect too.
2671    fn under(first: &str, rest: &str) -> String {
2672        let mut path = PathBuf::from(if cfg!(windows) {
2673            format!("C:\\{first}")
2674        } else {
2675            format!("/{first}")
2676        });
2677        for part in rest.split('/').filter(|part| !part.is_empty()) {
2678            path.push(part);
2679        }
2680        path.to_string_lossy().into_owned()
2681    }
2682
2683    fn assertion_sources(run: &PythonFrontendRun) -> Vec<String> {
2684        run.request.raw_results[0]
2685            .phases
2686            .iter()
2687            .filter(|phase| phase.kind == "assertion")
2688            .filter_map(|phase| phase.source.clone())
2689            .collect()
2690    }
2691
2692    fn run_with_sites(
2693        name: &str,
2694        sites: &[serde_json::Value],
2695        outcome_file: Option<&str>,
2696        root: &str,
2697    ) -> PythonFrontendRun {
2698        let source = "def f(a):\n    return a\n";
2699        let obligations = build_python_obligations("m.py", source).unwrap();
2700        let mut outcome = json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false});
2701        if let Some(file) = outcome_file {
2702            outcome["file"] = json!(file);
2703        }
2704        let mut lines = vec![
2705            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2706            json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2707            json!({"t":"assert","ctx":1}),
2708        ];
2709        lines.extend(sites.iter().cloned());
2710        lines.push(outcome);
2711        lines.push(json!({"t":"exit","at":9}));
2712        let directory = temporary(name);
2713        write_transport(&directory.join("main.1.mmap"), &lines, 0);
2714        let run = build_python_frontend_run(
2715            &obligations.manifest,
2716            &directory,
2717            "run-1",
2718            "now",
2719            0,
2720            Path::new(root),
2721        )
2722        .unwrap();
2723        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2724        fs::remove_dir_all(directory).unwrap();
2725        run
2726    }
2727
2728    const SLOT_DIGEST: &str = "0123456789abcdef0123456789abcdef";
2729
2730    /// Two obligations and one two-condition decision `a or b`, whose
2731    /// logical branch is decided by the vector: a 16-byte header, two
2732    /// obligation bytes, then the decision's `2 * 3^2` region bytes.
2733    fn slot_layout() -> serde_json::Value {
2734        json!({
2735            "version": 2, "digest": SLOT_DIGEST, "header": 16, "bytes": 36,
2736            "ids": ["s1", "s2"],
2737            "decisions": [{
2738                "start": 18, "width": 2, "id": "d",
2739                "outcomeTrue": "d:true", "outcomeFalse": "d:false",
2740                "logical": [{"evaluated": "or:evaluated", "shortCircuit": "or:short",
2741                             "previousLeaves": [0], "operandLeaves": [1]}]
2742            }]
2743        })
2744    }
2745
2746    /// A slot of `slot_layout` for `context`, with `set` bytes at 1.
2747    fn slot_bytes(context: u64, tag: Option<&str>, set: &[usize]) -> Vec<u8> {
2748        let mut bytes = vec![0_u8; 36];
2749        bytes[..8].copy_from_slice(&context.to_le_bytes());
2750        if let Some(tag) = tag {
2751            for index in 0..8 {
2752                bytes[8 + index] = u8::from_str_radix(&tag[index * 2..index * 2 + 2], 16).unwrap();
2753            }
2754        }
2755        for index in set {
2756            bytes[*index] = 1;
2757        }
2758        bytes
2759    }
2760
2761    /// A process killed during a test's call phase: its transport declares the
2762    /// phase and nothing after it, and its slot holds what it observed.
2763    fn killed_process(name: &str, slot: &[u8]) -> PathBuf {
2764        let directory = temporary(name);
2765        write_transport(
2766            &directory.join("main.1.token.mmap"),
2767            &[
2768                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.9.21","executable":"python","argv":["pytest"]}),
2769                json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2770            ],
2771            0,
2772        );
2773        fs::write(directory.join("main.1.token.0.slot"), slot).unwrap();
2774        fs::write(
2775            directory.join("layout.json"),
2776            serde_json::to_vec(&slot_layout()).unwrap(),
2777        )
2778        .unwrap();
2779        directory
2780    }
2781
2782    fn call_observations(evidence: &Evidence) -> Observations {
2783        evidence
2784            .per_identity
2785            .iter()
2786            .find(|(identity, _)| identity.phase == "call")
2787            .map(|(_, observations)| Observations {
2788                // Slot hits named, as a snapshot names them.
2789                hits: observations
2790                    .hits
2791                    .iter()
2792                    .cloned()
2793                    .chain(
2794                        observations
2795                            .slot_hits
2796                            .iter()
2797                            .map(|position| evidence.slot_ids[*position as usize].clone()),
2798                    )
2799                    .collect(),
2800                slot_hits: Vec::new(),
2801                vectors: observations.vectors.clone(),
2802            })
2803            .unwrap_or_default()
2804    }
2805
2806    #[test]
2807    fn a_killed_process_slot_is_read_as_its_last_records() {
2808        // s1 ran; `a or b` evaluated a false, then b true -- digits "12", mask
2809        // 1 + 2*3 = 7 -- and came out true: region byte 18 + 2*7 + 1.
2810        let directory = killed_process(
2811            "py-slot-killed",
2812            &slot_bytes(1, Some(SLOT_DIGEST), &[16, 18 + 15]),
2813        );
2814        let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2815        let call = call_observations(&evidence);
2816        assert_eq!(
2817            call.hits.iter().cloned().collect::<Vec<_>>(),
2818            ["d:true", "or:evaluated", "s1"],
2819            "the obligation, and what the vector implies"
2820        );
2821        assert_eq!(
2822            call.vectors["d"],
2823            BTreeSet::from([(vec![Some(false), Some(true)], true)])
2824        );
2825        fs::remove_dir_all(directory).unwrap();
2826    }
2827
2828    #[test]
2829    fn a_head_byte_is_read_with_the_bytes_it_stands_for() {
2830        // Byte 17 (s2) heads what bytes 16 (s1) and 18 + 2*7 + 1 (a vector
2831        // of d) stand for: the transform left those stores out because s2's
2832        // probe records them, and reading s2 reads them.
2833        let directory = temporary("py-slot-implied");
2834        write_transport(
2835            &directory.join("main.1.token.mmap"),
2836            &[
2837                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.9.21","executable":"python","argv":["pytest"]}),
2838                json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2839                json!({"t":"runs","ctx":1,"r":[17, 1]}),
2840            ],
2841            0,
2842        );
2843        let mut layout = slot_layout();
2844        layout["implied"] = json!([[17, [16, 18 + 15]]]);
2845        fs::write(
2846            directory.join("layout.json"),
2847            serde_json::to_vec(&layout).unwrap(),
2848        )
2849        .unwrap();
2850        let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2851        let call = call_observations(&evidence);
2852        assert_eq!(
2853            call.hits.iter().cloned().collect::<Vec<_>>(),
2854            ["d:true", "or:evaluated", "s1", "s2"]
2855        );
2856        assert_eq!(
2857            call.vectors["d"],
2858            BTreeSet::from([(vec![Some(false), Some(true)], true)])
2859        );
2860        // A byte may not imply a statement's own head, past the slot, or a
2861        // byte that implies others.
2862        for implied in [
2863            json!([[17, [17]]]),
2864            json!([[17, [36]]]),
2865            json!([[17, [16]], [16, [18]]]),
2866        ] {
2867            layout["implied"] = implied;
2868            fs::write(
2869                directory.join("layout.json"),
2870                serde_json::to_vec(&layout).unwrap(),
2871            )
2872            .unwrap();
2873            assert!(read_evidence_directory(&directory, "run-1").is_err());
2874        }
2875        fs::remove_dir_all(directory).unwrap();
2876    }
2877
2878    #[test]
2879    fn a_slot_gone_since_the_listing_is_skipped_and_nothing_else_is() {
2880        // Between listing the evidence and reading it, a process that outlived
2881        // the test command can close. A slot it took back is skipped at either
2882        // step; a transport, or anything else, that goes missing is still an
2883        // error rather than evidence quietly lost.
2884        let directory = temporary("py-slot-vanished");
2885        let gone = directory.join("main.1.token.0.slot");
2886        assert!(
2887            entry_metadata("main.1.token.0.slot", &gone)
2888                .unwrap()
2889                .is_none()
2890        );
2891        assert!(map_slot(&gone).unwrap().is_none());
2892        assert!(entry_metadata("main.1.token.mmap", &directory.join("main.1.token.mmap")).is_err());
2893        assert!(entry_metadata("layout.json", &directory.join("layout.json")).is_err());
2894        fs::write(&gone, slot_bytes(1, Some(SLOT_DIGEST), &[16])).unwrap();
2895        assert!(
2896            entry_metadata("main.1.token.0.slot", &gone)
2897                .unwrap()
2898                .is_some()
2899        );
2900        assert!(map_slot(&gone).unwrap().is_some());
2901        fs::remove_dir_all(directory).unwrap();
2902    }
2903
2904    #[test]
2905    fn a_slot_its_process_closed_is_not_counted() {
2906        // Close zeroes the tag after its final harvest; a daemon thread may
2907        // still have written after that, and nothing it wrote is a test's.
2908        let directory = killed_process("py-slot-closed", &slot_bytes(1, None, &[16]));
2909        let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2910        assert!(call_observations(&evidence).hits.is_empty());
2911        fs::remove_dir_all(directory).unwrap();
2912    }
2913
2914    #[test]
2915    fn a_slot_is_read_only_against_its_own_layout() {
2916        let other = "fedcba9876543210fedcba9876543210";
2917        let directory = killed_process("py-slot-foreign", &slot_bytes(1, Some(other), &[16]));
2918        assert!(matches!(
2919            read_evidence_directory(&directory, "run-1"),
2920            Err(PythonEvidenceError::InvalidTransport { .. })
2921        ));
2922        fs::remove_dir_all(directory).unwrap();
2923
2924        // A byte that is neither set nor clear was not written by a probe.
2925        let directory = killed_process("py-slot-byte", &slot_bytes(1, Some(SLOT_DIGEST), &[]));
2926        let mut bytes = slot_bytes(1, Some(SLOT_DIGEST), &[]);
2927        bytes[17] = 7;
2928        fs::write(directory.join("main.1.token.0.slot"), bytes).unwrap();
2929        assert!(read_evidence_directory(&directory, "run-1").is_err());
2930        fs::remove_dir_all(directory).unwrap();
2931    }
2932
2933    #[test]
2934    fn a_slot_needs_its_transport_and_a_partial_layout_is_ignored() {
2935        let directory = killed_process("py-slot-orphan", &slot_bytes(1, Some(SLOT_DIGEST), &[16]));
2936        // A layout write cut short by the process dying is left behind.
2937        fs::write(directory.join("layout.json.42.token.partial"), b"{").unwrap();
2938        assert!(read_evidence_directory(&directory, "run-1").is_ok());
2939        fs::write(
2940            directory.join("other.9.token.0.slot"),
2941            slot_bytes(1, Some(SLOT_DIGEST), &[16]),
2942        )
2943        .unwrap();
2944        assert!(matches!(
2945            read_evidence_directory(&directory, "run-1"),
2946            Err(PythonEvidenceError::InvalidTransport { .. })
2947        ));
2948        fs::remove_dir_all(directory).unwrap();
2949    }
2950
2951    #[test]
2952    fn a_test_s_assertions_are_its_one_assertion_phase() {
2953        // The per-test assertion phase carries the evidence from the first
2954        // assertion on; there is no separate phase per assertion line.
2955        let run = run_with_sites("py-assertion-phase", &[], None, &under("project", ""));
2956        assert_eq!(
2957            assertion_sources(&run),
2958            vec!["tests/test_m.py::test_a".to_string()]
2959        );
2960    }
2961
2962    #[test]
2963    fn the_runner_names_the_test_file_in_either_path_form() {
2964        // pytest reports the file from the report's location and unittest from
2965        // the test's module; either may be absolute or relative. Both name the
2966        // same project file, and a runner that names none falls back to the
2967        // node id.
2968        let root = under("project", "");
2969        for reported in [
2970            under("project", "tests/test_m.py"),
2971            "tests/test_m.py".to_owned(),
2972            "./tests/test_m.py".to_owned(),
2973        ] {
2974            let run = run_with_sites("py-asite-file", &[], Some(reported.as_str()), &root);
2975            assert_eq!(
2976                run.request.raw_results[0].test_file.as_deref(),
2977                Some("tests/test_m.py"),
2978                "{reported} should resolve to the project path"
2979            );
2980        }
2981        let without = run_with_sites("py-asite-nofile", &[], None, &root);
2982        assert_eq!(
2983            without.request.raw_results[0].test_file.as_deref(),
2984            Some("tests/test_m.py"),
2985            "a pytest node id already begins with the file"
2986        );
2987    }
2988
2989    /// A run with every kind of phase evidence: setup, a call phase split by
2990    /// its first assertion, teardown, background, and decision vectors on
2991    /// both sides of the assertion.
2992    fn phased_run(name: &str) -> PythonFrontendRun {
2993        let source = "def f(a, b):\n    if a and b:\n        return 1\n    return 2\n";
2994        let obligations = build_python_obligations("m.py", source).unwrap();
2995        let statements = &obligations.plan.statements;
2996        let decision = &obligations.manifest.decisions[0].id;
2997        let test = "tests/test_m.py::test_a";
2998        let lines = [
2999            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3000            json!({"t":"hit","ctx":0,"id":statements[0].id}),
3001            json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":test,"retry":0,"phase":"setup"}),
3002            json!({"t":"hits","ctx":1,"ids":[statements[0].id, statements[1].id]}),
3003            json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"setup","outcome":"passed","xfail":false}),
3004            json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":test,"retry":0,"phase":"call"}),
3005            json!({"t":"hit","ctx":2,"id":statements[1].id}),
3006            json!({"t":"dec","ctx":2,"id":decision,"v":"22","o":1}),
3007            json!({"t":"assert","ctx":2}),
3008            json!({"t":"hit","ctx":2,"id":statements[2].id}),
3009            json!({"t":"dec","ctx":2,"id":decision,"v":"21","o":0}),
3010            json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"call","outcome":"passed","xfail":false}),
3011            json!({"t":"phase","ctx":3,"at":7,"worker":"main","test":test,"retry":0,"phase":"teardown"}),
3012            json!({"t":"hit","ctx":3,"id":statements[2].id}),
3013            json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
3014            json!({"t":"exit","at":9}),
3015        ];
3016        let directory = temporary(name);
3017        write_transport(&directory.join("main.1.mmap"), &lines, 0);
3018        let run = build_python_frontend_run(
3019            &obligations.manifest,
3020            &directory,
3021            "run-1",
3022            "now",
3023            0,
3024            Path::new(""),
3025        )
3026        .unwrap();
3027        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3028        fs::remove_dir_all(directory).unwrap();
3029        run
3030    }
3031
3032    #[test]
3033    fn a_snapshot_that_names_its_phase_analyses_as_the_events_it_replaces() {
3034        // Python names a snapshot's phase once instead of writing an explicit
3035        // event per hit and vector. Every count and relation the analysis
3036        // derives must be the one those events gave.
3037        use crate::coverage_report::{RuntimeEvent, analyze_coverage_results};
3038        let run = phased_run("py-phase-named");
3039        let named = &run.request;
3040        assert!(
3041            named
3042                .raw_results
3043                .iter()
3044                .flat_map(|raw| &raw.runtime)
3045                .filter(|snapshot| snapshot.phase_id.is_some())
3046                .count()
3047                >= 4,
3048            "setup, call, assertion and teardown each name their phase"
3049        );
3050        let mut events = named.clone();
3051        for snapshot in events
3052            .raw_results
3053            .iter_mut()
3054            .flat_map(|raw| raw.runtime.iter_mut())
3055        {
3056            let Some(phase) = snapshot.phase_id.take() else {
3057                continue;
3058            };
3059            let mut clock = 1;
3060            let mut event = |event_type: &str, id: &str, vector: Option<McdcVector>| {
3061                clock += 1;
3062                RuntimeEvent {
3063                    event_type: event_type.into(),
3064                    id: id.into(),
3065                    vector,
3066                    timestamp_ms: clock,
3067                    phase_id: Some(phase.clone()),
3068                    statement_id: None,
3069                    environment: "python".into(),
3070                }
3071            };
3072            let mut written = snapshot
3073                .hits
3074                .iter()
3075                .map(|hit| event("hit", hit, None))
3076                .collect::<Vec<_>>();
3077            for decision in &snapshot.decisions {
3078                for vector in &decision.vectors {
3079                    written.push(event("decision", &decision.meta.id, Some(vector.clone())));
3080                }
3081            }
3082            snapshot.events = written;
3083        }
3084        let from_names = analyze_coverage_results(named).unwrap();
3085        let from_events = analyze_coverage_results(&events).unwrap();
3086        assert!(
3087            from_names
3088                .view
3089                .phases
3090                .iter()
3091                .any(|phase| phase.explicit_events > 1),
3092            "the fixture's phases carry explicit evidence"
3093        );
3094        assert_eq!(
3095            serde_json::to_value(&from_names).unwrap(),
3096            serde_json::to_value(&from_events).unwrap()
3097        );
3098    }
3099
3100    #[test]
3101    fn analysing_the_archived_request_is_analysing_the_archive() {
3102        // Publication analyses the request it has just archived instead of
3103        // reading the archive back. The two must agree to the byte.
3104        use crate::coverage_report::{
3105            ArchiveReportRequest, ExitCodeInput, TransportStats, analyze_coverage_archive,
3106            analyze_frontend_request,
3107        };
3108        let run = phased_run("py-archived");
3109        let directory = temporary("py-archived-archive");
3110        let archive = directory.join("evidence.raw.gz");
3111        crate::evidence_archive::write_archive(run.archive_entries().unwrap(), &archive).unwrap();
3112        let integrity = json!({"schemaVersion": 2});
3113        let from_archive = analyze_coverage_archive(&ArchiveReportRequest {
3114            archive_path: archive,
3115            run_id: "run-1".into(),
3116            generated_at: "then".into(),
3117            integrity: Some(integrity.clone()),
3118            test_exit_code: ExitCodeInput::Present(Some(0)),
3119            test_filter: None,
3120        })
3121        .unwrap();
3122        let in_memory = analyze_frontend_request(
3123            &run.declaration,
3124            &CoverageReportRequest {
3125                run_id: "run-1".into(),
3126                generated_at: "then".into(),
3127                integrity: Some(integrity),
3128                test_exit_code: ExitCodeInput::Present(Some(0)),
3129                ..run.request.clone()
3130            },
3131            TransportStats::none(),
3132        )
3133        .unwrap();
3134        fs::remove_dir_all(directory).unwrap();
3135        assert_eq!(
3136            serde_json::to_value(&in_memory).unwrap(),
3137            serde_json::to_value(&from_archive).unwrap()
3138        );
3139    }
3140
3141    #[test]
3142    fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
3143        // The runtime's marker says everything the call phase recorded so far
3144        // ran before an assertion. That evidence carries an assertion phase
3145        // that passed with the test; what ran after the marker, and all of a
3146        // test that failed, stays execution only. A second marker is inert.
3147        let source = "def f(a):\n    return a\n\n\ndef g(b):\n    return b\n";
3148        let obligations = build_python_obligations("m.py", source).unwrap();
3149        let before = &obligations.plan.statements[0].id;
3150        let after = &obligations.plan.statements[1].id;
3151        let run_with = |name: &str, outcome: &str| {
3152            let lines = [
3153                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3154                json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
3155                json!({"t":"hit","ctx":1,"id":before}),
3156                json!({"t":"assert","ctx":1}),
3157                json!({"t":"assert","ctx":1}),
3158                json!({"t":"hit","ctx":1,"id":after}),
3159                json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":outcome,"xfail":false}),
3160                json!({"t":"exit","at":9}),
3161            ];
3162            let directory = temporary(name);
3163            write_transport(&directory.join("main.1.mmap"), &lines, 0);
3164            let run = build_python_frontend_run(
3165                &obligations.manifest,
3166                &directory,
3167                "run-1",
3168                "now",
3169                0,
3170                Path::new(""),
3171            )
3172            .unwrap();
3173            validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3174            fs::remove_dir_all(directory).unwrap();
3175            run
3176        };
3177        let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
3178            result
3179                .runtime
3180                .iter()
3181                .filter(|snapshot| snapshot.phase_id.as_deref() == Some(phase))
3182                .flat_map(|snapshot| snapshot.hits.iter().cloned())
3183                .collect()
3184        };
3185
3186        let run = run_with("asserted-passed", "passed");
3187        let passed = &run.request.raw_results[0];
3188        assert_eq!(passed.test, "tests/test_m.py::test_a");
3189        let assertion = passed
3190            .phases
3191            .iter()
3192            .find(|phase| phase.kind == "assertion")
3193            .expect("the asserting test carries an assertion phase");
3194        assert_eq!(assertion.status.as_deref(), Some("passed"));
3195        assert_eq!(
3196            events_of(passed, &assertion.id),
3197            BTreeSet::from([before.clone()]),
3198            "only what ran before the marker is the assertion's evidence"
3199        );
3200        let test_phase = passed
3201            .phases
3202            .iter()
3203            .find(|phase| phase.kind == "test")
3204            .unwrap();
3205        assert_eq!(
3206            events_of(passed, &test_phase.id),
3207            BTreeSet::from([before.clone(), after.clone()]),
3208            "the test phase keeps everything it ran"
3209        );
3210        assert_eq!(
3211            run.declaration.runners[0].attribution.assertion,
3212            AttributionPrecision::Exact
3213        );
3214
3215        let run = run_with("asserted-failed", "failed");
3216        let failed = &run.request.raw_results[0];
3217        let assertion = failed
3218            .phases
3219            .iter()
3220            .find(|phase| phase.kind == "assertion")
3221            .unwrap();
3222        assert_eq!(
3223            assertion.status.as_deref(),
3224            Some("failed"),
3225            "a failed test's assertion witnessed nothing"
3226        );
3227    }
3228
3229    #[test]
3230    fn repeated_runtime_limitations_keep_each_affected_file_once() {
3231        let mut manifest = build_python_obligations("a.py", "x = 1\n")
3232            .unwrap()
3233            .manifest;
3234        let second = build_python_obligations("b.py", "y = 2\n")
3235            .unwrap()
3236            .manifest;
3237        manifest.points.extend(second.points);
3238        let directory = temporary("repeated-limitations");
3239        let mut records = vec![
3240            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11","executable":"python","argv":[]}),
3241            json!({"t":"outcome","worker":"main","test":"test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
3242        ];
3243        for _ in 0..1000 {
3244            for file in ["a.py", "b.py"] {
3245                records.push(json!({"t":"limitation","id":"python-probes-unobserved-module","file":file,"reason":"entry script"}));
3246            }
3247        }
3248        write_transport(&directory.join("main.1.mmap"), &records, 0);
3249        let evidence = read_evidence_directory(&directory, "run-1").unwrap();
3250        assert_eq!(
3251            evidence.limitations.len(),
3252            2,
3253            "deduplicate before manifest expansion"
3254        );
3255        let run =
3256            build_python_frontend_run(&manifest, &directory, "run-1", "now", 0, Path::new(""))
3257                .unwrap();
3258        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3259        let limitations = &run.request.manifest.limitations;
3260        assert_eq!(limitations.len(), 2);
3261        assert_ne!(limitations[0]["id"], limitations[1]["id"]);
3262        assert_eq!(limitations[0]["file"], "a.py");
3263        assert_eq!(limitations[1]["file"], "b.py");
3264        assert_eq!(limitations[0]["code"], "python-probes-unobserved-module");
3265        assert_eq!(run.request.manifest.unmeasured.len(), 2);
3266        fs::remove_dir_all(directory).unwrap();
3267    }
3268
3269    #[test]
3270    fn a_script_run_unobserved_stays_measured_where_its_imports_were_observed() {
3271        // issue #40: tests import `bin/anti-drift-gate.py` and also run it as
3272        // `python bin/anti-drift-gate.py`. The direct run made the whole file
3273        // unmeasured, discarding what the imports were seen to run.
3274        let imported = build_python_obligations("imported.py", "def f():\n    return 1\n").unwrap();
3275        let only_run = build_python_obligations("only_run.py", "y = 2\n").unwrap();
3276        let mut manifest = imported.manifest.clone();
3277        manifest.points.extend(only_run.manifest.points.clone());
3278        let statement = &imported.plan.statements[0];
3279        let directory = temporary("script-and-import");
3280        let records = vec![
3281            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11","executable":"python","argv":["pytest"]}),
3282            json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
3283            json!({"t":"hit","ctx":1,"id":statement.id}),
3284            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
3285            json!({"t":"limitation","id":"python-probes-unobserved-module","file":"imported.py","reason":"entry script"}),
3286            json!({"t":"limitation","id":"python-probes-unobserved-module","file":"only_run.py","reason":"entry script"}),
3287        ];
3288        write_transport(&directory.join("main.1.mmap"), &records, 0);
3289        let run =
3290            build_python_frontend_run(&manifest, &directory, "run-1", "now", 0, Path::new(""))
3291                .unwrap();
3292        let unmeasured = &run.request.manifest.unmeasured;
3293        let file_points = |file: &str| {
3294            manifest
3295                .points
3296                .iter()
3297                .filter(|point| point.file == file)
3298                .map(|point| point.id.clone())
3299                .collect::<Vec<_>>()
3300        };
3301        assert!(
3302            file_points("imported.py")
3303                .iter()
3304                .all(|id| !unmeasured.contains(id)),
3305            "an observed file keeps its measurement"
3306        );
3307        assert!(
3308            file_points("only_run.py")
3309                .iter()
3310                .all(|id| unmeasured.contains(id)),
3311            "a file nothing observed stays unmeasured"
3312        );
3313        // Both still say so: the imported file's numbers are a lower bound.
3314        let files = run
3315            .request
3316            .manifest
3317            .limitations
3318            .iter()
3319            .map(|limitation| limitation["file"].as_str().unwrap().to_owned())
3320            .collect::<BTreeSet<_>>();
3321        assert!(files.contains("imported.py") && files.contains("only_run.py"));
3322        fs::remove_dir_all(directory).unwrap();
3323    }
3324
3325    #[test]
3326    fn joins_phases_outcomes_hits_and_vectors_into_exact_results() {
3327        let source = "def f(a, b):\n    if a and b:\n        return 1\n    return 0\n";
3328        let obligations = build_python_obligations("m.py", source).unwrap();
3329        let decision = &obligations.plan.decisions[0];
3330        let statement = &obligations.plan.statements[0];
3331        let directory = temporary("join");
3332        let lines = [
3333            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3334            json!({"t":"hit","ctx":0,"id":statement.id}),
3335            json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
3336            json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
3337            json!({"t":"hit","ctx":1,"id":decision.outcome_true}),
3338            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
3339            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
3340            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
3341            json!({"t":"limitation","id":"python-decision-partially-mapped","reason":"folded","obligation":decision.id}),
3342            json!({"t":"exit","at":9}),
3343        ];
3344        write_transport(&directory.join("main.1.mmap"), &lines, 0);
3345        let run = build_python_frontend_run(
3346            &obligations.manifest,
3347            &directory,
3348            "run-1",
3349            "now",
3350            0,
3351            Path::new(""),
3352        )
3353        .unwrap();
3354        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3355        assert_eq!(run.tests, 1);
3356        assert_eq!(run.request.raw_results.len(), 2);
3357        let test = &run.request.raw_results[0];
3358        assert_eq!(test.status.as_deref(), Some("passed"));
3359        assert_eq!(test.phases.len(), 3);
3360        assert_eq!(test.runtime.len(), 1);
3361        assert_eq!(test.runtime[0].decisions.len(), 1);
3362        assert!(test.runtime[0].phase_id.is_some());
3363        assert!(test.runtime[0].events.is_empty());
3364        let background = &run.request.raw_results[1];
3365        assert_eq!(background.role, "background");
3366        assert!(run.request.manifest.unmeasured.contains(&decision.id));
3367        assert!(run.declaration.structural_limitations.contains(&stable_id(
3368            "python-decision-partially-mapped",
3369            &["", &decision.id]
3370        )));
3371        fs::remove_dir_all(directory).unwrap();
3372    }
3373
3374    #[test]
3375    fn a_damaged_record_is_refused_with_what_is_wrong() {
3376        // The runtime writes none of these. Another version of it, another
3377        // run's leftovers or a damaged write can, and each is refused rather
3378        // than read as evidence.
3379        let source = "def f(a, b):\n    if a and b:\n        return 1\n    return 0\n";
3380        let obligations = build_python_obligations("m.py", source).unwrap();
3381        let decision = obligations.plan.decisions[0].id.clone();
3382        let process = json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]});
3383        let phase = json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"t::a","retry":0,"phase":"call"});
3384        let outcome = json!({"t":"outcome","worker":"main","test":"t::a","retry":0,"phase":"call","outcome":"passed","xfail":false});
3385        let with = |record: &serde_json::Value, key: &str, value: serde_json::Value| {
3386            let mut changed = record.clone();
3387            changed[key] = value;
3388            changed
3389        };
3390        let read = |name: &str, records: Vec<serde_json::Value>| {
3391            let directory = temporary(name);
3392            write_transport(&directory.join("main.1.mmap"), &records, 0);
3393            let said = match build_python_frontend_run(
3394                &obligations.manifest,
3395                &directory,
3396                "run-1",
3397                "now",
3398                0,
3399                Path::new(""),
3400            ) {
3401                Ok(_) => "accepted".to_owned(),
3402                Err(error) => error.to_string(),
3403            };
3404            fs::remove_dir_all(directory).unwrap();
3405            said
3406        };
3407        let exit = json!({"t":"exit","at":9});
3408        assert_eq!(
3409            read(
3410                "record-valid",
3411                vec![
3412                    process.clone(),
3413                    phase.clone(),
3414                    outcome.clone(),
3415                    exit.clone()
3416                ]
3417            ),
3418            "accepted"
3419        );
3420        for (name, records, reason) in [
3421            (
3422                "record-version",
3423                vec![with(&process, "v", json!(2))],
3424                "unsupported Python evidence version 2",
3425            ),
3426            (
3427                "record-run",
3428                vec![with(&process, "run", json!("run-0"))],
3429                "belongs to run run-0, expected run-1",
3430            ),
3431            (
3432                "record-pid",
3433                vec![with(&process, "pid", json!(7))],
3434                "does not match the transport owner",
3435            ),
3436            (
3437                "record-python",
3438                vec![with(&process, "python", json!("3.8.1"))],
3439                "the test command ran Python 3.8.1",
3440            ),
3441            (
3442                "record-context",
3443                vec![process.clone(), with(&phase, "ctx", json!(0))],
3444                "phase context 0 is reserved",
3445            ),
3446            (
3447                "record-phase",
3448                vec![process.clone(), with(&phase, "phase", json!("body"))],
3449                "unknown pytest phase",
3450            ),
3451            (
3452                "record-identity",
3453                vec![process.clone(), with(&phase, "test", json!(" "))],
3454                "must name a worker and test",
3455            ),
3456            (
3457                "record-outcome-phase",
3458                vec![process.clone(), with(&outcome, "phase", json!("body"))],
3459                "unknown test outcome phase",
3460            ),
3461            (
3462                "record-outcome",
3463                vec![process.clone(), with(&outcome, "outcome", json!("maybe"))],
3464                "unknown test outcome",
3465            ),
3466            (
3467                "record-runner",
3468                vec![process.clone(), with(&outcome, "runner", json!("nose"))],
3469                "unknown Python test runner",
3470            ),
3471            (
3472                "record-two-runners",
3473                vec![
3474                    process.clone(),
3475                    outcome.clone(),
3476                    with(&outcome, "runner", json!("unittest")),
3477                ],
3478                "one attempt was reported by two runners",
3479            ),
3480            (
3481                "record-digits",
3482                vec![
3483                    process.clone(),
3484                    phase.clone(),
3485                    json!({"t":"dec","ctx":1,"id":decision,"v":"23","o":1}),
3486                ],
3487                "decision vector digits must be 0, 1 or 2",
3488            ),
3489            (
3490                "record-decision-outcome",
3491                vec![
3492                    process.clone(),
3493                    phase.clone(),
3494                    json!({"t":"dec","ctx":1,"id":decision,"v":"22","o":2}),
3495                ],
3496                "decision outcome must be 0 or 1",
3497            ),
3498            (
3499                "record-batch-digits",
3500                vec![
3501                    process.clone(),
3502                    phase.clone(),
3503                    json!({"t":"decs","ctx":1,"v":[[decision, "", 1]]}),
3504                ],
3505                "decision vector digits must be 0, 1 or 2",
3506            ),
3507            (
3508                "record-batch-outcome",
3509                vec![
3510                    process.clone(),
3511                    phase.clone(),
3512                    json!({"t":"decs","ctx":1,"v":[[decision, "21", 3]]}),
3513                ],
3514                "decision outcome must be 0 or 1",
3515            ),
3516            (
3517                "record-unknown-hit",
3518                vec![
3519                    process.clone(),
3520                    phase.clone(),
3521                    json!({"t":"hit","ctx":1,"id":"py:nowhere"}),
3522                    outcome.clone(),
3523                    exit.clone(),
3524                ],
3525                "unknown obligation: py:nowhere",
3526            ),
3527            (
3528                "record-unknown-decision",
3529                vec![
3530                    process.clone(),
3531                    phase.clone(),
3532                    json!({"t":"dec","ctx":1,"id":"py:nodecision","v":"22","o":1}),
3533                    outcome.clone(),
3534                    exit.clone(),
3535                ],
3536                "unknown obligation: py:nodecision",
3537            ),
3538            (
3539                "record-short-vector",
3540                vec![
3541                    process.clone(),
3542                    phase.clone(),
3543                    json!({"t":"dec","ctx":1,"id":decision,"v":"2","o":1}),
3544                    outcome.clone(),
3545                    exit.clone(),
3546                ],
3547                "reported 1 condition values, expected 2",
3548            ),
3549        ] {
3550            let said = read(name, records);
3551            assert!(said.contains(reason), "{name}: {said}");
3552        }
3553    }
3554
3555    // Version 2 checksums with CRC-32, version 1 with FNV-1a. A runtime older
3556    // than this reader -- evidence kept from a failed run, say -- still reads,
3557    // by its own checksum; a payload that does not match its checksum, and a
3558    // version this reader does not know, are refused rather than guessed at.
3559    #[test]
3560    fn a_transport_reads_by_its_own_versions_checksum() {
3561        let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
3562        let process = json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]});
3563        let read = |name: &str, version: u32, corrupt: Option<usize>| {
3564            let directory = temporary(name);
3565            write_transport_version(
3566                &directory.join("main.1.mmap"),
3567                std::slice::from_ref(&process),
3568                0,
3569                version,
3570                corrupt,
3571            );
3572            let outcome = build_python_frontend_run(
3573                &obligations.manifest,
3574                &directory,
3575                "run-1",
3576                "now",
3577                0,
3578                Path::new(""),
3579            );
3580            fs::remove_dir_all(directory).unwrap();
3581            outcome.map(|_| ()).map_err(|error| error.to_string())
3582        };
3583        let current = read("checksum-v2", TRANSPORT_VERSION, None);
3584        let older = read("checksum-v1", TRANSPORT_VERSION_FNV, None);
3585        assert_eq!(
3586            older, current,
3587            "a version 1 transport reads as a version 2 one does"
3588        );
3589        assert!(
3590            !current
3591                .as_ref()
3592                .err()
3593                .is_some_and(|error| error.contains("checksum")),
3594            "{current:?}"
3595        );
3596        let corrupted = read("checksum-bad", TRANSPORT_VERSION, Some(0)).unwrap_err();
3597        assert!(corrupted.contains("checksum does not match"), "{corrupted}");
3598        let unknown = read("checksum-v3", TRANSPORT_VERSION + 1, None).unwrap_err();
3599        assert!(unknown.contains("version"), "{unknown}");
3600    }
3601
3602    #[test]
3603    fn mixed_pytest_and_unittest_runs_have_distinct_runner_limitations() {
3604        let obligations = build_python_obligations("m.py", "def f():\n    return 1\n").unwrap();
3605        let directory = temporary("mixed-runners");
3606        let mut records = vec![json!({"t":"process","v":1,"run":"run-1","pid":1,
3607            "worker":"main","python":"3.11.11","executable":"python","argv":["pytest"]})];
3608        for (index, runner) in [PYTEST_RUNNER, UNITTEST_RUNNER].iter().enumerate() {
3609            let test = format!("tests/test_m.py::test_{runner}");
3610            records.push(
3611                json!({"t":"phase","ctx":index+1,"at":index+5,"worker":"main",
3612                "test":test,"retry":0,"phase":"call"}),
3613            );
3614            records.push(json!({"t":"hit","ctx":index+1,"id":obligations.plan.statements[0].id}));
3615            records.push(json!({"t":"outcome","worker":"main","test":test,"retry":0,
3616                "phase":"call","outcome":"passed","xfail":false,"runner":runner}));
3617        }
3618        write_transport(&directory.join("main.1.mmap"), &records, 0);
3619        let run = build_python_frontend_run(
3620            &obligations.manifest,
3621            &directory,
3622            "run-1",
3623            "now",
3624            0,
3625            Path::new(""),
3626        )
3627        .unwrap();
3628        assert_eq!(run.tests, 2);
3629        assert_eq!(run.declaration.runners.len(), 2);
3630        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3631        for runner in &run.declaration.runners {
3632            assert_eq!(runner.limitations.len(), 1);
3633            assert_eq!(runner.attribution.action, AttributionPrecision::Unavailable);
3634            assert_eq!(
3635                runner.limitations[0].scopes,
3636                [FrontendLimitationScope::Action]
3637            );
3638        }
3639        fs::remove_dir_all(directory).unwrap();
3640    }
3641
3642    #[test]
3643    fn a_phase_its_runner_reported_more_than_once_is_one_phase() {
3644        // pytest-subtests reports every subtest under the same node id and the
3645        // same `call` phase, so an attempt carries several outcomes for the
3646        // identity a phase id is derived from. Each became its own phase with
3647        // the same id, and the run was refused whole -- a suite of 6872 tests
3648        // lost two and three-quarter hours of measurement to it (#40). The
3649        // outcomes fold the way an attempt's do: a failure anywhere is the
3650        // phase's.
3651        let source = "def f(a):\n    return a\n";
3652        let obligations = build_python_obligations("m.py", source).unwrap();
3653        let directory = temporary("subtests");
3654        let subtest = |outcome: &str| {
3655            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,
3656                   "phase":"call","outcome":outcome,"xfail":false})
3657        };
3658        let lines = [
3659            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3660            json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"call"}),
3661            json!({"t":"hit","ctx":1,"id":obligations.plan.statements[0].id}),
3662            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
3663            subtest("passed"),
3664            subtest("passed"),
3665            subtest("failed"),
3666            subtest("passed"),
3667            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
3668            json!({"t":"exit","at":9}),
3669        ];
3670        write_transport(&directory.join("main.1.mmap"), &lines, 0);
3671        let run = build_python_frontend_run(
3672            &obligations.manifest,
3673            &directory,
3674            "run-1",
3675            "now",
3676            1,
3677            Path::new(""),
3678        )
3679        .unwrap();
3680        // The protocol refuses a repeated phase id, which is what threw the
3681        // run away; nothing may reach it twice.
3682        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3683        let test = &run.request.raw_results[0];
3684        assert_eq!(
3685            test.phases.len(),
3686            3,
3687            "setup, one call, teardown: {:?}",
3688            test.phases
3689        );
3690        assert_eq!(
3691            test.phases
3692                .iter()
3693                .filter(|phase| phase.kind == "test")
3694                .count(),
3695            1,
3696            "the subtests share the call phase their coverage was recorded under"
3697        );
3698        assert_eq!(
3699            test.status.as_deref(),
3700            Some("failed"),
3701            "a subtest that failed fails its test"
3702        );
3703        // And the coverage recorded under that identity survives the fold.
3704        assert_eq!(test.runtime.len(), 1);
3705        assert!(!test.runtime[0].hits.is_empty());
3706        fs::remove_dir_all(directory).unwrap();
3707    }
3708
3709    #[test]
3710    fn fails_closed_without_an_interpreter_or_tests() {
3711        let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
3712        let directory = temporary("empty");
3713        assert!(matches!(
3714            build_python_frontend_run(
3715                &obligations.manifest,
3716                &directory,
3717                "run-1",
3718                "now",
3719                0,
3720                Path::new("")
3721            ),
3722            Err(PythonEvidenceError::NoInterpreter)
3723        ));
3724        let path = directory.join("main.1.mmap");
3725        write_transport(
3726            &path,
3727            &[
3728                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
3729            ],
3730            0,
3731        );
3732        assert!(matches!(
3733            build_python_frontend_run(
3734                &obligations.manifest,
3735                &directory,
3736                "run-1",
3737                "now",
3738                0,
3739                Path::new("")
3740            ),
3741            Err(PythonEvidenceError::NoTests)
3742        ));
3743        // A killed writer may have copied part of its next payload without
3744        // publishing the commit byte. The reader must stop at that frame,
3745        // even when the tail would be invalid JSON if treated as committed.
3746        let mut torn = fs::read(&path).unwrap();
3747        let process_length = transport_u32(&torn, TRANSPORT_HEADER_SIZE + 4).unwrap() as usize;
3748        let torn_cursor =
3749            align_transport(TRANSPORT_HEADER_SIZE + TRANSPORT_RECORD_HEADER_SIZE + process_length)
3750                .unwrap();
3751        torn[torn_cursor + 4..torn_cursor + 8].copy_from_slice(&5_u32.to_le_bytes());
3752        torn[torn_cursor + TRANSPORT_RECORD_HEADER_SIZE
3753            ..torn_cursor + TRANSPORT_RECORD_HEADER_SIZE + 5]
3754            .copy_from_slice(b"{nope");
3755        fs::write(&path, torn).unwrap();
3756        assert!(matches!(
3757            build_python_frontend_run(
3758                &obligations.manifest,
3759                &directory,
3760                "run-1",
3761                "now",
3762                0,
3763                Path::new("")
3764            ),
3765            Err(PythonEvidenceError::NoTests)
3766        ));
3767        write_transport(
3768            &path,
3769            &[
3770                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.8.20","executable":"p","argv":[]}),
3771            ],
3772            0,
3773        );
3774        assert!(matches!(
3775            build_python_frontend_run(
3776                &obligations.manifest,
3777                &directory,
3778                "run-1",
3779                "now",
3780                0,
3781                Path::new("")
3782            ),
3783            Err(PythonEvidenceError::UnsupportedPython(_))
3784        ));
3785        write_transport(
3786            &path,
3787            &[
3788                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
3789            ],
3790            2,
3791        );
3792        assert!(matches!(
3793            build_python_frontend_run(
3794                &obligations.manifest,
3795                &directory,
3796                "run-1",
3797                "now",
3798                0,
3799                Path::new("")
3800            ),
3801            Err(PythonEvidenceError::DroppedRecords { count: 2, .. })
3802        ));
3803        fs::remove_dir_all(directory).unwrap();
3804    }
3805}