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, RuntimeEvent, RuntimeSnapshot, TestProvenance,
40    },
41    evidence_archive::EvidenceArchiveEntry,
42};
43
44pub const PYTHON_EVIDENCE_VERSION: u32 = 1;
45pub const PYTHON_FRONTEND_VERSION: &str = "python-probes-v1";
46pub const PYTEST_RUNNER: &str = "pytest";
47pub const UNITTEST_RUNNER: &str = "unittest";
48
49const TRANSPORT_MAGIC: &[u8; 8] = b"SCVPYTH1";
50/// Version 2 checksums records with CRC-32; version 1 used FNV-1a, and the
51/// runtime computed it a byte at a time in Python -- two microseconds of every
52/// record's five. Evidence kept from a failed run by an older runtime still
53/// reads, by its own checksum.
54const TRANSPORT_VERSION: u32 = 2;
55const TRANSPORT_VERSION_FNV: u32 = 1;
56const TRANSPORT_HEADER_SIZE: usize = 64;
57const TRANSPORT_RECORD_HEADER_SIZE: usize = 16;
58const TRANSPORT_MAX_RECORD_SIZE: usize = 4 * 1024 * 1024;
59
60fn default_runner() -> String {
61    PYTEST_RUNNER.into()
62}
63
64/// Every field the runtime writes is named so `deny_unknown_fields` keeps
65/// the record shape frozen, even where Rust does not read the value yet.
66#[derive(Debug, Deserialize)]
67#[allow(dead_code)]
68#[serde(tag = "t", rename_all = "lowercase", deny_unknown_fields)]
69enum Record {
70    Process {
71        v: u32,
72        run: String,
73        pid: u64,
74        worker: String,
75        python: String,
76        executable: String,
77        argv: Vec<String>,
78        /// Which observer measured the process. Probes are the only one
79        /// now; the field stays so evidence kept by an earlier runtime reads.
80        #[serde(default)]
81        frontend: Option<String>,
82    },
83    Worker {
84        worker: String,
85    },
86    Phase {
87        ctx: u64,
88        at: i64,
89        worker: String,
90        test: String,
91        retry: usize,
92        phase: String,
93    },
94    Outcome {
95        worker: String,
96        test: String,
97        retry: usize,
98        phase: String,
99        outcome: String,
100        xfail: bool,
101        #[serde(default = "default_runner")]
102        runner: String,
103        /// Where the runner says the test is defined. Absent for adapters or
104        /// synthesised tests that cannot name a file.
105        #[serde(default)]
106        file: Option<String>,
107    },
108    Hit {
109        ctx: u64,
110        id: String,
111    },
112    /// Many hits of one context in one record: the probe frontend harvests
113    /// a context's array at the phase switch and writes what it found at
114    /// once, instead of a record per first hit.
115    Hits {
116        ctx: u64,
117        ids: Vec<String>,
118    },
119    Dec {
120        ctx: u64,
121        id: String,
122        v: String,
123        o: u8,
124    },
125    /// Many decision vectors of one context: (id, digits, outcome).
126    Decs {
127        ctx: u64,
128        v: Vec<(String, String, u8)>,
129    },
130    /// The first assertion of a call phase: what the context recorded before
131    /// this record is the assertion's evidence too.
132    Assert {
133        ctx: u64,
134    },
135    /// One assertion site a call phase reached, once per site per test.
136    /// `unittest` reports the caller's frame and pytest reports the line its
137    /// rewriter recorded; both are resolved against the syntax inventory, and
138    /// a frame naming no inventoried site witnesses nothing.
139    Asite {
140        ctx: u64,
141        f: String,
142        l: usize,
143    },
144    Limitation {
145        id: String,
146        reason: String,
147        #[serde(default)]
148        file: Option<String>,
149        #[serde(default)]
150        obligation: Option<String>,
151    },
152    Exit {
153        at: i64,
154    },
155}
156
157#[derive(Debug)]
158pub enum PythonEvidenceError {
159    Io(String),
160    UnsafeEntry(String),
161    InvalidRecord {
162        file: String,
163        line: usize,
164        reason: String,
165    },
166    InvalidTransport {
167        file: String,
168        reason: String,
169    },
170    DroppedRecords {
171        file: String,
172        count: u64,
173    },
174    RunMismatch {
175        expected: String,
176        actual: String,
177    },
178    UnsupportedVersion(u32),
179    UnknownContext {
180        file: String,
181        line: usize,
182        context: u64,
183    },
184    UnknownObligation(String),
185    InvalidVector {
186        id: String,
187        expected: usize,
188        actual: usize,
189    },
190    NoInterpreter,
191    NoTests,
192    UnsupportedPython(String),
193}
194
195impl std::fmt::Display for PythonEvidenceError {
196    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            Self::Io(reason) => write!(formatter, "could not read Python evidence: {reason}"),
199            Self::UnsafeEntry(name) => write!(formatter, "unsafe Python evidence entry: {name}"),
200            Self::InvalidRecord { file, line, reason } => {
201                write!(formatter, "invalid Python evidence record {file}:{line}: {reason}")
202            }
203            Self::InvalidTransport { file, reason } => {
204                write!(formatter, "invalid Python evidence transport {file}: {reason}")
205            }
206            Self::DroppedRecords { file, count } => write!(
207                formatter,
208                "Python evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
209            ),
210            Self::RunMismatch { expected, actual } => write!(
211                formatter,
212                "Python evidence belongs to run {actual}, expected {expected}"
213            ),
214            Self::UnsupportedVersion(version) => {
215                write!(formatter, "unsupported Python evidence version {version}")
216            }
217            Self::UnknownContext { file, line, context } => write!(
218                formatter,
219                "Python evidence {file}:{line} references undeclared context {context}"
220            ),
221            Self::UnknownObligation(id) => {
222                write!(formatter, "Python runtime reported an unknown obligation: {id}")
223            }
224            Self::InvalidVector {
225                id,
226                expected,
227                actual,
228            } => write!(
229                formatter,
230                "Python decision {id} reported {actual} condition values, expected {expected}"
231            ),
232            Self::NoInterpreter => formatter.write_str(
233                "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)",
234            ),
235            Self::NoTests => formatter.write_str(
236                "the Python run produced no test outcomes; Supercov measures Python through pytest and unittest",
237            ),
238            Self::UnsupportedPython(version) => write!(
239                formatter,
240                "Supercov measures CPython 3.9 or newer; the test command ran Python {version}"
241            ),
242        }
243    }
244}
245
246impl std::error::Error for PythonEvidenceError {}
247
248fn stable_id(prefix: &str, values: &[&str]) -> String {
249    let mut hash = Sha256::new();
250    for value in values {
251        hash.update(value.as_bytes());
252        hash.update([0]);
253    }
254    let digest = hash.finalize();
255    let mut encoded = String::with_capacity(prefix.len() + 25);
256    encoded.push_str(prefix);
257    encoded.push(':');
258    for byte in &digest[..12] {
259        use std::fmt::Write as _;
260        write!(&mut encoded, "{byte:02x}").expect("string formatting");
261    }
262    encoded
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
266struct Identity {
267    worker: String,
268    test: String,
269    retry: usize,
270    phase: String,
271}
272
273type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
274/// (worker, test, retry) -> [(phase, outcome, xfail)]
275type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
276/// (worker, test, retry) -> runner that reported the attempt
277type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
278/// (worker, test, retry) -> the source file the runner named for the test
279type TestFilesByAttempt = BTreeMap<(String, String, usize), String>;
280/// (worker, test, retry) -> assertion sites the call phase reached, in the
281/// order they were first seen, as the runtime reported them: (path, line)
282type SitesByAttempt = BTreeMap<(String, String, usize), Vec<(String, usize)>>;
283
284/// The assertion sites Supercov inventoried from source before the run,
285/// indexed so a runtime frame can name one exactly.
286///
287/// Python reports a file and a line for an assertion, while an assertion
288/// anchor is a file, line and column. The inventory supplies the missing
289/// column and validates the frame: one that names no inventoried site
290/// witnesses nothing, so a wrong frame loses a witness rather than inventing
291/// one.
292pub struct PythonAssertionInventory {
293    root: PathBuf,
294    /// (project-relative file, line) -> the sites on that line
295    columns: BTreeMap<(String, usize), Vec<usize>>,
296}
297
298impl PythonAssertionInventory {
299    pub fn new(root: &Path, inputs: &crate::assertion_map::Inputs) -> Self {
300        let mut columns = BTreeMap::<(String, usize), Vec<usize>>::new();
301        for site in &inputs.assertions {
302            columns
303                .entry((site.at.file.clone(), site.at.line))
304                .or_default()
305                // Every native manifest reports a zero-based byte column and
306                // the report adds one to reach the anchor's own column.
307                .push(site.at.column.saturating_sub(1));
308        }
309        for sites in columns.values_mut() {
310            sites.sort_unstable();
311            sites.dedup();
312        }
313        Self {
314            root: root.to_path_buf(),
315            columns,
316        }
317    }
318
319    /// An inventory with no sites: every frame names nothing, which is what a
320    /// run with no assertion inputs should see.
321    pub fn empty() -> Self {
322        Self {
323            root: PathBuf::new(),
324            columns: BTreeMap::new(),
325        }
326    }
327
328    /// Python reports both forms: a frame's `co_filename` is whatever the
329    /// interpreter loaded, absolute or relative. A path outside the project
330    /// names nothing here.
331    pub fn relative(&self, path: &str) -> Option<String> {
332        let candidate = Path::new(path);
333        let relative = if candidate.is_absolute() {
334            candidate.strip_prefix(&self.root).ok()?
335        } else {
336            candidate.strip_prefix("./").unwrap_or(candidate)
337        };
338        let text = relative.to_string_lossy().replace('\\', "/");
339        (!text.is_empty() && !text.starts_with("../")).then_some(text)
340    }
341
342    /// `file:line:column` when that line holds exactly one inventoried site.
343    /// Two assertions on one line cannot be told apart from a line number, so
344    /// the frame names neither rather than guessing between them.
345    pub fn locate(&self, path: &str, line: usize) -> Option<String> {
346        let file = self.relative(path)?;
347        match self.columns.get(&(file.clone(), line))?.as_slice() {
348            [column] => Some(format!("{file}:{line}:{column}")),
349            _ => None,
350        }
351    }
352}
353
354#[derive(Debug, Default)]
355struct Observations {
356    hits: BTreeSet<String>,
357    vectors: BTreeMap<String, ObservedVectors>,
358}
359
360#[derive(Debug, Clone)]
361struct RuntimeLimitation {
362    id: String,
363    reason: String,
364    file: Option<String>,
365    obligation: Option<String>,
366}
367
368#[derive(Debug, Default)]
369struct Evidence {
370    interpreters: usize,
371    python_versions: BTreeSet<String>,
372    per_identity: BTreeMap<Identity, Observations>,
373    background: BTreeMap<String, Observations>,
374    outcomes: OutcomesByAttempt,
375    runners: RunnersByAttempt,
376    test_files: TestFilesByAttempt,
377    sites: SitesByAttempt,
378    limitations: Vec<RuntimeLimitation>,
379}
380
381fn read_evidence_directory(
382    directory: &Path,
383    run_id: &str,
384) -> Result<Evidence, PythonEvidenceError> {
385    let mut evidence = Evidence::default();
386    let mut files = match fs::read_dir(directory) {
387        Ok(entries) => entries
388            .collect::<Result<Vec<_>, _>>()
389            .map_err(|error| PythonEvidenceError::Io(error.to_string()))?,
390        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
391        Err(error) => return Err(PythonEvidenceError::Io(error.to_string())),
392    };
393    files.sort_by_key(|entry| entry.file_name());
394    let mut transports = Vec::new();
395    let mut slots = BTreeMap::<String, Vec<(String, PathBuf)>>::new();
396    let mut layout_path = None;
397    for entry in files {
398        let name = entry
399            .file_name()
400            .into_string()
401            .map_err(|_| PythonEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
402        if Path::new(&name)
403            .components()
404            .any(|component| !matches!(component, Component::Normal(_)))
405        {
406            return Err(PythonEvidenceError::UnsafeEntry(name));
407        }
408        // A layout write cut short by the process dying; another process of
409        // the run, or none, published the whole file.
410        if name.starts_with(SLOT_LAYOUT_NAME) && name.ends_with(".partial") {
411            continue;
412        }
413        let metadata = fs::symlink_metadata(entry.path())
414            .map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
415        if !metadata.file_type().is_file() {
416            return Err(PythonEvidenceError::UnsafeEntry(name));
417        }
418        if name == SLOT_LAYOUT_NAME {
419            layout_path = Some(entry.path());
420        } else if name.ends_with(".mmap") {
421            transports.push((name, entry.path()));
422        } else if let Some(transport) = name
423            .strip_suffix(".slot")
424            .and_then(|stem| stem.rsplit_once('.'))
425            .filter(|(_, number)| number.parse::<u64>().is_ok())
426            .map(|(transport, _)| format!("{transport}.mmap"))
427        {
428            // Created and killed before it was sized: nothing was written.
429            if metadata.len() > 0 {
430                slots
431                    .entry(transport)
432                    .or_default()
433                    .push((name, entry.path()));
434            }
435        } else {
436            return Err(PythonEvidenceError::UnsafeEntry(name));
437        }
438    }
439    let layout = match layout_path {
440        Some(path) => Some(SlotLayout::read(&path)?),
441        None => None,
442    };
443    for (name, path) in transports {
444        let contents = map_evidence(&path)?;
445        let slot_contents = slots
446            .remove(&name)
447            .unwrap_or_default()
448            .into_iter()
449            .map(|(slot, path)| map_evidence(&path).map(|contents| (slot, contents)))
450            .collect::<Result<Vec<_>, _>>()?;
451        read_evidence_file(
452            &name,
453            &contents,
454            run_id,
455            &mut evidence,
456            &slot_contents,
457            layout.as_ref(),
458        )?;
459    }
460    if let Some((_, orphans)) = slots.into_iter().next() {
461        return Err(PythonEvidenceError::InvalidTransport {
462            file: orphans[0].0.clone(),
463            reason: "a slot was left without its process's transport".into(),
464        });
465    }
466    Ok(evidence)
467}
468
469fn map_evidence(path: &Path) -> Result<Mmap, PythonEvidenceError> {
470    let file = File::open(path).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
471    // The file is immutable from Supercov's perspective after the wrapped
472    // interpreter has exited. No mutable alias is created while this map is
473    // alive.
474    unsafe { MmapOptions::new().map(&file) }
475        .map_err(|error| PythonEvidenceError::Io(error.to_string()))
476}
477
478const SLOT_LAYOUT_NAME: &str = "layout.json";
479/// The widest decision the runtime gives a region, as `supercov_probes`
480/// declares it; a wider one records its vectors directly.
481const SLOT_MAX_REGION_WIDTH: usize = 6;
482
483/// What each byte of a slot stands for: `SlotLayout::header` bytes naming the
484/// context and the layout, one byte per obligation, then a region per decision.
485#[derive(Debug, Deserialize)]
486#[serde(rename_all = "camelCase", deny_unknown_fields)]
487struct SlotLayout {
488    #[allow(dead_code)]
489    version: u32,
490    digest: String,
491    header: usize,
492    bytes: usize,
493    ids: Vec<String>,
494    decisions: Vec<SlotDecision>,
495}
496
497#[derive(Debug, Deserialize)]
498#[serde(rename_all = "camelCase", deny_unknown_fields)]
499struct SlotDecision {
500    start: usize,
501    width: usize,
502    id: String,
503    outcome_true: String,
504    outcome_false: String,
505    logical: Vec<SlotLogical>,
506}
507
508#[derive(Debug, Deserialize)]
509#[serde(rename_all = "camelCase", deny_unknown_fields)]
510struct SlotLogical {
511    evaluated: String,
512    short_circuit: String,
513    previous_leaves: Vec<usize>,
514    operand_leaves: Vec<usize>,
515}
516
517impl SlotDecision {
518    /// A single condition has a byte per truth; a wider decision a byte per
519    /// evaluation mask and outcome, the mask one base-3 digit per condition.
520    fn region_bytes(&self) -> usize {
521        if self.width == 1 {
522            2
523        } else {
524            2 * 3_usize.pow(self.width as u32)
525        }
526    }
527
528    /// The vector a region byte stands for, as the evidence spells it.
529    fn vector(&self, offset: usize) -> (String, bool) {
530        if self.width == 1 {
531            let outcome = offset == 1;
532            return ((if outcome { "2" } else { "1" }).into(), outcome);
533        }
534        let mut mask = offset >> 1;
535        let digits = (0..self.width)
536            .map(|_| {
537                let digit = b"012"[mask % 3] as char;
538                mask /= 3;
539                digit
540            })
541            .collect();
542        (digits, offset & 1 == 1)
543    }
544
545    /// What the runtime's harvest adds for a vector: the outcome hit, and
546    /// each logical operator's alternative the vector decided.
547    fn implied(&self, digits: &str, outcome: bool, into: &mut Vec<String>) {
548        into.push(
549            if outcome {
550                &self.outcome_true
551            } else {
552                &self.outcome_false
553            }
554            .clone(),
555        );
556        let evaluated = |leaves: &[usize]| {
557            leaves.iter().any(|index| {
558                digits
559                    .as_bytes()
560                    .get(*index)
561                    .is_some_and(|digit| *digit != b'0')
562            })
563        };
564        for logical in &self.logical {
565            if evaluated(&logical.operand_leaves) {
566                into.push(logical.evaluated.clone());
567            } else if evaluated(&logical.previous_leaves) {
568                into.push(logical.short_circuit.clone());
569            }
570        }
571    }
572}
573
574impl SlotLayout {
575    fn read(path: &Path) -> Result<Self, PythonEvidenceError> {
576        let invalid = |reason: String| PythonEvidenceError::InvalidTransport {
577            file: SLOT_LAYOUT_NAME.into(),
578            reason,
579        };
580        let bytes = fs::read(path).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
581        let layout: Self =
582            serde_json::from_slice(&bytes).map_err(|error| invalid(error.to_string()))?;
583        if layout.digest.len() < 16 || !layout.digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
584            return Err(invalid("the layout digest is not hex".into()));
585        }
586        // Regions follow the obligations back to back, in slot order, and end
587        // where the slot does: a byte stands for exactly one thing.
588        let mut cursor = layout.header + layout.ids.len();
589        for decision in &layout.decisions {
590            if decision.start != cursor
591                || decision.width == 0
592                || decision.width > SLOT_MAX_REGION_WIDTH
593            {
594                return Err(invalid(format!(
595                    "decision {} has no valid region",
596                    decision.id
597                )));
598            }
599            if decision
600                .logical
601                .iter()
602                .flat_map(|logical| {
603                    logical
604                        .previous_leaves
605                        .iter()
606                        .chain(&logical.operand_leaves)
607                })
608                .any(|leaf| *leaf >= decision.width)
609            {
610                return Err(invalid(format!(
611                    "decision {} names a condition it lacks",
612                    decision.id
613                )));
614            }
615            cursor += decision.region_bytes();
616        }
617        if cursor != layout.bytes || layout.header < 16 {
618            return Err(invalid("the regions do not fill the slot".into()));
619        }
620        Ok(layout)
621    }
622
623    fn tag(&self) -> Vec<u8> {
624        (0..8)
625            .map(|index| {
626                u8::from_str_radix(&self.digest[index * 2..index * 2 + 2], 16).unwrap_or(0)
627            })
628            .collect()
629    }
630
631    /// A slot's bytes as the records a harvest would have written from them.
632    fn records(&self, name: &str, contents: &[u8]) -> Result<Vec<Record>, PythonEvidenceError> {
633        let invalid = |reason: &str| PythonEvidenceError::InvalidTransport {
634            file: name.into(),
635            reason: reason.into(),
636        };
637        let tag = contents
638            .get(8..16)
639            .ok_or_else(|| invalid("slot header is missing"))?;
640        // Its process closed it after a final harvest: whatever a thread wrote
641        // after that belongs to no test.
642        if tag.iter().all(|byte| *byte == 0) {
643            return Ok(Vec::new());
644        }
645        if tag != self.tag().as_slice() {
646            return Err(invalid("slot was written for another layout"));
647        }
648        if contents.len() != self.bytes {
649            return Err(invalid("slot size does not match the layout"));
650        }
651        let context =
652            transport_u64(contents, 0).ok_or_else(|| invalid("slot context is missing"))?;
653        let id_end = self.header + self.ids.len();
654        let mut ids = Vec::new();
655        let mut vectors = Vec::new();
656        for (index, byte) in contents.iter().enumerate().skip(self.header) {
657            match byte {
658                0 => continue,
659                1 => {}
660                _ => return Err(invalid("slot byte is neither 0 nor 1")),
661            }
662            if index < id_end {
663                ids.push(self.ids[index - self.header].clone());
664                continue;
665            }
666            let decision = &self.decisions[self
667                .decisions
668                .partition_point(|decision| decision.start <= index)
669                - 1];
670            let (digits, outcome) = decision.vector(index - decision.start);
671            decision.implied(&digits, outcome, &mut ids);
672            vectors.push((decision.id.clone(), digits, u8::from(outcome)));
673        }
674        let mut records = Vec::new();
675        if !ids.is_empty() {
676            records.push(Record::Hits { ctx: context, ids });
677        }
678        if !vectors.is_empty() {
679            records.push(Record::Decs {
680                ctx: context,
681                v: vectors,
682            });
683        }
684        Ok(records)
685    }
686}
687
688fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
689    bytes
690        .get(offset..offset + 4)
691        .and_then(|value| value.try_into().ok())
692        .map(u32::from_le_bytes)
693}
694
695fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
696    bytes
697        .get(offset..offset + 8)
698        .and_then(|value| value.try_into().ok())
699        .map(u64::from_le_bytes)
700}
701
702fn transport_checksum(payload: &[u8], version: u32) -> u32 {
703    if version == TRANSPORT_VERSION_FNV {
704        return payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
705            (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
706        });
707    }
708    crc32fast::hash(payload)
709}
710
711fn align_transport(value: usize) -> Option<usize> {
712    value.checked_add(7).map(|value| value & !7)
713}
714
715fn read_evidence_file(
716    name: &str,
717    contents: &Mmap,
718    run_id: &str,
719    evidence: &mut Evidence,
720    slots: &[(String, Mmap)],
721    layout: Option<&SlotLayout>,
722) -> Result<(), PythonEvidenceError> {
723    let invalid_transport = |reason: &str| PythonEvidenceError::InvalidTransport {
724        file: name.into(),
725        reason: reason.into(),
726    };
727    let version = transport_u32(contents, 8);
728    if contents.len() < TRANSPORT_HEADER_SIZE
729        || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
730        || !matches!(version, Some(TRANSPORT_VERSION | TRANSPORT_VERSION_FNV))
731        || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
732    {
733        return Err(invalid_transport("header or version does not match"));
734    }
735    let version = version.unwrap_or(TRANSPORT_VERSION);
736    let declared_capacity =
737        transport_u64(contents, 16).ok_or_else(|| invalid_transport("capacity is missing"))?;
738    if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
739    {
740        return Err(invalid_transport(
741            "declared capacity is outside the mapped file",
742        ));
743    }
744    let dropped =
745        transport_u64(contents, 24).ok_or_else(|| invalid_transport("drop counter is missing"))?;
746    if dropped != 0 {
747        return Err(PythonEvidenceError::DroppedRecords {
748            file: name.into(),
749            count: dropped,
750        });
751    }
752    let transport_pid = transport_u64(contents, 32)
753        .filter(|pid| *pid != 0)
754        .ok_or_else(|| invalid_transport("process id is missing"))?;
755    let mut contexts = BTreeMap::<u64, Identity>::new();
756    // What each call phase recorded so far, kept until its first assertion
757    // marker moves it to the phase's assertion identity.
758    let mut before_assertion = BTreeMap::<u64, Observations>::new();
759    let mut process_worker: Option<String> = None;
760    let mut apply = |line_number: usize, record: Record| -> Result<(), PythonEvidenceError> {
761        let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
762            file: name.into(),
763            line: line_number,
764            reason: reason.into(),
765        };
766        match record {
767            Record::Process {
768                v,
769                run,
770                pid,
771                worker,
772                python,
773                ..
774            } => {
775                if v != PYTHON_EVIDENCE_VERSION {
776                    return Err(PythonEvidenceError::UnsupportedVersion(v));
777                }
778                if run != run_id {
779                    return Err(PythonEvidenceError::RunMismatch {
780                        expected: run_id.into(),
781                        actual: run,
782                    });
783                }
784                if pid != transport_pid {
785                    return Err(invalid("process record does not match the transport owner"));
786                }
787                let supported = python
788                    .split('.')
789                    .take(2)
790                    .map(|part| part.parse::<u32>().ok())
791                    .collect::<Option<Vec<_>>>()
792                    // The runtime measures 3.9 and newer and refuses to
793                    // install on anything older.
794                    .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 9));
795                if !supported {
796                    return Err(PythonEvidenceError::UnsupportedPython(python));
797                }
798                evidence.interpreters += 1;
799                evidence.python_versions.insert(python);
800                process_worker = Some(worker);
801            }
802            Record::Worker { worker } => process_worker = Some(worker),
803            Record::Phase {
804                ctx,
805                worker,
806                test,
807                retry,
808                phase,
809                ..
810            } => {
811                if ctx == 0 {
812                    return Err(invalid("phase context 0 is reserved for background"));
813                }
814                if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
815                    return Err(invalid("unknown pytest phase"));
816                }
817                if test.trim().is_empty() || worker.trim().is_empty() {
818                    return Err(invalid("phase identity must name a worker and test"));
819                }
820                if phase == "call" {
821                    before_assertion.insert(ctx, Observations::default());
822                }
823                contexts.insert(
824                    ctx,
825                    Identity {
826                        worker,
827                        test,
828                        retry,
829                        phase,
830                    },
831                );
832            }
833            Record::Outcome {
834                worker,
835                test,
836                retry,
837                phase,
838                outcome,
839                xfail,
840                runner,
841                file,
842            } => {
843                if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
844                    return Err(invalid("unknown test outcome phase"));
845                }
846                if !matches!(
847                    outcome.as_str(),
848                    "passed" | "failed" | "skipped" | "rerun" | "error"
849                ) {
850                    return Err(invalid("unknown test outcome"));
851                }
852                if !matches!(runner.as_str(), PYTEST_RUNNER | UNITTEST_RUNNER) {
853                    return Err(invalid("unknown Python test runner"));
854                }
855                let key = (worker, test, retry);
856                if let Some(previous) = evidence.runners.get(&key)
857                    && previous != &runner
858                {
859                    return Err(invalid("one attempt was reported by two runners"));
860                }
861                evidence.runners.insert(key.clone(), runner);
862                if let Some(file) = file.filter(|path| !path.is_empty()) {
863                    evidence.test_files.entry(key.clone()).or_insert(file);
864                }
865                fold_outcome(
866                    evidence.outcomes.entry(key).or_default(),
867                    phase,
868                    outcome,
869                    xfail,
870                );
871            }
872            Record::Hit { ctx, id } => {
873                if let Some(before) = before_assertion.get_mut(&ctx) {
874                    before.hits.insert(id.clone());
875                }
876                observations(
877                    evidence,
878                    &contexts,
879                    process_worker.as_deref(),
880                    ctx,
881                    name,
882                    line_number,
883                )?
884                .hits
885                .insert(id);
886            }
887            Record::Hits { ctx, ids } => {
888                for id in ids {
889                    if let Some(before) = before_assertion.get_mut(&ctx) {
890                        before.hits.insert(id.clone());
891                    }
892                    observations(
893                        evidence,
894                        &contexts,
895                        process_worker.as_deref(),
896                        ctx,
897                        name,
898                        line_number,
899                    )?
900                    .hits
901                    .insert(id);
902                }
903            }
904            Record::Dec { ctx, id, v, o } => {
905                if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
906                    return Err(invalid("decision vector digits must be 0, 1 or 2"));
907                }
908                if o > 1 {
909                    return Err(invalid("decision outcome must be 0 or 1"));
910                }
911                let values = v
912                    .bytes()
913                    .map(|digit| match digit {
914                        b'0' => None,
915                        b'1' => Some(false),
916                        _ => Some(true),
917                    })
918                    .collect::<Vec<_>>();
919                if let Some(before) = before_assertion.get_mut(&ctx) {
920                    before
921                        .vectors
922                        .entry(id.clone())
923                        .or_default()
924                        .insert((values.clone(), o == 1));
925                }
926                observations(
927                    evidence,
928                    &contexts,
929                    process_worker.as_deref(),
930                    ctx,
931                    name,
932                    line_number,
933                )?
934                .vectors
935                .entry(id)
936                .or_default()
937                .insert((values, o == 1));
938            }
939            Record::Decs { ctx, v: vectors } => {
940                for (id, v, o) in vectors {
941                    if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
942                        return Err(invalid("decision vector digits must be 0, 1 or 2"));
943                    }
944                    if o > 1 {
945                        return Err(invalid("decision outcome must be 0 or 1"));
946                    }
947                    let values = v
948                        .bytes()
949                        .map(|digit| match digit {
950                            b'0' => None,
951                            b'1' => Some(false),
952                            _ => Some(true),
953                        })
954                        .collect::<Vec<_>>();
955                    if let Some(before) = before_assertion.get_mut(&ctx) {
956                        before
957                            .vectors
958                            .entry(id.clone())
959                            .or_default()
960                            .insert((values.clone(), o == 1));
961                    }
962                    observations(
963                        evidence,
964                        &contexts,
965                        process_worker.as_deref(),
966                        ctx,
967                        name,
968                        line_number,
969                    )?
970                    .vectors
971                    .entry(id)
972                    .or_default()
973                    .insert((values, o == 1));
974                }
975            }
976            Record::Assert { ctx } => {
977                // Only the first marker of a call phase moves anything; a
978                // later one, or one outside a call phase, is inert.
979                if let Some(before) = before_assertion.remove(&ctx) {
980                    let identity =
981                        contexts
982                            .get(&ctx)
983                            .ok_or(PythonEvidenceError::UnknownContext {
984                                file: name.into(),
985                                line: line_number,
986                                context: ctx,
987                            })?;
988                    let asserted = evidence
989                        .per_identity
990                        .entry(Identity {
991                            phase: "assertion".into(),
992                            ..identity.clone()
993                        })
994                        .or_default();
995                    asserted.hits.extend(before.hits);
996                    for (id, vectors) in before.vectors {
997                        asserted.vectors.entry(id).or_default().extend(vectors);
998                    }
999                }
1000            }
1001            Record::Asite { ctx, f, l } => {
1002                if f.is_empty() || l == 0 {
1003                    return Err(invalid("assertion site needs a file and a line"));
1004                }
1005                let identity = contexts
1006                    .get(&ctx)
1007                    .ok_or(PythonEvidenceError::UnknownContext {
1008                        file: name.into(),
1009                        line: line_number,
1010                        context: ctx,
1011                    })?;
1012                // Only the call phase witnesses a test's assertions; setup and
1013                // teardown assertions belong to no single site under test.
1014                if identity.phase == "call" {
1015                    let key = (
1016                        identity.worker.clone(),
1017                        identity.test.clone(),
1018                        identity.retry,
1019                    );
1020                    let sites = evidence.sites.entry(key).or_default();
1021                    let site = (f, l);
1022                    if !sites.contains(&site) {
1023                        sites.push(site);
1024                    }
1025                }
1026            }
1027            Record::Limitation {
1028                id,
1029                reason,
1030                file,
1031                obligation,
1032            } => evidence.limitations.push(RuntimeLimitation {
1033                id,
1034                reason,
1035                file,
1036                obligation,
1037            }),
1038            Record::Exit { .. } => {}
1039        }
1040        Ok(())
1041    };
1042    let mut cursor = TRANSPORT_HEADER_SIZE;
1043    let mut record_index = 0;
1044    while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
1045        let commit = contents[cursor];
1046        if commit == 0 {
1047            // Payload bytes can exist after a killed writer, but an absent
1048            // commit byte makes that frame and every later zeroed frame inert.
1049            break;
1050        }
1051        record_index += 1;
1052        let line_number = record_index;
1053        let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
1054            file: name.into(),
1055            line: line_number,
1056            reason: reason.into(),
1057        };
1058        if commit != 1
1059            || contents[cursor + 1..cursor + 4] != [0, 0, 0]
1060            || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
1061        {
1062            return Err(invalid("commit marker or reserved bytes are invalid"));
1063        }
1064        let length = transport_u32(contents, cursor + 4)
1065            .map(|value| value as usize)
1066            .ok_or_else(|| invalid("payload length is missing"))?;
1067        if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
1068            return Err(invalid("payload length is outside the transport bound"));
1069        }
1070        let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
1071        let payload_end = payload_start
1072            .checked_add(length)
1073            .filter(|end| *end <= contents.len())
1074            .ok_or_else(|| invalid("payload extends past the mapped file"))?;
1075        let next_cursor = align_transport(payload_end)
1076            .filter(|end| *end <= contents.len())
1077            .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
1078        if contents[payload_end..next_cursor]
1079            .iter()
1080            .any(|byte| *byte != 0)
1081        {
1082            return Err(invalid("frame padding is not zero"));
1083        }
1084        let payload = &contents[payload_start..payload_end];
1085        let expected_checksum = transport_u32(contents, cursor + 8)
1086            .ok_or_else(|| invalid("payload checksum is missing"))?;
1087        if transport_checksum(payload, version) != expected_checksum {
1088            return Err(invalid("payload checksum does not match"));
1089        }
1090        let record: Record = serde_json::from_slice(payload).map_err(|error| {
1091            PythonEvidenceError::InvalidRecord {
1092                file: name.into(),
1093                line: line_number,
1094                reason: error.to_string(),
1095            }
1096        })?;
1097        apply(line_number, record)?;
1098        cursor = next_cursor;
1099    }
1100    // What the process's slots still held when it ended: after every record
1101    // of its transport, as the harvest it never reached would have been.
1102    for (slot, slot_contents) in slots {
1103        let layout = layout.ok_or_else(|| PythonEvidenceError::InvalidTransport {
1104            file: slot.clone(),
1105            reason: "a slot was left without the run's slot layout".into(),
1106        })?;
1107        for record in layout.records(slot, slot_contents)? {
1108            record_index += 1;
1109            apply(record_index, record)?;
1110        }
1111    }
1112    Ok(())
1113}
1114
1115fn observations<'a>(
1116    evidence: &'a mut Evidence,
1117    contexts: &BTreeMap<u64, Identity>,
1118    process_worker: Option<&str>,
1119    context: u64,
1120    file: &str,
1121    line: usize,
1122) -> Result<&'a mut Observations, PythonEvidenceError> {
1123    if context == 0 {
1124        return Ok(evidence
1125            .background
1126            .entry(process_worker.unwrap_or("main").to_owned())
1127            .or_default());
1128    }
1129    let identity = contexts
1130        .get(&context)
1131        .ok_or(PythonEvidenceError::UnknownContext {
1132            file: file.into(),
1133            line,
1134            context,
1135        })?;
1136    Ok(evidence.per_identity.entry(identity.clone()).or_default())
1137}
1138
1139/// Python is observed through probes compiled into each measured module as it
1140/// is imported, from the obligations the plan derived from the source.
1141pub fn python_coverage_model() -> CoverageModelDeclaration {
1142    CoverageModelDeclaration {
1143        language: "python".into(),
1144        variant: "python-owned-probes".into(),
1145        name: PYTHON_FRONTEND_VERSION.into(),
1146        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(),
1147        measured: vec![
1148            "executable statements, each by a probe placed before it".into(),
1149            "function and lambda entry".into(),
1150            "boolean decision vectors with masking MC/DC, each condition's value taken as the program evaluated it".into(),
1151            "for-loop and comprehension zero-versus-entered iteration".into(),
1152            "logical and/or short-circuit alternatives".into(),
1153            "match case selection and guards".into(),
1154            "try completion, handler selection and exception propagation".into(),
1155            "pytest and unittest worker, test, retry and setup/call/teardown phase identity".into(),
1156            "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
1157        ],
1158        not_measured: vec![
1159            "causal linkage to individual actions, or to any assertion after a test's first".into(),
1160            "code compiled from strings at runtime".into(),
1161            "measured modules imported before Supercov installed, or compiled past the import system; the run names each as a limitation".into(),
1162            "causal test context for raw _thread or native-extension-created threads".into(),
1163            "child coverage outside subprocess.Popen and multiprocessing adapters".into(),
1164            "all input values, semantic partitions, paths, or concurrency interleavings".into(),
1165            "mutation score or assertion fault-detection strength".into(),
1166        ],
1167    }
1168}
1169
1170fn phase_id(run: &str, identity: &Identity) -> String {
1171    stable_id(
1172        "python-phase",
1173        &[
1174            run,
1175            &identity.worker,
1176            &identity.test,
1177            &identity.retry.to_string(),
1178            &identity.phase,
1179        ],
1180    )
1181}
1182
1183fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
1184    ExecutionScope {
1185        version: 1,
1186        run_id: run.into(),
1187        worker_id: worker.into(),
1188        test_id: test.into(),
1189        test_key: stable_id("python-test", &[worker, test]),
1190        retry,
1191        attempt_id: stable_id("python-attempt", &[run, worker, test, &retry.to_string()]),
1192    }
1193}
1194
1195struct ManifestIndex<'a> {
1196    points: BTreeSet<&'a str>,
1197    alternatives: BTreeSet<&'a str>,
1198    decisions: BTreeMap<&'a str, &'a DecisionMeta>,
1199    lines: BTreeMap<&'a str, (String, usize)>,
1200}
1201
1202impl<'a> ManifestIndex<'a> {
1203    fn new(manifest: &'a CoverageManifest) -> Self {
1204        let mut lines = BTreeMap::new();
1205        for point in &manifest.points {
1206            lines.insert(point.id.as_str(), (point.file.clone(), point.line));
1207        }
1208        for decision in &manifest.decisions {
1209            lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
1210        }
1211        for branch in &manifest.branches {
1212            lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
1213        }
1214        Self {
1215            points: manifest
1216                .points
1217                .iter()
1218                .map(|point| point.id.as_str())
1219                .collect(),
1220            alternatives: manifest
1221                .branches
1222                .iter()
1223                .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
1224                .collect(),
1225            decisions: manifest
1226                .decisions
1227                .iter()
1228                .map(|decision| (decision.id.as_str(), decision))
1229                .collect(),
1230            lines,
1231        }
1232    }
1233}
1234
1235fn snapshot(
1236    index: &ManifestIndex<'_>,
1237    observations: &Observations,
1238    phase: &str,
1239) -> Result<RuntimeSnapshot, PythonEvidenceError> {
1240    let mut hits = BTreeSet::new();
1241    for id in &observations.hits {
1242        if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
1243            return Err(PythonEvidenceError::UnknownObligation(id.clone()));
1244        }
1245        hits.insert(id.clone());
1246    }
1247    let mut decisions = Vec::new();
1248    let mut events = Vec::new();
1249    let mut clock = 1;
1250    for id in &hits {
1251        events.push(RuntimeEvent {
1252            event_type: "hit".into(),
1253            id: id.clone(),
1254            vector: None,
1255            timestamp_ms: clock,
1256            phase_id: Some(phase.into()),
1257            statement_id: None,
1258            environment: "python".into(),
1259        });
1260        clock += 1;
1261    }
1262    for (id, vectors) in &observations.vectors {
1263        let Some(meta) = index.decisions.get(id.as_str()) else {
1264            return Err(PythonEvidenceError::UnknownObligation(id.clone()));
1265        };
1266        let mut observed = Vec::new();
1267        for (values, outcome) in vectors {
1268            if values.len() != meta.conditions.len() {
1269                return Err(PythonEvidenceError::InvalidVector {
1270                    id: id.clone(),
1271                    expected: meta.conditions.len(),
1272                    actual: values.len(),
1273                });
1274            }
1275            let vector = McdcVector {
1276                values: values.clone(),
1277                outcome: *outcome,
1278            };
1279            events.push(RuntimeEvent {
1280                event_type: "decision".into(),
1281                id: id.clone(),
1282                vector: Some(vector.clone()),
1283                timestamp_ms: clock,
1284                phase_id: Some(phase.into()),
1285                statement_id: None,
1286                environment: "python".into(),
1287            });
1288            clock += 1;
1289            observed.push(vector);
1290        }
1291        decisions.push(DecisionSnapshot {
1292            meta: (*meta).clone(),
1293            vectors: observed,
1294        });
1295    }
1296    Ok(RuntimeSnapshot {
1297        decisions,
1298        hits: hits.into_iter().collect(),
1299        events,
1300        logicals: Vec::new(),
1301    })
1302}
1303
1304/// One phase, however many times its runner reported it. pytest-subtests
1305/// reports every subtest under the same node id and `call` phase, so an
1306/// attempt carries several outcomes for the identity a phase id is derived
1307/// from; each became a phase of its own with the same id, and the run was
1308/// refused whole. The outcomes fold the way an attempt's do -- a failure
1309/// anywhere is the phase's, then a skip -- because that is what the phase
1310/// ended up doing, and the coverage recorded under that identity is one
1311/// phase's however the runner chose to narrate it.
1312fn fold_outcome(
1313    outcomes: &mut Vec<(String, String, bool)>,
1314    phase: String,
1315    outcome: String,
1316    xfail: bool,
1317) {
1318    let Some(reported) = outcomes.iter_mut().find(|(name, _, _)| *name == phase) else {
1319        outcomes.push((phase, outcome, xfail));
1320        return;
1321    };
1322    if outcome_severity(&outcome) > outcome_severity(&reported.1) {
1323        reported.1 = outcome;
1324    }
1325    reported.2 |= xfail;
1326}
1327
1328/// The order `attempt_status` reads outcomes in, as one value.
1329fn outcome_severity(outcome: &str) -> u8 {
1330    match outcome {
1331        "failed" | "rerun" | "error" => 2,
1332        "skipped" => 1,
1333        _ => 0,
1334    }
1335}
1336
1337fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
1338    if outcomes
1339        .iter()
1340        .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
1341    {
1342        "failed"
1343    } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
1344        "skipped"
1345    } else {
1346        "passed"
1347    }
1348    .into()
1349}
1350
1351#[derive(Debug, Clone, PartialEq)]
1352pub struct PythonFrontendRun {
1353    pub declaration: FrontendRunDeclaration,
1354    pub request: CoverageReportRequest,
1355    pub tests: usize,
1356    pub interpreters: usize,
1357    pub python_versions: Vec<String>,
1358}
1359
1360impl PythonFrontendRun {
1361    pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
1362        let model = PersistedCoverageModel::from_declaration(
1363            self.request
1364                .coverage_model
1365                .as_ref()
1366                .expect("Python frontend always declares a coverage model"),
1367        )
1368        .expect("Python coverage model is contract-valid");
1369        let mut entries = vec![
1370            EvidenceArchiveEntry {
1371                path: "coverage-model.json".into(),
1372                contents: serde_json::to_vec(&model)?,
1373            },
1374            EvidenceArchiveEntry {
1375                path: "frontend.json".into(),
1376                contents: serde_json::to_vec(&self.declaration)?,
1377            },
1378            EvidenceArchiveEntry {
1379                path: "manifest.json".into(),
1380                contents: serde_json::to_vec(&self.request.manifest)?,
1381            },
1382        ];
1383        for (index, result) in self.request.raw_results.iter().enumerate() {
1384            entries.push(EvidenceArchiveEntry {
1385                path: format!("results/{index:08}/mcdc.json"),
1386                contents: serde_json::to_vec(result)?,
1387            });
1388        }
1389        Ok(entries)
1390    }
1391}
1392
1393/// Join the runtime's evidence directory with the ahead-of-run manifest into
1394/// a protocol-conformant frontend run.
1395pub fn build_python_frontend_run(
1396    manifest: &CoverageManifest,
1397    evidence_directory: &Path,
1398    run_id: &str,
1399    generated_at: &str,
1400    test_exit_code: i32,
1401    assertions: &PythonAssertionInventory,
1402) -> Result<PythonFrontendRun, PythonEvidenceError> {
1403    let evidence = read_evidence_directory(evidence_directory, run_id)?;
1404    if evidence.interpreters == 0 {
1405        return Err(PythonEvidenceError::NoInterpreter);
1406    }
1407    if evidence.outcomes.is_empty() {
1408        return Err(PythonEvidenceError::NoTests);
1409    }
1410    let Evidence {
1411        interpreters,
1412        python_versions,
1413        per_identity,
1414        background,
1415        outcomes,
1416        runners,
1417        test_files,
1418        sites,
1419        limitations,
1420    } = evidence;
1421    let mut manifest = manifest.clone();
1422    let index = ManifestIndex::new(&manifest);
1423
1424    let mut raw_results = Vec::new();
1425    let mut observed_runners = BTreeSet::new();
1426    let mut identities_by_attempt =
1427        BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1428    for (identity, observations) in &per_identity {
1429        identities_by_attempt
1430            .entry((
1431                identity.worker.clone(),
1432                identity.test.clone(),
1433                identity.retry,
1434            ))
1435            .or_default()
1436            .push((identity, observations));
1437    }
1438    for ((worker, test, retry), mut outcomes) in outcomes {
1439        let runner = runners
1440            .get(&(worker.clone(), test.clone(), retry))
1441            .cloned()
1442            .unwrap_or_else(default_runner);
1443        let attempt_identities = identities_by_attempt
1444            .remove(&(worker.clone(), test.clone(), retry))
1445            .unwrap_or_default();
1446        observed_runners.insert(runner.clone());
1447        outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1448            "setup" => 0,
1449            "call" => 1,
1450            _ => 2,
1451        });
1452        let mut phases = Vec::new();
1453        let mut runtime = Vec::new();
1454        let mut observed_phases = BTreeSet::new();
1455        for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1456            observed_phases.insert(phase_name.clone());
1457            let identity = Identity {
1458                worker: worker.clone(),
1459                test: test.clone(),
1460                retry,
1461                phase: phase_name.clone(),
1462            };
1463            let id = phase_id(run_id, &identity);
1464            phases.push(CoveragePhase {
1465                id: id.clone(),
1466                kind: match phase_name.as_str() {
1467                    "call" => "test",
1468                    value => value,
1469                }
1470                .into(),
1471                operation: format!("{runner} {phase_name}"),
1472                source: Some(test.clone()),
1473                caused_by_phase_id: None,
1474                started_at_ms: position as i64 * 2 + 1,
1475                ended_at_ms: Some(position as i64 * 2 + 2),
1476                status: Some(match outcome.as_str() {
1477                    "rerun" | "error" => "failed".into(),
1478                    value => value.into(),
1479                }),
1480                error: None,
1481            });
1482            if let Some((_, observations)) = attempt_identities
1483                .iter()
1484                .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1485            {
1486                runtime.push(snapshot(&index, observations, &id)?);
1487            }
1488            if phase_name != "call" {
1489                continue;
1490            }
1491            // What the test recorded before its first assertion is that
1492            // assertion's evidence, linked when the phase passed outright:
1493            // a failed, skipped or expected-to-fail phase witnessed nothing.
1494            if let Some((identity, observations)) = attempt_identities
1495                .iter()
1496                .find(|(candidate, _)| candidate.phase == "assertion")
1497            {
1498                observed_phases.insert("assertion".to_owned());
1499                let id = phase_id(run_id, identity);
1500                phases.push(CoveragePhase {
1501                    id: id.clone(),
1502                    kind: "assertion".into(),
1503                    operation: format!("{runner} assertion"),
1504                    source: Some(test.clone()),
1505                    caused_by_phase_id: None,
1506                    started_at_ms: position as i64 * 2 + 1,
1507                    ended_at_ms: Some(position as i64 * 2 + 2),
1508                    status: Some(
1509                        if outcome == "passed" && !*xfail {
1510                            "passed"
1511                        } else {
1512                            "failed"
1513                        }
1514                        .into(),
1515                    ),
1516                    error: None,
1517                });
1518                runtime.push(snapshot(&index, observations, &id)?);
1519                // One phase per assertion site the call phase reached, so an
1520                // assertion map can tell the sites apart. The per-test phase
1521                // above keeps carrying the pre-assertion evidence; these are
1522                // witnesses only, and a site the inventory does not know is
1523                // skipped rather than guessed at.
1524                let attempt = (worker.clone(), test.clone(), retry);
1525                // Keyed on the located site, not on the spelling reported:
1526                // two spellings of one file name one site, and a phase id
1527                // minted twice refuses the run, as it did in #40.
1528                let mut located = BTreeSet::new();
1529                for (path, line) in sites.get(&attempt).into_iter().flatten() {
1530                    let Some(location) = assertions.locate(path, *line) else {
1531                        continue;
1532                    };
1533                    if !located.insert(location.clone()) {
1534                        continue;
1535                    }
1536                    phases.push(CoveragePhase {
1537                        id: stable_id("python-assertion", &[run_id, &id, &location]),
1538                        kind: "assertion".into(),
1539                        operation: format!("{runner} assertion at {location}"),
1540                        source: Some(location),
1541                        caused_by_phase_id: Some(id.clone()),
1542                        started_at_ms: position as i64 * 2 + 1,
1543                        ended_at_ms: Some(position as i64 * 2 + 2),
1544                        status: Some(
1545                            if outcome == "passed" && !*xfail {
1546                                "passed"
1547                            } else {
1548                                "failed"
1549                            }
1550                            .into(),
1551                        ),
1552                        error: None,
1553                    });
1554                }
1555            }
1556        }
1557        // A phase the runtime entered but pytest never reported (the worker
1558        // died inside it) is a failed phase with its evidence kept.
1559        for (identity, observations) in attempt_identities {
1560            if !observed_phases.contains(&identity.phase) {
1561                let id = phase_id(run_id, identity);
1562                phases.push(CoveragePhase {
1563                    id: id.clone(),
1564                    kind: match identity.phase.as_str() {
1565                        "call" => "test",
1566                        value => value,
1567                    }
1568                    .into(),
1569                    operation: format!("{runner} {}", identity.phase),
1570                    source: Some(test.clone()),
1571                    caused_by_phase_id: None,
1572                    started_at_ms: phases.len() as i64 * 2 + 1,
1573                    ended_at_ms: None,
1574                    status: Some("failed".into()),
1575                    error: Some("the phase started but the runner reported no outcome".into()),
1576                });
1577                runtime.push(snapshot(&index, observations, &id)?);
1578            }
1579        }
1580        let status = if phases.iter().any(|phase| phase.error.is_some()) {
1581            "failed".into()
1582        } else {
1583            attempt_status(&outcomes)
1584        };
1585        raw_results.push(RawTestResult {
1586            test_id: Some(test.clone()),
1587            scope: Some(scope(run_id, &worker, &test, retry)),
1588            test: test.clone(),
1589            // What the runner named, as the project names it. A dotted
1590            // module path or a pytest node id is not a path, so the old
1591            // derivation stays only as a fallback.
1592            test_file: test_files
1593                .get(&(worker.clone(), test.clone(), retry))
1594                .and_then(|path| assertions.relative(path))
1595                .or_else(|| test.split("::").next().map(str::to_owned)),
1596            title: test.rsplit("::").next().map(str::to_owned),
1597            retry: Some(retry),
1598            status: Some(status),
1599            expected_status: Some(
1600                if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1601                    "failed"
1602                } else {
1603                    "passed"
1604                }
1605                .into(),
1606            ),
1607            flaky: false,
1608            provenance: TestProvenance {
1609                runner: runner.clone(),
1610                kind: "unit".into(),
1611                project: None,
1612                source: PYTHON_FRONTEND_VERSION.into(),
1613            },
1614            role: "test".into(),
1615            attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1616            phases,
1617            runtime,
1618            browser: Vec::new(),
1619            server: Vec::new(),
1620        });
1621    }
1622    // Phases with observations whose test never produced any outcome at all
1623    // (for example a worker killed during its first phase).
1624    let default_observed = observed_runners
1625        .iter()
1626        .next()
1627        .cloned()
1628        .unwrap_or_else(default_runner);
1629    for ((worker, test, retry), identities) in identities_by_attempt {
1630        let runner = default_observed.clone();
1631        let mut phases = Vec::new();
1632        let mut runtime = Vec::new();
1633        for (position, (identity, observations)) in identities.iter().enumerate() {
1634            let id = phase_id(run_id, identity);
1635            phases.push(CoveragePhase {
1636                id: id.clone(),
1637                kind: match identity.phase.as_str() {
1638                    "call" => "test",
1639                    value => value,
1640                }
1641                .into(),
1642                operation: format!("{runner} {}", identity.phase),
1643                source: Some(test.clone()),
1644                caused_by_phase_id: None,
1645                started_at_ms: position as i64 * 2 + 1,
1646                ended_at_ms: None,
1647                status: Some("failed".into()),
1648                error: Some("the phase started but the runner reported no outcome".into()),
1649            });
1650            runtime.push(snapshot(&index, observations, &id)?);
1651        }
1652        raw_results.push(RawTestResult {
1653            test_id: Some(test.clone()),
1654            scope: Some(scope(run_id, &worker, &test, retry)),
1655            test: test.clone(),
1656            // What the runner named, as the project names it. A dotted
1657            // module path or a pytest node id is not a path, so the old
1658            // derivation stays only as a fallback.
1659            test_file: test_files
1660                .get(&(worker.clone(), test.clone(), retry))
1661                .and_then(|path| assertions.relative(path))
1662                .or_else(|| test.split("::").next().map(str::to_owned)),
1663            title: test.rsplit("::").next().map(str::to_owned),
1664            retry: Some(retry),
1665            status: Some("failed".into()),
1666            expected_status: Some("passed".into()),
1667            flaky: false,
1668            provenance: TestProvenance {
1669                runner: runner.clone(),
1670                kind: "unit".into(),
1671                project: None,
1672                source: PYTHON_FRONTEND_VERSION.into(),
1673            },
1674            role: "test".into(),
1675            attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1676            phases,
1677            runtime,
1678            browser: Vec::new(),
1679            server: Vec::new(),
1680        });
1681    }
1682    for (worker, observations) in &background {
1683        if observations.hits.is_empty() && observations.vectors.is_empty() {
1684            continue;
1685        }
1686        let test = format!("__supercov_background__:{worker}");
1687        let identity = Identity {
1688            worker: worker.clone(),
1689            test: test.clone(),
1690            retry: 0,
1691            phase: "background".into(),
1692        };
1693        let phase = phase_id(run_id, &identity);
1694        raw_results.push(RawTestResult {
1695            test_id: Some(test.clone()),
1696            scope: Some(scope(run_id, worker, &test, 0)),
1697            test: "Python import, collection and background execution".into(),
1698            test_file: None,
1699            title: None,
1700            retry: Some(0),
1701            status: Some("unknown".into()),
1702            expected_status: None,
1703            flaky: false,
1704            provenance: TestProvenance {
1705                runner: default_observed.clone(),
1706                kind: "unit".into(),
1707                project: None,
1708                source: PYTHON_FRONTEND_VERSION.into(),
1709            },
1710            role: "background".into(),
1711            attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1712            phases: vec![CoveragePhase {
1713                id: phase.clone(),
1714                kind: "background".into(),
1715                operation: "Python import and collection background".into(),
1716                source: None,
1717                caused_by_phase_id: None,
1718                started_at_ms: 0,
1719                ended_at_ms: Some(0),
1720                status: Some("passed".into()),
1721                error: None,
1722            }],
1723            runtime: vec![snapshot(&index, observations, &phase)?],
1724            browser: Vec::new(),
1725            server: Vec::new(),
1726        });
1727    }
1728
1729    // Runtime-detected limitations: obligations the runtime could not map
1730    // become unmeasured, and every limitation ID joins the manifest so the
1731    // declaration and manifest agree.
1732    let mut limitation_ids = manifest
1733        .limitations
1734        .iter()
1735        .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1736        .map(str::to_owned)
1737        .collect::<BTreeSet<_>>();
1738    let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1739    let mut new_limitations = Vec::new();
1740    for limitation in &limitations {
1741        if let Some(obligation) = &limitation.obligation {
1742            if !index.lines.contains_key(obligation.as_str()) {
1743                return Err(PythonEvidenceError::UnknownObligation(obligation.clone()));
1744            }
1745            unmeasured.insert(obligation.clone());
1746        } else if let Some(file) = &limitation.file {
1747            // A code-object mapping failure or missing debug ranges prevents
1748            // every obligation in that source file from being observed. Mark
1749            // the whole file unmeasured instead of presenting its denominator
1750            // as ordinary uncovered code.
1751            unmeasured.extend(
1752                index
1753                    .lines
1754                    .iter()
1755                    .filter(|(_, (obligation_file, _))| obligation_file == file)
1756                    .map(|(id, _)| (*id).to_owned()),
1757            );
1758        }
1759        if limitation_ids.insert(limitation.id.clone()) {
1760            let (file, line) = limitation
1761                .obligation
1762                .as_deref()
1763                .and_then(|id| index.lines.get(id).cloned())
1764                .unwrap_or_else(|| {
1765                    (
1766                        limitation.file.clone().unwrap_or_else(|| {
1767                            manifest
1768                                .points
1769                                .first()
1770                                .map_or(".".into(), |point| point.file.clone())
1771                        }),
1772                        1,
1773                    )
1774                });
1775            new_limitations.push(json!({
1776                "id": limitation.id,
1777                "kind": "semantic-safety",
1778                "file": file,
1779                "line": line,
1780                "column": 0,
1781                "source": "",
1782                "reason": limitation.reason
1783            }));
1784        }
1785    }
1786    manifest.limitations.extend(new_limitations);
1787    manifest.unmeasured = unmeasured.into_iter().collect();
1788    let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1789
1790    // Retries are separate raw results so their coverage remains attempt
1791    // exact, but the public lifecycle diagnostic reports logical tests rather
1792    // than inflating the count when a flaky test is rerun.
1793    let tests = raw_results
1794        .iter()
1795        .filter(|raw| raw.role == "test")
1796        .map(|raw| raw.test.as_str())
1797        .collect::<BTreeSet<_>>()
1798        .len();
1799    Ok(PythonFrontendRun {
1800        declaration: FrontendRunDeclaration {
1801            protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1802            frontend_id: "python".into(),
1803            frontend_version: PYTHON_FRONTEND_VERSION.into(),
1804            language: "python".into(),
1805            structural_source: StructuralSource::OwnedProbes,
1806            runners: observed_runners
1807                .iter()
1808                .map(|runner| FrontendRunnerDeclaration {
1809                    runner: runner.clone(),
1810                    execution_model: if runner == UNITTEST_RUNNER {
1811                        ExecutionModel::SerialInProcess
1812                    } else {
1813                        ExecutionModel::ParallelContextPropagated
1814                    },
1815                    attribution: FrontendAttribution {
1816                        run: AttributionPrecision::Exact,
1817                        worker: AttributionPrecision::Exact,
1818                        test: AttributionPrecision::Exact,
1819                        retry: AttributionPrecision::Exact,
1820                        phase: AttributionPrecision::Exact,
1821                        action: AttributionPrecision::Unavailable,
1822                        assertion: AttributionPrecision::Exact,
1823                    },
1824                    limitations: vec![FrontendLimitation {
1825                        id: "python-action-linkage".into(),
1826                        scopes: vec![FrontendLimitationScope::Action],
1827                        reason: format!("{runner} exposes no general action lifecycle"),
1828                    }],
1829                })
1830                .collect(),
1831            structural_limitations,
1832        },
1833        request: CoverageReportRequest {
1834            run_id: run_id.into(),
1835            manifest,
1836            raw_results,
1837            generated_at: generated_at.into(),
1838            coverage_model: Some(python_coverage_model()),
1839            integrity: None,
1840            test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1841        },
1842        tests,
1843        interpreters,
1844        python_versions: python_versions.into_iter().collect(),
1845    })
1846}
1847
1848#[cfg(test)]
1849mod tests {
1850    use std::time::{SystemTime, UNIX_EPOCH};
1851
1852    use super::*;
1853    use crate::{
1854        frontend_protocol::validate_frontend_report_request,
1855        python_instrumenter::build_python_obligations,
1856    };
1857
1858    fn temporary(name: &str) -> std::path::PathBuf {
1859        let nonce = SystemTime::now()
1860            .duration_since(UNIX_EPOCH)
1861            .unwrap()
1862            .as_nanos();
1863        let path = std::env::temp_dir().join(format!(
1864            "supercov-python-evidence-{}-{nonce}-{name}",
1865            std::process::id()
1866        ));
1867        fs::create_dir_all(&path).unwrap();
1868        path
1869    }
1870
1871    fn write_transport(path: &Path, records: &[serde_json::Value], dropped: u64) {
1872        write_transport_version(path, records, dropped, TRANSPORT_VERSION, None);
1873    }
1874
1875    /// A transport of any version, checksummed as that version does -- or,
1876    /// with `corrupt`, with one payload's checksum deliberately wrong.
1877    fn write_transport_version(
1878        path: &Path,
1879        records: &[serde_json::Value],
1880        dropped: u64,
1881        version: u32,
1882        corrupt: Option<usize>,
1883    ) {
1884        let payloads = records
1885            .iter()
1886            .map(|record| serde_json::to_vec(record).unwrap())
1887            .collect::<Vec<_>>();
1888        let capacity = payloads
1889            .iter()
1890            .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
1891                align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
1892            })
1893            + 64;
1894        let mut bytes = vec![0_u8; capacity];
1895        bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1896        bytes[8..12].copy_from_slice(&version.to_le_bytes());
1897        bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1898        bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1899        bytes[24..32].copy_from_slice(&dropped.to_le_bytes());
1900        bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
1901        let mut cursor = TRANSPORT_HEADER_SIZE;
1902        for (index, payload) in payloads.into_iter().enumerate() {
1903            let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
1904            let payload_end = payload_start + payload.len();
1905            bytes[payload_start..payload_end].copy_from_slice(&payload);
1906            bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
1907            // Computed here, not through the reader's own function: a test of
1908            // which checksum a version means must not share the code under
1909            // test with the transport it writes.
1910            let expected = if version == TRANSPORT_VERSION_FNV {
1911                payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
1912                    (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
1913                })
1914            } else {
1915                crc32fast::hash(&payload)
1916            };
1917            let checksum = expected ^ u32::from(corrupt == Some(index));
1918            bytes[cursor + 8..cursor + 12].copy_from_slice(&checksum.to_le_bytes());
1919            bytes[cursor] = 1;
1920            cursor = align_transport(payload_end).unwrap();
1921        }
1922        fs::write(path, bytes).unwrap();
1923    }
1924
1925    // A path is only absolute in the platform's own spelling: "/project" is a
1926    // relative path on Windows, where an absolute one needs a drive. The
1927    // runtimes report whatever the interpreter loaded, so these fixtures have
1928    // to speak the host's dialect too.
1929    fn under(first: &str, rest: &str) -> String {
1930        let mut path = PathBuf::from(if cfg!(windows) {
1931            format!("C:\\{first}")
1932        } else {
1933            format!("/{first}")
1934        });
1935        for part in rest.split('/').filter(|part| !part.is_empty()) {
1936            path.push(part);
1937        }
1938        path.to_string_lossy().into_owned()
1939    }
1940
1941    fn inventory_of(root: &str, sites: &[(&str, usize, usize)]) -> PythonAssertionInventory {
1942        use crate::assertion_map::{Anchor, Files, Inputs, InventorySite};
1943        PythonAssertionInventory::new(
1944            Path::new(root),
1945            &Inputs {
1946                schema_version: 1,
1947                language: "python".into(),
1948                context_digest: "context".into(),
1949                files: Files::new(),
1950                assertions: sites
1951                    .iter()
1952                    .map(|(file, line, column)| InventorySite {
1953                        at: Anchor {
1954                            file: (*file).into(),
1955                            line: *line,
1956                            column: *column,
1957                            text: "assert f(1) == 1".into(),
1958                        },
1959                        operation: "assert".into(),
1960                    })
1961                    .collect(),
1962                limitations: vec![],
1963            },
1964        )
1965    }
1966
1967    fn assertion_sources(run: &PythonFrontendRun) -> Vec<String> {
1968        run.request.raw_results[0]
1969            .phases
1970            .iter()
1971            .filter(|phase| phase.kind == "assertion")
1972            .filter_map(|phase| phase.source.clone())
1973            .collect()
1974    }
1975
1976    fn run_with_sites(
1977        name: &str,
1978        sites: &[serde_json::Value],
1979        outcome_file: Option<&str>,
1980        inventory: &PythonAssertionInventory,
1981    ) -> PythonFrontendRun {
1982        let source = "def f(a):\n    return a\n";
1983        let obligations = build_python_obligations("m.py", source).unwrap();
1984        let mut outcome = json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false});
1985        if let Some(file) = outcome_file {
1986            outcome["file"] = json!(file);
1987        }
1988        let mut lines = vec![
1989            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1990            json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1991            json!({"t":"assert","ctx":1}),
1992        ];
1993        lines.extend(sites.iter().cloned());
1994        lines.push(outcome);
1995        lines.push(json!({"t":"exit","at":9}));
1996        let directory = temporary(name);
1997        write_transport(&directory.join("main.1.mmap"), &lines, 0);
1998        let run = build_python_frontend_run(
1999            &obligations.manifest,
2000            &directory,
2001            "run-1",
2002            "now",
2003            0,
2004            inventory,
2005        )
2006        .unwrap();
2007        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2008        fs::remove_dir_all(directory).unwrap();
2009        run
2010    }
2011
2012    const SLOT_DIGEST: &str = "0123456789abcdef0123456789abcdef";
2013
2014    /// Two obligations and one two-condition decision `a or b`, whose
2015    /// logical branch is decided by the vector: a 16-byte header, two
2016    /// obligation bytes, then the decision's `2 * 3^2` region bytes.
2017    fn slot_layout() -> serde_json::Value {
2018        json!({
2019            "version": 2, "digest": SLOT_DIGEST, "header": 16, "bytes": 36,
2020            "ids": ["s1", "s2"],
2021            "decisions": [{
2022                "start": 18, "width": 2, "id": "d",
2023                "outcomeTrue": "d:true", "outcomeFalse": "d:false",
2024                "logical": [{"evaluated": "or:evaluated", "shortCircuit": "or:short",
2025                             "previousLeaves": [0], "operandLeaves": [1]}]
2026            }]
2027        })
2028    }
2029
2030    /// A slot of `slot_layout` for `context`, with `set` bytes at 1.
2031    fn slot_bytes(context: u64, tag: Option<&str>, set: &[usize]) -> Vec<u8> {
2032        let mut bytes = vec![0_u8; 36];
2033        bytes[..8].copy_from_slice(&context.to_le_bytes());
2034        if let Some(tag) = tag {
2035            for index in 0..8 {
2036                bytes[8 + index] = u8::from_str_radix(&tag[index * 2..index * 2 + 2], 16).unwrap();
2037            }
2038        }
2039        for index in set {
2040            bytes[*index] = 1;
2041        }
2042        bytes
2043    }
2044
2045    /// A process killed during a test's call phase: its transport declares the
2046    /// phase and nothing after it, and its slot holds what it observed.
2047    fn killed_process(name: &str, slot: &[u8]) -> PathBuf {
2048        let directory = temporary(name);
2049        write_transport(
2050            &directory.join("main.1.token.mmap"),
2051            &[
2052                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.9.21","executable":"python","argv":["pytest"]}),
2053                json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2054            ],
2055            0,
2056        );
2057        fs::write(directory.join("main.1.token.0.slot"), slot).unwrap();
2058        fs::write(
2059            directory.join("layout.json"),
2060            serde_json::to_vec(&slot_layout()).unwrap(),
2061        )
2062        .unwrap();
2063        directory
2064    }
2065
2066    fn call_observations(evidence: &Evidence) -> Observations {
2067        evidence
2068            .per_identity
2069            .iter()
2070            .find(|(identity, _)| identity.phase == "call")
2071            .map(|(_, observations)| Observations {
2072                hits: observations.hits.clone(),
2073                vectors: observations.vectors.clone(),
2074            })
2075            .unwrap_or_default()
2076    }
2077
2078    #[test]
2079    fn a_killed_process_slot_is_read_as_its_last_records() {
2080        // s1 ran; `a or b` evaluated a false, then b true -- digits "12", mask
2081        // 1 + 2*3 = 7 -- and came out true: region byte 18 + 2*7 + 1.
2082        let directory = killed_process(
2083            "py-slot-killed",
2084            &slot_bytes(1, Some(SLOT_DIGEST), &[16, 18 + 15]),
2085        );
2086        let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2087        let call = call_observations(&evidence);
2088        assert_eq!(
2089            call.hits.iter().cloned().collect::<Vec<_>>(),
2090            ["d:true", "or:evaluated", "s1"],
2091            "the obligation, and what the vector implies"
2092        );
2093        assert_eq!(
2094            call.vectors["d"],
2095            BTreeSet::from([(vec![Some(false), Some(true)], true)])
2096        );
2097        fs::remove_dir_all(directory).unwrap();
2098    }
2099
2100    #[test]
2101    fn a_slot_its_process_closed_is_not_counted() {
2102        // Close zeroes the tag after its final harvest; a daemon thread may
2103        // still have written after that, and nothing it wrote is a test's.
2104        let directory = killed_process("py-slot-closed", &slot_bytes(1, None, &[16]));
2105        let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2106        assert!(call_observations(&evidence).hits.is_empty());
2107        fs::remove_dir_all(directory).unwrap();
2108    }
2109
2110    #[test]
2111    fn a_slot_is_read_only_against_its_own_layout() {
2112        let other = "fedcba9876543210fedcba9876543210";
2113        let directory = killed_process("py-slot-foreign", &slot_bytes(1, Some(other), &[16]));
2114        assert!(matches!(
2115            read_evidence_directory(&directory, "run-1"),
2116            Err(PythonEvidenceError::InvalidTransport { .. })
2117        ));
2118        fs::remove_dir_all(directory).unwrap();
2119
2120        // A byte that is neither set nor clear was not written by a probe.
2121        let directory = killed_process("py-slot-byte", &slot_bytes(1, Some(SLOT_DIGEST), &[]));
2122        let mut bytes = slot_bytes(1, Some(SLOT_DIGEST), &[]);
2123        bytes[17] = 7;
2124        fs::write(directory.join("main.1.token.0.slot"), bytes).unwrap();
2125        assert!(read_evidence_directory(&directory, "run-1").is_err());
2126        fs::remove_dir_all(directory).unwrap();
2127    }
2128
2129    #[test]
2130    fn a_slot_needs_its_transport_and_a_partial_layout_is_ignored() {
2131        let directory = killed_process("py-slot-orphan", &slot_bytes(1, Some(SLOT_DIGEST), &[16]));
2132        // A layout write cut short by the process dying is left behind.
2133        fs::write(directory.join("layout.json.42.token.partial"), b"{").unwrap();
2134        assert!(read_evidence_directory(&directory, "run-1").is_ok());
2135        fs::write(
2136            directory.join("other.9.token.0.slot"),
2137            slot_bytes(1, Some(SLOT_DIGEST), &[16]),
2138        )
2139        .unwrap();
2140        assert!(matches!(
2141            read_evidence_directory(&directory, "run-1"),
2142            Err(PythonEvidenceError::InvalidTransport { .. })
2143        ));
2144        fs::remove_dir_all(directory).unwrap();
2145    }
2146
2147    #[test]
2148    fn an_assertion_site_becomes_a_located_phase_when_the_inventory_names_one() {
2149        // Python reports a file and a line for an assertion, so a line is a
2150        // witness only when the inventory holds exactly one site on it. The
2151        // column reported is zero-based, which is what every native manifest
2152        // reports and what the assertion report adds one to.
2153        let inventory = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
2154        let run = run_with_sites(
2155            "py-asite-located",
2156            &[json!({"t":"asite","ctx":1,"f":under("project", "tests/test_m.py"),"l":6})],
2157            None,
2158            &inventory,
2159        );
2160        assert!(
2161            assertion_sources(&run).contains(&"tests/test_m.py:6:4".to_string()),
2162            "expected a located assertion phase, got {:?}",
2163            assertion_sources(&run)
2164        );
2165    }
2166
2167    #[test]
2168    fn an_ambiguous_or_foreign_assertion_site_witnesses_nothing() {
2169        // Two sites on one line cannot be told apart from a line number, and a
2170        // frame outside the project names nothing. Both lose the witness
2171        // rather than guessing one.
2172        let ambiguous = inventory_of(
2173            &under("project", ""),
2174            &[("tests/test_m.py", 6, 5), ("tests/test_m.py", 6, 30)],
2175        );
2176        let run = run_with_sites(
2177            "py-asite-ambiguous",
2178            &[json!({"t":"asite","ctx":1,"f":under("project", "tests/test_m.py"),"l":6})],
2179            None,
2180            &ambiguous,
2181        );
2182        assert_eq!(
2183            assertion_sources(&run),
2184            vec!["tests/test_m.py::test_a".to_string()],
2185            "only the per-test assertion phase should remain"
2186        );
2187
2188        let known = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
2189        let outside = run_with_sites(
2190            "py-asite-outside",
2191            &[json!({"t":"asite","ctx":1,"f":under("elsewhere", "tests/test_m.py"),"l":6})],
2192            None,
2193            &known,
2194        );
2195        assert_eq!(
2196            assertion_sources(&outside),
2197            vec!["tests/test_m.py::test_a".to_string()]
2198        );
2199    }
2200
2201    #[test]
2202    fn the_runner_names_the_test_file_in_either_path_form() {
2203        // pytest reports the file from the report's location and unittest from
2204        // the test's module; either may be absolute or relative. Both name the
2205        // same project file, and a runner that names none falls back to the
2206        // node id.
2207        let inventory = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
2208        for reported in [
2209            under("project", "tests/test_m.py"),
2210            "tests/test_m.py".to_owned(),
2211            "./tests/test_m.py".to_owned(),
2212        ] {
2213            let run = run_with_sites("py-asite-file", &[], Some(reported.as_str()), &inventory);
2214            assert_eq!(
2215                run.request.raw_results[0].test_file.as_deref(),
2216                Some("tests/test_m.py"),
2217                "{reported} should resolve to the project path"
2218            );
2219        }
2220        let without = run_with_sites("py-asite-nofile", &[], None, &inventory);
2221        assert_eq!(
2222            without.request.raw_results[0].test_file.as_deref(),
2223            Some("tests/test_m.py"),
2224            "a pytest node id already begins with the file"
2225        );
2226    }
2227
2228    #[test]
2229    fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
2230        // The runtime's marker says everything the call phase recorded so far
2231        // ran before an assertion. That evidence carries an assertion phase
2232        // that passed with the test; what ran after the marker, and all of a
2233        // test that failed, stays execution only. A second marker is inert.
2234        let source = "def f(a):\n    return a\n\n\ndef g(b):\n    return b\n";
2235        let obligations = build_python_obligations("m.py", source).unwrap();
2236        let before = &obligations.plan.statements[0].id;
2237        let after = &obligations.plan.statements[1].id;
2238        let run_with = |name: &str, outcome: &str| {
2239            let lines = [
2240                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2241                json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2242                json!({"t":"hit","ctx":1,"id":before}),
2243                json!({"t":"assert","ctx":1}),
2244                json!({"t":"assert","ctx":1}),
2245                json!({"t":"hit","ctx":1,"id":after}),
2246                json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":outcome,"xfail":false}),
2247                json!({"t":"exit","at":9}),
2248            ];
2249            let directory = temporary(name);
2250            write_transport(&directory.join("main.1.mmap"), &lines, 0);
2251            let run = build_python_frontend_run(
2252                &obligations.manifest,
2253                &directory,
2254                "run-1",
2255                "now",
2256                0,
2257                &PythonAssertionInventory::empty(),
2258            )
2259            .unwrap();
2260            validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2261            fs::remove_dir_all(directory).unwrap();
2262            run
2263        };
2264        let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
2265            result
2266                .runtime
2267                .iter()
2268                .flat_map(|snapshot| snapshot.events.iter())
2269                .filter(|event| event.phase_id.as_deref() == Some(phase))
2270                .map(|event| event.id.clone())
2271                .collect()
2272        };
2273
2274        let run = run_with("asserted-passed", "passed");
2275        let passed = &run.request.raw_results[0];
2276        assert_eq!(passed.test, "tests/test_m.py::test_a");
2277        let assertion = passed
2278            .phases
2279            .iter()
2280            .find(|phase| phase.kind == "assertion")
2281            .expect("the asserting test carries an assertion phase");
2282        assert_eq!(assertion.status.as_deref(), Some("passed"));
2283        assert_eq!(
2284            events_of(passed, &assertion.id),
2285            BTreeSet::from([before.clone()]),
2286            "only what ran before the marker is the assertion's evidence"
2287        );
2288        let test_phase = passed
2289            .phases
2290            .iter()
2291            .find(|phase| phase.kind == "test")
2292            .unwrap();
2293        assert_eq!(
2294            events_of(passed, &test_phase.id),
2295            BTreeSet::from([before.clone(), after.clone()]),
2296            "the test phase keeps everything it ran"
2297        );
2298        assert_eq!(
2299            run.declaration.runners[0].attribution.assertion,
2300            AttributionPrecision::Exact
2301        );
2302
2303        let run = run_with("asserted-failed", "failed");
2304        let failed = &run.request.raw_results[0];
2305        let assertion = failed
2306            .phases
2307            .iter()
2308            .find(|phase| phase.kind == "assertion")
2309            .unwrap();
2310        assert_eq!(
2311            assertion.status.as_deref(),
2312            Some("failed"),
2313            "a failed test's assertion witnessed nothing"
2314        );
2315    }
2316
2317    #[test]
2318    fn joins_phases_outcomes_hits_and_vectors_into_exact_results() {
2319        let source = "def f(a, b):\n    if a and b:\n        return 1\n    return 0\n";
2320        let obligations = build_python_obligations("m.py", source).unwrap();
2321        let decision = &obligations.plan.decisions[0];
2322        let statement = &obligations.plan.statements[0];
2323        let directory = temporary("join");
2324        let lines = [
2325            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2326            json!({"t":"hit","ctx":0,"id":statement.id}),
2327            json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2328            json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
2329            json!({"t":"hit","ctx":1,"id":decision.outcome_true}),
2330            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
2331            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
2332            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
2333            json!({"t":"limitation","id":"python-decision-partially-mapped","reason":"folded","obligation":decision.id}),
2334            json!({"t":"exit","at":9}),
2335        ];
2336        write_transport(&directory.join("main.1.mmap"), &lines, 0);
2337        let run = build_python_frontend_run(
2338            &obligations.manifest,
2339            &directory,
2340            "run-1",
2341            "now",
2342            0,
2343            &PythonAssertionInventory::empty(),
2344        )
2345        .unwrap();
2346        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2347        assert_eq!(run.tests, 1);
2348        assert_eq!(run.request.raw_results.len(), 2);
2349        let test = &run.request.raw_results[0];
2350        assert_eq!(test.status.as_deref(), Some("passed"));
2351        assert_eq!(test.phases.len(), 3);
2352        assert_eq!(test.runtime.len(), 1);
2353        assert_eq!(test.runtime[0].decisions.len(), 1);
2354        assert!(
2355            test.runtime[0]
2356                .events
2357                .iter()
2358                .all(|event| event.phase_id.is_some())
2359        );
2360        let background = &run.request.raw_results[1];
2361        assert_eq!(background.role, "background");
2362        assert!(run.request.manifest.unmeasured.contains(&decision.id));
2363        assert!(
2364            run.declaration
2365                .structural_limitations
2366                .contains(&"python-decision-partially-mapped".to_owned())
2367        );
2368        fs::remove_dir_all(directory).unwrap();
2369    }
2370
2371    // Version 2 checksums with CRC-32, version 1 with FNV-1a. A runtime older
2372    // than this reader -- evidence kept from a failed run, say -- still reads,
2373    // by its own checksum; a payload that does not match its checksum, and a
2374    // version this reader does not know, are refused rather than guessed at.
2375    #[test]
2376    fn a_transport_reads_by_its_own_versions_checksum() {
2377        let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
2378        let process = json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]});
2379        let read = |name: &str, version: u32, corrupt: Option<usize>| {
2380            let directory = temporary(name);
2381            write_transport_version(
2382                &directory.join("main.1.mmap"),
2383                std::slice::from_ref(&process),
2384                0,
2385                version,
2386                corrupt,
2387            );
2388            let outcome = build_python_frontend_run(
2389                &obligations.manifest,
2390                &directory,
2391                "run-1",
2392                "now",
2393                0,
2394                &PythonAssertionInventory::empty(),
2395            );
2396            fs::remove_dir_all(directory).unwrap();
2397            outcome.map(|_| ()).map_err(|error| error.to_string())
2398        };
2399        let current = read("checksum-v2", TRANSPORT_VERSION, None);
2400        let older = read("checksum-v1", TRANSPORT_VERSION_FNV, None);
2401        assert_eq!(
2402            older, current,
2403            "a version 1 transport reads as a version 2 one does"
2404        );
2405        assert!(
2406            !current
2407                .as_ref()
2408                .err()
2409                .is_some_and(|error| error.contains("checksum")),
2410            "{current:?}"
2411        );
2412        let corrupted = read("checksum-bad", TRANSPORT_VERSION, Some(0)).unwrap_err();
2413        assert!(corrupted.contains("checksum does not match"), "{corrupted}");
2414        let unknown = read("checksum-v3", TRANSPORT_VERSION + 1, None).unwrap_err();
2415        assert!(unknown.contains("version"), "{unknown}");
2416    }
2417
2418    #[test]
2419    fn a_phase_its_runner_reported_more_than_once_is_one_phase() {
2420        // pytest-subtests reports every subtest under the same node id and the
2421        // same `call` phase, so an attempt carries several outcomes for the
2422        // identity a phase id is derived from. Each became its own phase with
2423        // the same id, and the run was refused whole -- a suite of 6872 tests
2424        // lost two and three-quarter hours of measurement to it (#40). The
2425        // outcomes fold the way an attempt's do: a failure anywhere is the
2426        // phase's.
2427        let source = "def f(a):\n    return a\n";
2428        let obligations = build_python_obligations("m.py", source).unwrap();
2429        let directory = temporary("subtests");
2430        let subtest = |outcome: &str| {
2431            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,
2432                   "phase":"call","outcome":outcome,"xfail":false})
2433        };
2434        let lines = [
2435            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2436            json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"call"}),
2437            json!({"t":"hit","ctx":1,"id":obligations.plan.statements[0].id}),
2438            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
2439            subtest("passed"),
2440            subtest("passed"),
2441            subtest("failed"),
2442            subtest("passed"),
2443            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
2444            json!({"t":"exit","at":9}),
2445        ];
2446        write_transport(&directory.join("main.1.mmap"), &lines, 0);
2447        let run = build_python_frontend_run(
2448            &obligations.manifest,
2449            &directory,
2450            "run-1",
2451            "now",
2452            1,
2453            &PythonAssertionInventory::empty(),
2454        )
2455        .unwrap();
2456        // The protocol refuses a repeated phase id, which is what threw the
2457        // run away; nothing may reach it twice.
2458        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2459        let test = &run.request.raw_results[0];
2460        assert_eq!(
2461            test.phases.len(),
2462            3,
2463            "setup, one call, teardown: {:?}",
2464            test.phases
2465        );
2466        assert_eq!(
2467            test.phases
2468                .iter()
2469                .filter(|phase| phase.kind == "test")
2470                .count(),
2471            1,
2472            "the subtests share the call phase their coverage was recorded under"
2473        );
2474        assert_eq!(
2475            test.status.as_deref(),
2476            Some("failed"),
2477            "a subtest that failed fails its test"
2478        );
2479        // And the coverage recorded under that identity survives the fold.
2480        assert_eq!(test.runtime.len(), 1);
2481        assert!(!test.runtime[0].hits.is_empty());
2482        fs::remove_dir_all(directory).unwrap();
2483    }
2484
2485    #[test]
2486    fn fails_closed_without_an_interpreter_or_tests() {
2487        let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
2488        let directory = temporary("empty");
2489        assert!(matches!(
2490            build_python_frontend_run(
2491                &obligations.manifest,
2492                &directory,
2493                "run-1",
2494                "now",
2495                0,
2496                &PythonAssertionInventory::empty()
2497            ),
2498            Err(PythonEvidenceError::NoInterpreter)
2499        ));
2500        let path = directory.join("main.1.mmap");
2501        write_transport(
2502            &path,
2503            &[
2504                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
2505            ],
2506            0,
2507        );
2508        assert!(matches!(
2509            build_python_frontend_run(
2510                &obligations.manifest,
2511                &directory,
2512                "run-1",
2513                "now",
2514                0,
2515                &PythonAssertionInventory::empty()
2516            ),
2517            Err(PythonEvidenceError::NoTests)
2518        ));
2519        // A killed writer may have copied part of its next payload without
2520        // publishing the commit byte. The reader must stop at that frame,
2521        // even when the tail would be invalid JSON if treated as committed.
2522        let mut torn = fs::read(&path).unwrap();
2523        let process_length = transport_u32(&torn, TRANSPORT_HEADER_SIZE + 4).unwrap() as usize;
2524        let torn_cursor =
2525            align_transport(TRANSPORT_HEADER_SIZE + TRANSPORT_RECORD_HEADER_SIZE + process_length)
2526                .unwrap();
2527        torn[torn_cursor + 4..torn_cursor + 8].copy_from_slice(&5_u32.to_le_bytes());
2528        torn[torn_cursor + TRANSPORT_RECORD_HEADER_SIZE
2529            ..torn_cursor + TRANSPORT_RECORD_HEADER_SIZE + 5]
2530            .copy_from_slice(b"{nope");
2531        fs::write(&path, torn).unwrap();
2532        assert!(matches!(
2533            build_python_frontend_run(
2534                &obligations.manifest,
2535                &directory,
2536                "run-1",
2537                "now",
2538                0,
2539                &PythonAssertionInventory::empty()
2540            ),
2541            Err(PythonEvidenceError::NoTests)
2542        ));
2543        write_transport(
2544            &path,
2545            &[
2546                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.8.20","executable":"p","argv":[]}),
2547            ],
2548            0,
2549        );
2550        assert!(matches!(
2551            build_python_frontend_run(
2552                &obligations.manifest,
2553                &directory,
2554                "run-1",
2555                "now",
2556                0,
2557                &PythonAssertionInventory::empty()
2558            ),
2559            Err(PythonEvidenceError::UnsupportedPython(_))
2560        ));
2561        write_transport(
2562            &path,
2563            &[
2564                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
2565            ],
2566            2,
2567        );
2568        assert!(matches!(
2569            build_python_frontend_run(
2570                &obligations.manifest,
2571                &directory,
2572                "run-1",
2573                "now",
2574                0,
2575                &PythonAssertionInventory::empty()
2576            ),
2577            Err(PythonEvidenceError::DroppedRecords { count: 2, .. })
2578        ));
2579        fs::remove_dir_all(directory).unwrap();
2580    }
2581}