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