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