Skip to main content

supercov_engine/
rust_phase_projection.rs

1//! Exact projection of compiler-owned Rust assertion contexts into evidence v3.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use sha2::{Digest, Sha256};
6
7use crate::{
8    coverage_report::{CoverageManifest, CoveragePhase},
9    rust_probe_transport::{
10        RustPhaseContext, RustTransportError, RustTransportRead, validate_rust_phase_contexts,
11    },
12    rust_runtime::RustProbeObservation,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct RustPhaseProjection {
17    pub phases: Vec<CoveragePhase>,
18    pub phase_id_by_context: BTreeMap<u64, String>,
19    /// Thread phases are execution scope, not evidence-v3 phases: work under
20    /// an accepted thread phase belongs to the nearest enclosing assertion
21    /// phase or test, exactly as it did on the creating thread.
22    pub thread_parent_by_context: BTreeMap<u64, u64>,
23}
24
25impl RustPhaseProjection {
26    pub fn phase_id_for_context<'a>(
27        &'a self,
28        base_context_id: u64,
29        base_phase_id: &'a str,
30        context_id: u64,
31    ) -> Result<Option<&'a str>, RustTransportError> {
32        if context_id == 0 {
33            return Ok(None);
34        }
35        let mut context_id = context_id;
36        let mut hops = 0_usize;
37        while let Some(parent) = self.thread_parent_by_context.get(&context_id) {
38            hops += 1;
39            if hops > self.thread_parent_by_context.len() {
40                return Err(RustTransportError::InvalidAssertionContext(format!(
41                    "thread phase context cycle at {context_id:016x}"
42                )));
43            }
44            context_id = *parent;
45        }
46        if context_id == base_context_id {
47            return Ok(Some(base_phase_id));
48        }
49        self.phase_id_by_context
50            .get(&context_id)
51            .map(String::as_str)
52            .map(Some)
53            .ok_or_else(|| {
54                RustTransportError::InvalidAssertionContext(format!(
55                    "context {context_id:016x} has no evidence-v3 phase"
56                ))
57            })
58    }
59}
60
61fn phase_id(base_phase_id: &str, context_id: u64, invocation_nonce: u64) -> String {
62    let mut digest = Sha256::new();
63    digest.update((base_phase_id.len() as u64).to_be_bytes());
64    digest.update(base_phase_id.as_bytes());
65    digest.update(context_id.to_be_bytes());
66    digest.update(invocation_nonce.to_be_bytes());
67    let hex = format!("{:x}", digest.finalize());
68    format!("rust-phase:{}", &hex[..40])
69}
70
71fn assertion_status(
72    phase: &RustPhaseContext,
73    read: &RustTransportRead,
74) -> Result<Option<String>, RustTransportError> {
75    let outcomes = read
76        .observations
77        .iter()
78        .filter_map(|record| match &record.observation {
79            RustProbeObservation::Decision { id, outcome, .. }
80                if record.context_id == phase.child_context_id && id == &phase.decision_id =>
81            {
82                Some(*outcome)
83            }
84            _ => None,
85        })
86        .collect::<BTreeSet<_>>();
87    match outcomes.len() {
88        0 => Ok(None),
89        1 if outcomes.contains(&true) => Ok(Some("passed".into())),
90        1 => Ok(Some("failed".into())),
91        _ => Err(RustTransportError::InvalidAssertionContext(format!(
92            "assertion phase {:016x} committed contradictory outcomes",
93            phase.child_context_id
94        ))),
95    }
96}
97
98pub fn project_rust_assertion_phases(
99    base_context_id: u64,
100    base_phase: &CoveragePhase,
101    read: &RustTransportRead,
102    manifest: &CoverageManifest,
103) -> Result<RustPhaseProjection, RustTransportError> {
104    validate_rust_phase_contexts(base_context_id, read)?;
105    let decisions = manifest
106        .decisions
107        .iter()
108        .map(|decision| (decision.id.as_str(), decision))
109        .collect::<BTreeMap<_, _>>();
110    let mut definitions = BTreeMap::<u64, &RustPhaseContext>::new();
111    for phase in &read.phases {
112        definitions.entry(phase.child_context_id).or_insert(phase);
113    }
114    let thread_parent_by_context = read
115        .thread_phases
116        .iter()
117        .map(|phase| (phase.child_context_id, phase.parent_context_id))
118        .collect::<BTreeMap<_, _>>();
119    // Thread phases are execution scope, not causality: an assertion entered
120    // on an inherited thread is caused by the nearest enclosing assertion
121    // phase or the test itself, exactly as on the creating thread.
122    let collapse_thread_parents = |mut context: u64| -> Result<u64, RustTransportError> {
123        let mut hops = 0_usize;
124        while let Some(parent) = thread_parent_by_context.get(&context) {
125            hops += 1;
126            if hops > thread_parent_by_context.len() {
127                return Err(RustTransportError::InvalidAssertionContext(format!(
128                    "thread phase context cycle at {context:016x}"
129                )));
130            }
131            context = *parent;
132        }
133        Ok(context)
134    };
135    let phase_id_by_context = definitions
136        .values()
137        .map(|phase| {
138            (
139                phase.child_context_id,
140                phase_id(
141                    &base_phase.id,
142                    phase.child_context_id,
143                    phase.invocation_nonce,
144                ),
145            )
146        })
147        .collect::<BTreeMap<_, _>>();
148
149    let mut ordered = definitions.values().copied().collect::<Vec<_>>();
150    ordered.sort_by_key(|phase| phase.invocation_nonce);
151    let mut phases = Vec::with_capacity(ordered.len());
152    for phase in ordered {
153        let decision = decisions.get(phase.decision_id.as_str()).ok_or_else(|| {
154            RustTransportError::InvalidAssertionContext(format!(
155                "phase {:016x} references unknown decision {}",
156                phase.child_context_id, phase.decision_id
157            ))
158        })?;
159        if decision.kind != "assertion" {
160            return Err(RustTransportError::InvalidAssertionContext(format!(
161                "phase {:016x} references non-assertion decision {}",
162                phase.child_context_id, phase.decision_id
163            )));
164        }
165        let parent_context_id = collapse_thread_parents(phase.parent_context_id)?;
166        let caused_by_phase_id = if parent_context_id == base_context_id {
167            Some(base_phase.id.clone())
168        } else {
169            Some(
170                phase_id_by_context
171                    .get(&parent_context_id)
172                    .cloned()
173                    .ok_or_else(|| {
174                        RustTransportError::InvalidAssertionContext(format!(
175                            "phase {:016x} has unresolved parent {:016x}",
176                            phase.child_context_id, parent_context_id
177                        ))
178                    })?,
179            )
180        };
181        let status = assertion_status(phase, read)?;
182        phases.push(CoveragePhase {
183            id: phase_id_by_context[&phase.child_context_id].clone(),
184            kind: "assertion".into(),
185            operation: format!(
186                "Rust assertion at {}:{}:{}",
187                decision.file, decision.line, decision.column
188            ),
189            source: Some(decision.source.clone()),
190            caused_by_phase_id,
191            started_at_ms: base_phase.started_at_ms,
192            ended_at_ms: status.as_ref().and(base_phase.ended_at_ms),
193            status,
194            error: None,
195        });
196    }
197    Ok(RustPhaseProjection {
198        phases,
199        phase_id_by_context,
200        thread_parent_by_context,
201    })
202}
203
204#[cfg(test)]
205mod tests {
206    use crate::{
207        coverage_report::{CoverageManifest, CoveragePhase, DecisionMeta},
208        rust_probe_transport::{
209            RustPhaseContext, RustThreadPhase, RustTransportObservation, RustTransportRead,
210            rust_assertion_context_id, rust_thread_context_id,
211        },
212        rust_runtime::RustProbeObservation,
213    };
214
215    use super::*;
216
217    const BASE: u64 = 42;
218    const ASSERTION: &str = "rs:decision:0123456789abcdef01234567";
219
220    fn base_phase() -> CoveragePhase {
221        CoveragePhase {
222            id: "test-phase".into(),
223            kind: "test".into(),
224            operation: "libtest test".into(),
225            source: Some("src/lib.rs".into()),
226            caused_by_phase_id: None,
227            started_at_ms: 10,
228            ended_at_ms: Some(20),
229            status: Some("passed".into()),
230            error: None,
231        }
232    }
233
234    fn manifest() -> CoverageManifest {
235        CoverageManifest {
236            unmeasured: Vec::new(),
237            decisions: vec![DecisionMeta {
238                id: ASSERTION.into(),
239                file: "src/lib.rs".into(),
240                line: 7,
241                column: 5,
242                source: "assert!(value)".into(),
243                conditions: vec!["value".into()],
244                kind: "assertion".into(),
245            }],
246            points: Vec::new(),
247            branches: Vec::new(),
248            limitations: Vec::new(),
249            scope: None,
250        }
251    }
252
253    #[test]
254    fn repeated_and_nested_assertions_become_distinct_causal_evidence_phases() {
255        let first = rust_assertion_context_id(BASE, ASSERTION, 0).unwrap();
256        let repeated = rust_assertion_context_id(BASE, ASSERTION, 1).unwrap();
257        let nested = rust_assertion_context_id(first, ASSERTION, 2).unwrap();
258        let phases = vec![
259            RustPhaseContext {
260                process_id: 1,
261                child_context_id: first,
262                parent_context_id: BASE,
263                invocation_nonce: 0,
264                decision_id: ASSERTION.into(),
265            },
266            RustPhaseContext {
267                process_id: 2,
268                child_context_id: repeated,
269                parent_context_id: BASE,
270                invocation_nonce: 1,
271                decision_id: ASSERTION.into(),
272            },
273            RustPhaseContext {
274                process_id: 1,
275                child_context_id: nested,
276                parent_context_id: first,
277                invocation_nonce: 2,
278                decision_id: ASSERTION.into(),
279            },
280        ];
281        let observations = [(first, true), (repeated, false)]
282            .into_iter()
283            .map(|(context_id, outcome)| RustTransportObservation {
284                process_id: 1,
285                context_id,
286                observation: RustProbeObservation::Decision {
287                    id: ASSERTION.into(),
288                    values: vec![Some(outcome)],
289                    outcome,
290                },
291            })
292            .collect();
293        let thread_under_first = rust_thread_context_id(first, 9);
294        let thread_under_base = rust_thread_context_id(BASE, 10);
295        let read = RustTransportRead {
296            observations,
297            ordinal_hits: Vec::new(),
298            phases,
299            thread_phases: vec![
300                RustThreadPhase {
301                    process_id: 1,
302                    child_context_id: thread_under_first,
303                    parent_context_id: first,
304                    invocation_nonce: 9,
305                    commit_index: 5,
306                },
307                RustThreadPhase {
308                    process_id: 1,
309                    child_context_id: thread_under_base,
310                    parent_context_id: BASE,
311                    invocation_nonce: 10,
312                    commit_index: 6,
313                },
314            ],
315            committed: 7,
316            attachments: 2,
317            ..RustTransportRead::empty()
318        };
319        let projection =
320            project_rust_assertion_phases(BASE, &base_phase(), &read, &manifest()).unwrap();
321        assert_eq!(
322            projection
323                .phase_id_for_context(BASE, "test-phase", thread_under_first)
324                .unwrap(),
325            Some(projection.phases[0].id.as_str()),
326            "thread work belongs to the nearest enclosing assertion phase"
327        );
328        assert_eq!(
329            projection
330                .phase_id_for_context(BASE, "test-phase", thread_under_base)
331                .unwrap(),
332            Some("test-phase"),
333            "thread work directly under the test belongs to the test phase"
334        );
335        assert_eq!(projection.phases.len(), 3);
336        assert_eq!(
337            projection
338                .phases
339                .iter()
340                .map(|phase| phase.id.as_str())
341                .collect::<BTreeSet<_>>()
342                .len(),
343            3
344        );
345        assert_eq!(projection.phases[0].status.as_deref(), Some("passed"));
346        assert_eq!(projection.phases[1].status.as_deref(), Some("failed"));
347        assert_eq!(projection.phases[2].status, None);
348        assert_eq!(
349            projection.phases[2].caused_by_phase_id.as_deref(),
350            Some(projection.phases[0].id.as_str())
351        );
352        assert_eq!(
353            projection
354                .phase_id_for_context(BASE, "test-phase", repeated)
355                .unwrap(),
356            Some(projection.phases[1].id.as_str())
357        );
358        assert_eq!(
359            projection
360                .phase_id_for_context(BASE, "test-phase", 0)
361                .unwrap(),
362            None
363        );
364    }
365}