Skip to main content

supercov_engine/
python_evidence.rs

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