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, first-sighting hits,
6//! decision vectors and any measurement limitation the runtime detected. The
7//! one-byte commit marker is written last, so records completed before a hard
8//! kill remain readable while a torn tail stays inert. Rust joins those records
9//! into the shared frontend protocol; the runtime never computes a verdict.
10
11use std::{
12    collections::{BTreeMap, BTreeSet},
13    fs::{self, File},
14    path::{Component, Path},
15};
16
17use memmap2::{Mmap, MmapOptions};
18use serde::Deserialize;
19use serde_json::json;
20use sha2::{Digest, Sha256};
21use supercov_contracts::{
22    AttributionPrecision, ExecutionModel, FrontendAttribution, FrontendLimitation,
23    FrontendLimitationScope, FrontendRunDeclaration, FrontendRunnerDeclaration,
24    LANGUAGE_FRONTEND_PROTOCOL_VERSION, StructuralSource,
25};
26
27use crate::{
28    coverage_analysis::McdcVector,
29    coverage_report::{
30        CoverageManifest, CoverageModelDeclaration, CoveragePhase, CoverageReportRequest,
31        DecisionMeta, DecisionSnapshot, ExecutionScope, ExitCodeInput, PersistedCoverageModel,
32        RawTestResult, RuntimeEvent, RuntimeSnapshot, TestProvenance,
33    },
34    evidence_archive::EvidenceArchiveEntry,
35};
36
37pub const PYTHON_EVIDENCE_VERSION: u32 = 1;
38pub const PYTHON_FRONTEND_VERSION: &str = "python-monitoring-v1";
39pub const PYTEST_RUNNER: &str = "pytest";
40pub const UNITTEST_RUNNER: &str = "unittest";
41
42const TRANSPORT_MAGIC: &[u8; 8] = b"SCVPYTH1";
43const TRANSPORT_VERSION: u32 = 1;
44const TRANSPORT_HEADER_SIZE: usize = 64;
45const TRANSPORT_RECORD_HEADER_SIZE: usize = 16;
46const TRANSPORT_MAX_RECORD_SIZE: usize = 4 * 1024 * 1024;
47
48fn default_runner() -> String {
49    PYTEST_RUNNER.into()
50}
51
52/// Every field the runtime writes is named so `deny_unknown_fields` keeps
53/// the record shape frozen, even where Rust does not read the value yet.
54#[derive(Debug, Deserialize)]
55#[allow(dead_code)]
56#[serde(tag = "t", rename_all = "lowercase", deny_unknown_fields)]
57enum Record {
58    Process {
59        v: u32,
60        run: String,
61        pid: u64,
62        worker: String,
63        python: String,
64        executable: String,
65        argv: Vec<String>,
66    },
67    Worker {
68        worker: String,
69    },
70    Phase {
71        ctx: u64,
72        at: i64,
73        worker: String,
74        test: String,
75        retry: usize,
76        phase: String,
77    },
78    Outcome {
79        worker: String,
80        test: String,
81        retry: usize,
82        phase: String,
83        outcome: String,
84        xfail: bool,
85        #[serde(default = "default_runner")]
86        runner: String,
87    },
88    Hit {
89        ctx: u64,
90        id: String,
91    },
92    Dec {
93        ctx: u64,
94        id: String,
95        v: String,
96        o: u8,
97    },
98    Limitation {
99        id: String,
100        reason: String,
101        #[serde(default)]
102        file: Option<String>,
103        #[serde(default)]
104        obligation: Option<String>,
105    },
106    Exit {
107        at: i64,
108    },
109}
110
111#[derive(Debug)]
112pub enum PythonEvidenceError {
113    Io(String),
114    UnsafeEntry(String),
115    InvalidRecord {
116        file: String,
117        line: usize,
118        reason: String,
119    },
120    InvalidTransport {
121        file: String,
122        reason: String,
123    },
124    DroppedRecords {
125        file: String,
126        count: u64,
127    },
128    RunMismatch {
129        expected: String,
130        actual: String,
131    },
132    UnsupportedVersion(u32),
133    UnknownContext {
134        file: String,
135        line: usize,
136        context: u64,
137    },
138    UnknownObligation(String),
139    InvalidVector {
140        id: String,
141        expected: usize,
142        actual: usize,
143    },
144    NoInterpreter,
145    NoTests,
146    UnsupportedPython(String),
147}
148
149impl std::fmt::Display for PythonEvidenceError {
150    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        match self {
152            Self::Io(reason) => write!(formatter, "could not read Python evidence: {reason}"),
153            Self::UnsafeEntry(name) => write!(formatter, "unsafe Python evidence entry: {name}"),
154            Self::InvalidRecord { file, line, reason } => {
155                write!(formatter, "invalid Python evidence record {file}:{line}: {reason}")
156            }
157            Self::InvalidTransport { file, reason } => {
158                write!(formatter, "invalid Python evidence transport {file}: {reason}")
159            }
160            Self::DroppedRecords { file, count } => write!(
161                formatter,
162                "Python evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
163            ),
164            Self::RunMismatch { expected, actual } => write!(
165                formatter,
166                "Python evidence belongs to run {actual}, expected {expected}"
167            ),
168            Self::UnsupportedVersion(version) => {
169                write!(formatter, "unsupported Python evidence version {version}")
170            }
171            Self::UnknownContext { file, line, context } => write!(
172                formatter,
173                "Python evidence {file}:{line} references undeclared context {context}"
174            ),
175            Self::UnknownObligation(id) => {
176                write!(formatter, "Python runtime reported an unknown obligation: {id}")
177            }
178            Self::InvalidVector {
179                id,
180                expected,
181                actual,
182            } => write!(
183                formatter,
184                "Python decision {id} reported {actual} condition values, expected {expected}"
185            ),
186            Self::NoInterpreter => formatter.write_str(
187                "no Supercov-hooked Python interpreter ran: the test command did not start CPython 3.12+ with Supercov's start-up hook (PYTHONPATH may be ignored by -I/-E/-S, or the runner is not Python)",
188            ),
189            Self::NoTests => formatter.write_str(
190                "the Python run produced no test outcomes; Supercov measures Python through pytest and unittest",
191            ),
192            Self::UnsupportedPython(version) => write!(
193                formatter,
194                "Supercov measures CPython 3.12 or newer; the test command ran Python {version}"
195            ),
196        }
197    }
198}
199
200impl std::error::Error for PythonEvidenceError {}
201
202fn stable_id(prefix: &str, values: &[&str]) -> String {
203    let mut hash = Sha256::new();
204    for value in values {
205        hash.update(value.as_bytes());
206        hash.update([0]);
207    }
208    let digest = hash.finalize();
209    let mut encoded = String::with_capacity(prefix.len() + 25);
210    encoded.push_str(prefix);
211    encoded.push(':');
212    for byte in &digest[..12] {
213        use std::fmt::Write as _;
214        write!(&mut encoded, "{byte:02x}").expect("string formatting");
215    }
216    encoded
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
220struct Identity {
221    worker: String,
222    test: String,
223    retry: usize,
224    phase: String,
225}
226
227type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
228/// (worker, test, retry) -> [(phase, outcome, xfail)]
229type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
230/// (worker, test, retry) -> runner that reported the attempt
231type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
232
233#[derive(Debug, Default)]
234struct Observations {
235    hits: BTreeSet<String>,
236    vectors: BTreeMap<String, ObservedVectors>,
237}
238
239#[derive(Debug, Clone)]
240struct RuntimeLimitation {
241    id: String,
242    reason: String,
243    file: Option<String>,
244    obligation: Option<String>,
245}
246
247#[derive(Debug, Default)]
248struct Evidence {
249    interpreters: usize,
250    python_versions: BTreeSet<String>,
251    per_identity: BTreeMap<Identity, Observations>,
252    background: BTreeMap<String, Observations>,
253    outcomes: OutcomesByAttempt,
254    runners: RunnersByAttempt,
255    limitations: Vec<RuntimeLimitation>,
256}
257
258fn read_evidence_directory(
259    directory: &Path,
260    run_id: &str,
261) -> Result<Evidence, PythonEvidenceError> {
262    let mut evidence = Evidence::default();
263    let mut files = match fs::read_dir(directory) {
264        Ok(entries) => entries
265            .collect::<Result<Vec<_>, _>>()
266            .map_err(|error| PythonEvidenceError::Io(error.to_string()))?,
267        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
268        Err(error) => return Err(PythonEvidenceError::Io(error.to_string())),
269    };
270    files.sort_by_key(|entry| entry.file_name());
271    for entry in files {
272        let name = entry
273            .file_name()
274            .into_string()
275            .map_err(|_| PythonEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
276        if Path::new(&name)
277            .components()
278            .any(|component| !matches!(component, Component::Normal(_)))
279            || !name.ends_with(".mmap")
280        {
281            return Err(PythonEvidenceError::UnsafeEntry(name));
282        }
283        let metadata = fs::symlink_metadata(entry.path())
284            .map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
285        if !metadata.file_type().is_file() {
286            return Err(PythonEvidenceError::UnsafeEntry(name));
287        }
288        let file =
289            File::open(entry.path()).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
290        // The file is immutable from Supercov's perspective after the wrapped
291        // interpreter has exited. No mutable alias is created while this map
292        // is alive.
293        let contents = unsafe { MmapOptions::new().map(&file) }
294            .map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
295        read_evidence_file(&name, &contents, run_id, &mut evidence)?;
296    }
297    Ok(evidence)
298}
299
300fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
301    bytes
302        .get(offset..offset + 4)
303        .and_then(|value| value.try_into().ok())
304        .map(u32::from_le_bytes)
305}
306
307fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
308    bytes
309        .get(offset..offset + 8)
310        .and_then(|value| value.try_into().ok())
311        .map(u64::from_le_bytes)
312}
313
314fn transport_checksum(payload: &[u8]) -> u32 {
315    payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
316        (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
317    })
318}
319
320fn align_transport(value: usize) -> Option<usize> {
321    value.checked_add(7).map(|value| value & !7)
322}
323
324fn read_evidence_file(
325    name: &str,
326    contents: &Mmap,
327    run_id: &str,
328    evidence: &mut Evidence,
329) -> Result<(), PythonEvidenceError> {
330    let invalid_transport = |reason: &str| PythonEvidenceError::InvalidTransport {
331        file: name.into(),
332        reason: reason.into(),
333    };
334    if contents.len() < TRANSPORT_HEADER_SIZE
335        || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
336        || transport_u32(contents, 8) != Some(TRANSPORT_VERSION)
337        || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
338    {
339        return Err(invalid_transport("header or version does not match"));
340    }
341    let declared_capacity =
342        transport_u64(contents, 16).ok_or_else(|| invalid_transport("capacity is missing"))?;
343    if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
344    {
345        return Err(invalid_transport(
346            "declared capacity is outside the mapped file",
347        ));
348    }
349    let dropped =
350        transport_u64(contents, 24).ok_or_else(|| invalid_transport("drop counter is missing"))?;
351    if dropped != 0 {
352        return Err(PythonEvidenceError::DroppedRecords {
353            file: name.into(),
354            count: dropped,
355        });
356    }
357    let transport_pid = transport_u64(contents, 32)
358        .filter(|pid| *pid != 0)
359        .ok_or_else(|| invalid_transport("process id is missing"))?;
360    let mut contexts = BTreeMap::<u64, Identity>::new();
361    let mut process_worker: Option<String> = None;
362    let mut cursor = TRANSPORT_HEADER_SIZE;
363    let mut record_index = 0;
364    while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
365        let commit = contents[cursor];
366        if commit == 0 {
367            // Payload bytes can exist after a killed writer, but an absent
368            // commit byte makes that frame and every later zeroed frame inert.
369            break;
370        }
371        record_index += 1;
372        let line_number = record_index;
373        let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
374            file: name.into(),
375            line: line_number,
376            reason: reason.into(),
377        };
378        if commit != 1
379            || contents[cursor + 1..cursor + 4] != [0, 0, 0]
380            || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
381        {
382            return Err(invalid("commit marker or reserved bytes are invalid"));
383        }
384        let length = transport_u32(contents, cursor + 4)
385            .map(|value| value as usize)
386            .ok_or_else(|| invalid("payload length is missing"))?;
387        if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
388            return Err(invalid("payload length is outside the transport bound"));
389        }
390        let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
391        let payload_end = payload_start
392            .checked_add(length)
393            .filter(|end| *end <= contents.len())
394            .ok_or_else(|| invalid("payload extends past the mapped file"))?;
395        let next_cursor = align_transport(payload_end)
396            .filter(|end| *end <= contents.len())
397            .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
398        if contents[payload_end..next_cursor]
399            .iter()
400            .any(|byte| *byte != 0)
401        {
402            return Err(invalid("frame padding is not zero"));
403        }
404        let payload = &contents[payload_start..payload_end];
405        let expected_checksum = transport_u32(contents, cursor + 8)
406            .ok_or_else(|| invalid("payload checksum is missing"))?;
407        if transport_checksum(payload) != expected_checksum {
408            return Err(invalid("payload checksum does not match"));
409        }
410        let record: Record = serde_json::from_slice(payload).map_err(|error| {
411            PythonEvidenceError::InvalidRecord {
412                file: name.into(),
413                line: line_number,
414                reason: error.to_string(),
415            }
416        })?;
417        match record {
418            Record::Process {
419                v,
420                run,
421                pid,
422                worker,
423                python,
424                ..
425            } => {
426                if v != PYTHON_EVIDENCE_VERSION {
427                    return Err(PythonEvidenceError::UnsupportedVersion(v));
428                }
429                if run != run_id {
430                    return Err(PythonEvidenceError::RunMismatch {
431                        expected: run_id.into(),
432                        actual: run,
433                    });
434                }
435                if pid != transport_pid {
436                    return Err(invalid("process record does not match the transport owner"));
437                }
438                let supported = python
439                    .split('.')
440                    .take(2)
441                    .map(|part| part.parse::<u32>().ok())
442                    .collect::<Option<Vec<_>>>()
443                    .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 12));
444                if !supported {
445                    return Err(PythonEvidenceError::UnsupportedPython(python));
446                }
447                evidence.interpreters += 1;
448                evidence.python_versions.insert(python);
449                process_worker = Some(worker);
450            }
451            Record::Worker { worker } => process_worker = Some(worker),
452            Record::Phase {
453                ctx,
454                worker,
455                test,
456                retry,
457                phase,
458                ..
459            } => {
460                if ctx == 0 {
461                    return Err(invalid("phase context 0 is reserved for background"));
462                }
463                if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
464                    return Err(invalid("unknown pytest phase"));
465                }
466                if test.trim().is_empty() || worker.trim().is_empty() {
467                    return Err(invalid("phase identity must name a worker and test"));
468                }
469                contexts.insert(
470                    ctx,
471                    Identity {
472                        worker,
473                        test,
474                        retry,
475                        phase,
476                    },
477                );
478            }
479            Record::Outcome {
480                worker,
481                test,
482                retry,
483                phase,
484                outcome,
485                xfail,
486                runner,
487            } => {
488                if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
489                    return Err(invalid("unknown test outcome phase"));
490                }
491                if !matches!(
492                    outcome.as_str(),
493                    "passed" | "failed" | "skipped" | "rerun" | "error"
494                ) {
495                    return Err(invalid("unknown test outcome"));
496                }
497                if !matches!(runner.as_str(), PYTEST_RUNNER | UNITTEST_RUNNER) {
498                    return Err(invalid("unknown Python test runner"));
499                }
500                let key = (worker, test, retry);
501                if let Some(previous) = evidence.runners.get(&key)
502                    && previous != &runner
503                {
504                    return Err(invalid("one attempt was reported by two runners"));
505                }
506                evidence.runners.insert(key.clone(), runner);
507                evidence
508                    .outcomes
509                    .entry(key)
510                    .or_default()
511                    .push((phase, outcome, xfail));
512            }
513            Record::Hit { ctx, id } => {
514                observations(
515                    evidence,
516                    &contexts,
517                    process_worker.as_deref(),
518                    ctx,
519                    name,
520                    line_number,
521                )?
522                .hits
523                .insert(id);
524            }
525            Record::Dec { ctx, id, v, o } => {
526                if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
527                    return Err(invalid("decision vector digits must be 0, 1 or 2"));
528                }
529                if o > 1 {
530                    return Err(invalid("decision outcome must be 0 or 1"));
531                }
532                let values = v
533                    .bytes()
534                    .map(|digit| match digit {
535                        b'0' => None,
536                        b'1' => Some(false),
537                        _ => Some(true),
538                    })
539                    .collect::<Vec<_>>();
540                observations(
541                    evidence,
542                    &contexts,
543                    process_worker.as_deref(),
544                    ctx,
545                    name,
546                    line_number,
547                )?
548                .vectors
549                .entry(id)
550                .or_default()
551                .insert((values, o == 1));
552            }
553            Record::Limitation {
554                id,
555                reason,
556                file,
557                obligation,
558            } => evidence.limitations.push(RuntimeLimitation {
559                id,
560                reason,
561                file,
562                obligation,
563            }),
564            Record::Exit { .. } => {}
565        }
566        cursor = next_cursor;
567    }
568    Ok(())
569}
570
571fn observations<'a>(
572    evidence: &'a mut Evidence,
573    contexts: &BTreeMap<u64, Identity>,
574    process_worker: Option<&str>,
575    context: u64,
576    file: &str,
577    line: usize,
578) -> Result<&'a mut Observations, PythonEvidenceError> {
579    if context == 0 {
580        return Ok(evidence
581            .background
582            .entry(process_worker.unwrap_or("main").to_owned())
583            .or_default());
584    }
585    let identity = contexts
586        .get(&context)
587        .ok_or(PythonEvidenceError::UnknownContext {
588            file: file.into(),
589            line,
590            context,
591        })?;
592    Ok(evidence.per_identity.entry(identity.clone()).or_default())
593}
594
595pub fn python_coverage_model() -> CoverageModelDeclaration {
596    CoverageModelDeclaration {
597        language: "python".into(),
598        variant: "python-owned-monitoring".into(),
599        name: "python-sys-monitoring-v1".into(),
600        completeness_meaning: "Every statement, function, decision vector, loop, short-circuit, match and exception-flow obligation Supercov derived from the source was observed through CPython's monitoring events with exact test identity; the declared runtime limitations remain separate.".into(),
601        measured: vec![
602            "executable statements proven by CPython LINE events on their header lines, or INSTRUCTION events when they share a line".into(),
603            "function and lambda entry".into(),
604            "boolean decision vectors with masking MC/DC from conditional-jump events".into(),
605            "for-loop and comprehension zero-versus-entered iteration".into(),
606            "logical and/or short-circuit alternatives".into(),
607            "match case selection and guards".into(),
608            "try completion, handler selection and exception propagation".into(),
609            "pytest and unittest worker, test, retry and setup/call/teardown phase identity".into(),
610        ],
611        not_measured: vec![
612            "zero-iteration executions of a loop after it has run and exited 16 times within one test phase on CPython 3.14".into(),
613            "causal linkage to individual actions or passing assertions".into(),
614            "code compiled from strings at runtime".into(),
615            "causal test context for raw _thread or native-extension-created threads".into(),
616            "child coverage outside subprocess.Popen and multiprocessing adapters".into(),
617            "all input values, semantic partitions, paths, or concurrency interleavings".into(),
618            "mutation score or assertion fault-detection strength".into(),
619        ],
620    }
621}
622
623fn phase_id(run: &str, identity: &Identity) -> String {
624    stable_id(
625        "python-phase",
626        &[
627            run,
628            &identity.worker,
629            &identity.test,
630            &identity.retry.to_string(),
631            &identity.phase,
632        ],
633    )
634}
635
636fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
637    ExecutionScope {
638        version: 1,
639        run_id: run.into(),
640        worker_id: worker.into(),
641        test_id: test.into(),
642        test_key: stable_id("python-test", &[worker, test]),
643        retry,
644        attempt_id: stable_id("python-attempt", &[run, worker, test, &retry.to_string()]),
645    }
646}
647
648struct ManifestIndex<'a> {
649    points: BTreeSet<&'a str>,
650    alternatives: BTreeSet<&'a str>,
651    decisions: BTreeMap<&'a str, &'a DecisionMeta>,
652    lines: BTreeMap<&'a str, (String, usize)>,
653}
654
655impl<'a> ManifestIndex<'a> {
656    fn new(manifest: &'a CoverageManifest) -> Self {
657        let mut lines = BTreeMap::new();
658        for point in &manifest.points {
659            lines.insert(point.id.as_str(), (point.file.clone(), point.line));
660        }
661        for decision in &manifest.decisions {
662            lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
663        }
664        for branch in &manifest.branches {
665            lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
666        }
667        Self {
668            points: manifest
669                .points
670                .iter()
671                .map(|point| point.id.as_str())
672                .collect(),
673            alternatives: manifest
674                .branches
675                .iter()
676                .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
677                .collect(),
678            decisions: manifest
679                .decisions
680                .iter()
681                .map(|decision| (decision.id.as_str(), decision))
682                .collect(),
683            lines,
684        }
685    }
686}
687
688fn snapshot(
689    index: &ManifestIndex<'_>,
690    observations: &Observations,
691    phase: &str,
692) -> Result<RuntimeSnapshot, PythonEvidenceError> {
693    let mut hits = BTreeSet::new();
694    for id in &observations.hits {
695        if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
696            return Err(PythonEvidenceError::UnknownObligation(id.clone()));
697        }
698        hits.insert(id.clone());
699    }
700    let mut decisions = Vec::new();
701    let mut events = Vec::new();
702    let mut clock = 1;
703    for id in &hits {
704        events.push(RuntimeEvent {
705            event_type: "hit".into(),
706            id: id.clone(),
707            vector: None,
708            timestamp_ms: clock,
709            phase_id: Some(phase.into()),
710            environment: "python".into(),
711        });
712        clock += 1;
713    }
714    for (id, vectors) in &observations.vectors {
715        let Some(meta) = index.decisions.get(id.as_str()) else {
716            return Err(PythonEvidenceError::UnknownObligation(id.clone()));
717        };
718        let mut observed = Vec::new();
719        for (values, outcome) in vectors {
720            if values.len() != meta.conditions.len() {
721                return Err(PythonEvidenceError::InvalidVector {
722                    id: id.clone(),
723                    expected: meta.conditions.len(),
724                    actual: values.len(),
725                });
726            }
727            let vector = McdcVector {
728                values: values.clone(),
729                outcome: *outcome,
730            };
731            events.push(RuntimeEvent {
732                event_type: "decision".into(),
733                id: id.clone(),
734                vector: Some(vector.clone()),
735                timestamp_ms: clock,
736                phase_id: Some(phase.into()),
737                environment: "python".into(),
738            });
739            clock += 1;
740            observed.push(vector);
741        }
742        decisions.push(DecisionSnapshot {
743            meta: (*meta).clone(),
744            vectors: observed,
745        });
746    }
747    Ok(RuntimeSnapshot {
748        decisions,
749        hits: hits.into_iter().collect(),
750        events,
751    })
752}
753
754fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
755    if outcomes
756        .iter()
757        .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
758    {
759        "failed"
760    } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
761        "skipped"
762    } else {
763        "passed"
764    }
765    .into()
766}
767
768#[derive(Debug, Clone, PartialEq)]
769pub struct PythonFrontendRun {
770    pub declaration: FrontendRunDeclaration,
771    pub request: CoverageReportRequest,
772    pub tests: usize,
773    pub interpreters: usize,
774    pub python_versions: Vec<String>,
775}
776
777impl PythonFrontendRun {
778    pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
779        let model = PersistedCoverageModel::from_declaration(
780            self.request
781                .coverage_model
782                .as_ref()
783                .expect("Python frontend always declares a coverage model"),
784        )
785        .expect("Python coverage model is contract-valid");
786        let mut entries = vec![
787            EvidenceArchiveEntry {
788                path: "coverage-model.json".into(),
789                contents: serde_json::to_vec(&model)?,
790            },
791            EvidenceArchiveEntry {
792                path: "frontend.json".into(),
793                contents: serde_json::to_vec(&self.declaration)?,
794            },
795            EvidenceArchiveEntry {
796                path: "manifest.json".into(),
797                contents: serde_json::to_vec(&self.request.manifest)?,
798            },
799        ];
800        for (index, result) in self.request.raw_results.iter().enumerate() {
801            entries.push(EvidenceArchiveEntry {
802                path: format!("results/{index:08}/mcdc.json"),
803                contents: serde_json::to_vec(result)?,
804            });
805        }
806        Ok(entries)
807    }
808}
809
810/// Join the runtime's evidence directory with the ahead-of-run manifest into
811/// a protocol-conformant frontend run.
812pub fn build_python_frontend_run(
813    manifest: &CoverageManifest,
814    evidence_directory: &Path,
815    run_id: &str,
816    generated_at: &str,
817    test_exit_code: i32,
818) -> Result<PythonFrontendRun, PythonEvidenceError> {
819    let evidence = read_evidence_directory(evidence_directory, run_id)?;
820    if evidence.interpreters == 0 {
821        return Err(PythonEvidenceError::NoInterpreter);
822    }
823    if evidence.outcomes.is_empty() {
824        return Err(PythonEvidenceError::NoTests);
825    }
826    let Evidence {
827        interpreters,
828        python_versions,
829        per_identity,
830        background,
831        outcomes,
832        runners,
833        limitations,
834    } = evidence;
835    let mut manifest = manifest.clone();
836    let index = ManifestIndex::new(&manifest);
837
838    let mut raw_results = Vec::new();
839    let mut observed_runners = BTreeSet::new();
840    let mut identities_by_attempt =
841        BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
842    for (identity, observations) in &per_identity {
843        identities_by_attempt
844            .entry((
845                identity.worker.clone(),
846                identity.test.clone(),
847                identity.retry,
848            ))
849            .or_default()
850            .push((identity, observations));
851    }
852    for ((worker, test, retry), mut outcomes) in outcomes {
853        let runner = runners
854            .get(&(worker.clone(), test.clone(), retry))
855            .cloned()
856            .unwrap_or_else(default_runner);
857        let attempt_identities = identities_by_attempt
858            .remove(&(worker.clone(), test.clone(), retry))
859            .unwrap_or_default();
860        observed_runners.insert(runner.clone());
861        outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
862            "setup" => 0,
863            "call" => 1,
864            _ => 2,
865        });
866        let mut phases = Vec::new();
867        let mut runtime = Vec::new();
868        let mut observed_phases = BTreeSet::new();
869        for (position, (phase_name, outcome, _)) in outcomes.iter().enumerate() {
870            observed_phases.insert(phase_name.clone());
871            let identity = Identity {
872                worker: worker.clone(),
873                test: test.clone(),
874                retry,
875                phase: phase_name.clone(),
876            };
877            let id = phase_id(run_id, &identity);
878            phases.push(CoveragePhase {
879                id: id.clone(),
880                kind: match phase_name.as_str() {
881                    "call" => "test",
882                    value => value,
883                }
884                .into(),
885                operation: format!("{runner} {phase_name}"),
886                source: Some(test.clone()),
887                caused_by_phase_id: None,
888                started_at_ms: position as i64 * 2 + 1,
889                ended_at_ms: Some(position as i64 * 2 + 2),
890                status: Some(match outcome.as_str() {
891                    "rerun" | "error" => "failed".into(),
892                    value => value.into(),
893                }),
894                error: None,
895            });
896            if let Some((_, observations)) = attempt_identities
897                .iter()
898                .find(|(candidate, _)| candidate.phase == phase_name.as_str())
899            {
900                runtime.push(snapshot(&index, observations, &id)?);
901            }
902        }
903        // A phase the runtime entered but pytest never reported (the worker
904        // died inside it) is a failed phase with its evidence kept.
905        for (identity, observations) in attempt_identities {
906            if !observed_phases.contains(&identity.phase) {
907                let id = phase_id(run_id, identity);
908                phases.push(CoveragePhase {
909                    id: id.clone(),
910                    kind: match identity.phase.as_str() {
911                        "call" => "test",
912                        value => value,
913                    }
914                    .into(),
915                    operation: format!("{runner} {}", identity.phase),
916                    source: Some(test.clone()),
917                    caused_by_phase_id: None,
918                    started_at_ms: phases.len() as i64 * 2 + 1,
919                    ended_at_ms: None,
920                    status: Some("failed".into()),
921                    error: Some("the phase started but the runner reported no outcome".into()),
922                });
923                runtime.push(snapshot(&index, observations, &id)?);
924            }
925        }
926        let status = if phases.iter().any(|phase| phase.error.is_some()) {
927            "failed".into()
928        } else {
929            attempt_status(&outcomes)
930        };
931        raw_results.push(RawTestResult {
932            test_id: Some(test.clone()),
933            scope: Some(scope(run_id, &worker, &test, retry)),
934            test: test.clone(),
935            test_file: test.split("::").next().map(str::to_owned),
936            title: test.rsplit("::").next().map(str::to_owned),
937            retry: Some(retry),
938            status: Some(status),
939            expected_status: Some(
940                if outcomes.iter().any(|(_, _, xfail)| *xfail) {
941                    "failed"
942                } else {
943                    "passed"
944                }
945                .into(),
946            ),
947            flaky: false,
948            provenance: TestProvenance {
949                runner: runner.clone(),
950                kind: "unit".into(),
951                project: None,
952                source: PYTHON_FRONTEND_VERSION.into(),
953            },
954            role: "test".into(),
955            phases,
956            runtime,
957            browser: Vec::new(),
958            server: Vec::new(),
959        });
960    }
961    // Phases with observations whose test never produced any outcome at all
962    // (for example a worker killed during its first phase).
963    let default_observed = observed_runners
964        .iter()
965        .next()
966        .cloned()
967        .unwrap_or_else(default_runner);
968    for ((worker, test, retry), identities) in identities_by_attempt {
969        let runner = default_observed.clone();
970        let mut phases = Vec::new();
971        let mut runtime = Vec::new();
972        for (position, (identity, observations)) in identities.iter().enumerate() {
973            let id = phase_id(run_id, identity);
974            phases.push(CoveragePhase {
975                id: id.clone(),
976                kind: match identity.phase.as_str() {
977                    "call" => "test",
978                    value => value,
979                }
980                .into(),
981                operation: format!("{runner} {}", identity.phase),
982                source: Some(test.clone()),
983                caused_by_phase_id: None,
984                started_at_ms: position as i64 * 2 + 1,
985                ended_at_ms: None,
986                status: Some("failed".into()),
987                error: Some("the phase started but the runner reported no outcome".into()),
988            });
989            runtime.push(snapshot(&index, observations, &id)?);
990        }
991        raw_results.push(RawTestResult {
992            test_id: Some(test.clone()),
993            scope: Some(scope(run_id, &worker, &test, retry)),
994            test: test.clone(),
995            test_file: test.split("::").next().map(str::to_owned),
996            title: test.rsplit("::").next().map(str::to_owned),
997            retry: Some(retry),
998            status: Some("failed".into()),
999            expected_status: Some("passed".into()),
1000            flaky: false,
1001            provenance: TestProvenance {
1002                runner: runner.clone(),
1003                kind: "unit".into(),
1004                project: None,
1005                source: PYTHON_FRONTEND_VERSION.into(),
1006            },
1007            role: "test".into(),
1008            phases,
1009            runtime,
1010            browser: Vec::new(),
1011            server: Vec::new(),
1012        });
1013    }
1014    for (worker, observations) in &background {
1015        if observations.hits.is_empty() && observations.vectors.is_empty() {
1016            continue;
1017        }
1018        let test = format!("__supercov_background__:{worker}");
1019        let identity = Identity {
1020            worker: worker.clone(),
1021            test: test.clone(),
1022            retry: 0,
1023            phase: "background".into(),
1024        };
1025        let phase = phase_id(run_id, &identity);
1026        raw_results.push(RawTestResult {
1027            test_id: Some(test.clone()),
1028            scope: Some(scope(run_id, worker, &test, 0)),
1029            test: "Python import, collection and background execution".into(),
1030            test_file: None,
1031            title: None,
1032            retry: Some(0),
1033            status: Some("unknown".into()),
1034            expected_status: None,
1035            flaky: false,
1036            provenance: TestProvenance {
1037                runner: default_observed.clone(),
1038                kind: "unit".into(),
1039                project: None,
1040                source: PYTHON_FRONTEND_VERSION.into(),
1041            },
1042            role: "background".into(),
1043            phases: vec![CoveragePhase {
1044                id: phase.clone(),
1045                kind: "background".into(),
1046                operation: "Python import and collection background".into(),
1047                source: None,
1048                caused_by_phase_id: None,
1049                started_at_ms: 0,
1050                ended_at_ms: Some(0),
1051                status: Some("passed".into()),
1052                error: None,
1053            }],
1054            runtime: vec![snapshot(&index, observations, &phase)?],
1055            browser: Vec::new(),
1056            server: Vec::new(),
1057        });
1058    }
1059
1060    // Runtime-detected limitations: obligations the runtime could not map
1061    // become unmeasured, and every limitation ID joins the manifest so the
1062    // declaration and manifest agree.
1063    let mut limitation_ids = manifest
1064        .limitations
1065        .iter()
1066        .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1067        .map(str::to_owned)
1068        .collect::<BTreeSet<_>>();
1069    let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1070    let mut new_limitations = Vec::new();
1071    for limitation in &limitations {
1072        if let Some(obligation) = &limitation.obligation {
1073            if !index.lines.contains_key(obligation.as_str()) {
1074                return Err(PythonEvidenceError::UnknownObligation(obligation.clone()));
1075            }
1076            unmeasured.insert(obligation.clone());
1077        } else if let Some(file) = &limitation.file {
1078            // A code-object mapping failure or missing debug ranges prevents
1079            // every obligation in that source file from being observed. Mark
1080            // the whole file unmeasured instead of presenting its denominator
1081            // as ordinary uncovered code.
1082            unmeasured.extend(
1083                index
1084                    .lines
1085                    .iter()
1086                    .filter(|(_, (obligation_file, _))| obligation_file == file)
1087                    .map(|(id, _)| (*id).to_owned()),
1088            );
1089        }
1090        if limitation_ids.insert(limitation.id.clone()) {
1091            let (file, line) = limitation
1092                .obligation
1093                .as_deref()
1094                .and_then(|id| index.lines.get(id).cloned())
1095                .unwrap_or_else(|| {
1096                    (
1097                        limitation.file.clone().unwrap_or_else(|| {
1098                            manifest
1099                                .points
1100                                .first()
1101                                .map_or(".".into(), |point| point.file.clone())
1102                        }),
1103                        1,
1104                    )
1105                });
1106            new_limitations.push(json!({
1107                "id": limitation.id,
1108                "kind": "semantic-safety",
1109                "file": file,
1110                "line": line,
1111                "column": 0,
1112                "source": "",
1113                "reason": limitation.reason
1114            }));
1115        }
1116    }
1117    manifest.limitations.extend(new_limitations);
1118    manifest.unmeasured = unmeasured.into_iter().collect();
1119    let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1120
1121    // Retries are separate raw results so their coverage remains attempt
1122    // exact, but the public lifecycle diagnostic reports logical tests rather
1123    // than inflating the count when a flaky test is rerun.
1124    let tests = raw_results
1125        .iter()
1126        .filter(|raw| raw.role == "test")
1127        .map(|raw| raw.test.as_str())
1128        .collect::<BTreeSet<_>>()
1129        .len();
1130    Ok(PythonFrontendRun {
1131        declaration: FrontendRunDeclaration {
1132            protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1133            frontend_id: "python".into(),
1134            frontend_version: PYTHON_FRONTEND_VERSION.into(),
1135            language: "python".into(),
1136            structural_source: StructuralSource::OwnedProbes,
1137            runners: observed_runners
1138                .iter()
1139                .map(|runner| FrontendRunnerDeclaration {
1140                    runner: runner.clone(),
1141                    execution_model: if runner == UNITTEST_RUNNER {
1142                        ExecutionModel::SerialInProcess
1143                    } else {
1144                        ExecutionModel::ParallelContextPropagated
1145                    },
1146                    attribution: FrontendAttribution {
1147                        run: AttributionPrecision::Exact,
1148                        worker: AttributionPrecision::Exact,
1149                        test: AttributionPrecision::Exact,
1150                        retry: AttributionPrecision::Exact,
1151                        phase: AttributionPrecision::Exact,
1152                        action: AttributionPrecision::Unavailable,
1153                        assertion: AttributionPrecision::Unavailable,
1154                    },
1155                    limitations: vec![
1156                        FrontendLimitation {
1157                            id: "python-action-linkage".into(),
1158                            scopes: vec![FrontendLimitationScope::Action],
1159                            reason: format!("{runner} exposes no general action lifecycle"),
1160                        },
1161                        FrontendLimitation {
1162                            id: "python-assertion-linkage".into(),
1163                            scopes: vec![FrontendLimitationScope::Assertion],
1164                            reason: format!("{runner} phase outcomes do not identify each passing assertion or the obligations caused by it"),
1165                        },
1166                    ],
1167                })
1168                .collect(),
1169            structural_limitations,
1170        },
1171        request: CoverageReportRequest {
1172            run_id: run_id.into(),
1173            manifest,
1174            raw_results,
1175            generated_at: generated_at.into(),
1176            coverage_model: Some(python_coverage_model()),
1177            integrity: None,
1178            test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1179        },
1180        tests,
1181        interpreters,
1182        python_versions: python_versions.into_iter().collect(),
1183    })
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188    use std::time::{SystemTime, UNIX_EPOCH};
1189
1190    use super::*;
1191    use crate::{
1192        frontend_protocol::validate_frontend_report_request,
1193        python_instrumenter::build_python_obligations,
1194    };
1195
1196    fn temporary(name: &str) -> std::path::PathBuf {
1197        let nonce = SystemTime::now()
1198            .duration_since(UNIX_EPOCH)
1199            .unwrap()
1200            .as_nanos();
1201        let path = std::env::temp_dir().join(format!(
1202            "supercov-python-evidence-{}-{nonce}-{name}",
1203            std::process::id()
1204        ));
1205        fs::create_dir_all(&path).unwrap();
1206        path
1207    }
1208
1209    fn write_transport(path: &Path, records: &[serde_json::Value], dropped: u64) {
1210        let payloads = records
1211            .iter()
1212            .map(|record| serde_json::to_vec(record).unwrap())
1213            .collect::<Vec<_>>();
1214        let capacity = payloads
1215            .iter()
1216            .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
1217                align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
1218            })
1219            + 64;
1220        let mut bytes = vec![0_u8; capacity];
1221        bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1222        bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1223        bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1224        bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1225        bytes[24..32].copy_from_slice(&dropped.to_le_bytes());
1226        bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
1227        let mut cursor = TRANSPORT_HEADER_SIZE;
1228        for payload in payloads {
1229            let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
1230            let payload_end = payload_start + payload.len();
1231            bytes[payload_start..payload_end].copy_from_slice(&payload);
1232            bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
1233            bytes[cursor + 8..cursor + 12]
1234                .copy_from_slice(&transport_checksum(&payload).to_le_bytes());
1235            bytes[cursor] = 1;
1236            cursor = align_transport(payload_end).unwrap();
1237        }
1238        fs::write(path, bytes).unwrap();
1239    }
1240
1241    #[test]
1242    fn joins_phases_outcomes_hits_and_vectors_into_exact_results() {
1243        let source = "def f(a, b):\n    if a and b:\n        return 1\n    return 0\n";
1244        let obligations = build_python_obligations("m.py", source).unwrap();
1245        let decision = &obligations.plan.decisions[0];
1246        let statement = &obligations.plan.statements[0];
1247        let directory = temporary("join");
1248        let lines = [
1249            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1250            json!({"t":"hit","ctx":0,"id":statement.id}),
1251            json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1252            json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1253            json!({"t":"hit","ctx":1,"id":decision.outcome_true}),
1254            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
1255            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
1256            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
1257            json!({"t":"limitation","id":"python-decision-partially-mapped","reason":"folded","obligation":decision.id}),
1258            json!({"t":"exit","at":9}),
1259        ];
1260        write_transport(&directory.join("main.1.mmap"), &lines, 0);
1261        let run = build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0)
1262            .unwrap();
1263        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1264        assert_eq!(run.tests, 1);
1265        assert_eq!(run.request.raw_results.len(), 2);
1266        let test = &run.request.raw_results[0];
1267        assert_eq!(test.status.as_deref(), Some("passed"));
1268        assert_eq!(test.phases.len(), 3);
1269        assert_eq!(test.runtime.len(), 1);
1270        assert_eq!(test.runtime[0].decisions.len(), 1);
1271        assert!(
1272            test.runtime[0]
1273                .events
1274                .iter()
1275                .all(|event| event.phase_id.is_some())
1276        );
1277        let background = &run.request.raw_results[1];
1278        assert_eq!(background.role, "background");
1279        assert!(run.request.manifest.unmeasured.contains(&decision.id));
1280        assert!(
1281            run.declaration
1282                .structural_limitations
1283                .contains(&"python-decision-partially-mapped".to_owned())
1284        );
1285        fs::remove_dir_all(directory).unwrap();
1286    }
1287
1288    #[test]
1289    fn fails_closed_without_an_interpreter_or_tests() {
1290        let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
1291        let directory = temporary("empty");
1292        assert!(matches!(
1293            build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1294            Err(PythonEvidenceError::NoInterpreter)
1295        ));
1296        let path = directory.join("main.1.mmap");
1297        write_transport(
1298            &path,
1299            &[
1300                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
1301            ],
1302            0,
1303        );
1304        assert!(matches!(
1305            build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1306            Err(PythonEvidenceError::NoTests)
1307        ));
1308        // A killed writer may have copied part of its next payload without
1309        // publishing the commit byte. The reader must stop at that frame,
1310        // even when the tail would be invalid JSON if treated as committed.
1311        let mut torn = fs::read(&path).unwrap();
1312        let process_length = transport_u32(&torn, TRANSPORT_HEADER_SIZE + 4).unwrap() as usize;
1313        let torn_cursor =
1314            align_transport(TRANSPORT_HEADER_SIZE + TRANSPORT_RECORD_HEADER_SIZE + process_length)
1315                .unwrap();
1316        torn[torn_cursor + 4..torn_cursor + 8].copy_from_slice(&5_u32.to_le_bytes());
1317        torn[torn_cursor + TRANSPORT_RECORD_HEADER_SIZE
1318            ..torn_cursor + TRANSPORT_RECORD_HEADER_SIZE + 5]
1319            .copy_from_slice(b"{nope");
1320        fs::write(&path, torn).unwrap();
1321        assert!(matches!(
1322            build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1323            Err(PythonEvidenceError::NoTests)
1324        ));
1325        write_transport(
1326            &path,
1327            &[
1328                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11.9","executable":"p","argv":[]}),
1329            ],
1330            0,
1331        );
1332        assert!(matches!(
1333            build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1334            Err(PythonEvidenceError::UnsupportedPython(_))
1335        ));
1336        write_transport(
1337            &path,
1338            &[
1339                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
1340            ],
1341            2,
1342        );
1343        assert!(matches!(
1344            build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1345            Err(PythonEvidenceError::DroppedRecords { count: 2, .. })
1346        ));
1347        fs::remove_dir_all(directory).unwrap();
1348    }
1349}