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