Skip to main content

virtio_accel_conformance/
lib.rs

1//! Reusable semantic conformance tests for [`virtio_accel_core::Accelerator`] backends.
2//!
3//! The suite depends only on the transport-independent backend contract. An implementer supplies a
4//! fresh-backend factory, one executable target fixture, and a small hook that advances an event to
5//! completion. Optional accounting lets the same cases detect leaked provider resources. The
6//! [`numerics`] module adds stable, device-neutral TOSA graphs and numerical oracles that host
7//! backends can execute without substituting provider-specific fixtures.
8
9#![forbid(unsafe_code)]
10
11mod cases;
12pub mod numerics;
13
14use std::fmt;
15use std::vec::Vec;
16use virtio_accel_core::{
17    Accelerator, AccessMode, ArtifactFormat, BackendError, Capabilities, MemoryDomain,
18    TargetIdentity,
19};
20
21/// Provider artifact accepted by the backend under test.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct ProgramFixture {
24    format: ArtifactFormat,
25    target: TargetIdentity,
26    payload: Vec<u8>,
27    resident_bytes: u64,
28}
29
30impl ProgramFixture {
31    /// Build one owned provider artifact fixture.
32    pub fn new(
33        format: ArtifactFormat,
34        target: TargetIdentity,
35        payload: impl Into<Vec<u8>>,
36        resident_bytes: u64,
37    ) -> Result<Self, TargetDescriptionError> {
38        let payload = payload.into();
39        if payload.is_empty() {
40            return Err(TargetDescriptionError::EmptyArtifact);
41        }
42        if resident_bytes == 0 {
43            return Err(TargetDescriptionError::ZeroResidentBytes);
44        }
45        Ok(Self {
46            format,
47            target,
48            payload,
49            resident_bytes,
50        })
51    }
52
53    pub const fn format(&self) -> ArtifactFormat {
54        self.format
55    }
56
57    pub const fn target(&self) -> TargetIdentity {
58        self.target
59    }
60
61    pub fn payload(&self) -> &[u8] {
62        &self.payload
63    }
64
65    pub const fn resident_bytes(&self) -> u64 {
66        self.resident_bytes
67    }
68}
69
70/// One observable program binding used by the standard execution cases.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct BindingFixture {
73    slot: u32,
74    access: AccessMode,
75    domain: MemoryDomain,
76    alignment: u64,
77    initial: Vec<u8>,
78    expected: Vec<u8>,
79}
80
81impl BindingFixture {
82    /// Describe one nonempty binding and its expected bytes after successful completion.
83    pub fn new(
84        slot: u32,
85        access: AccessMode,
86        domain: MemoryDomain,
87        alignment: u64,
88        initial: impl Into<Vec<u8>>,
89        expected: impl Into<Vec<u8>>,
90    ) -> Result<Self, TargetDescriptionError> {
91        let initial = initial.into();
92        let expected = expected.into();
93        if initial.is_empty() {
94            return Err(TargetDescriptionError::EmptyBinding);
95        }
96        if initial.len() != expected.len() {
97            return Err(TargetDescriptionError::OutputLengthMismatch);
98        }
99        if initial == expected {
100            return Err(TargetDescriptionError::UnobservableOutput);
101        }
102        if access == AccessMode::Read {
103            return Err(TargetDescriptionError::ReadOnlyBinding);
104        }
105        if alignment == 0 || !alignment.is_power_of_two() {
106            return Err(TargetDescriptionError::InvalidAlignment);
107        }
108        Ok(Self {
109            slot,
110            access,
111            domain,
112            alignment,
113            initial,
114            expected,
115        })
116    }
117
118    /// Describe one nonempty read-only binding whose bytes must be unchanged after completion.
119    ///
120    /// Read-only fixtures model program inputs of lowered artifacts whose input and output slots
121    /// are disjoint. The executing cases verify the bytes byte-for-byte after completion, so a
122    /// program that clobbers its inputs fails the suite.
123    pub fn read_only(
124        slot: u32,
125        domain: MemoryDomain,
126        alignment: u64,
127        initial: impl Into<Vec<u8>>,
128    ) -> Result<Self, TargetDescriptionError> {
129        let initial = initial.into();
130        if initial.is_empty() {
131            return Err(TargetDescriptionError::EmptyBinding);
132        }
133        if alignment == 0 || !alignment.is_power_of_two() {
134            return Err(TargetDescriptionError::InvalidAlignment);
135        }
136        let expected = initial.clone();
137        Ok(Self {
138            slot,
139            access: AccessMode::Read,
140            domain,
141            alignment,
142            initial,
143            expected,
144        })
145    }
146
147    /// Whether the program may write through this binding, making its output observable.
148    pub const fn is_writable(&self) -> bool {
149        !matches!(self.access, AccessMode::Read)
150    }
151
152    pub const fn slot(&self) -> u32 {
153        self.slot
154    }
155
156    pub const fn access(&self) -> AccessMode {
157        self.access
158    }
159
160    pub const fn domain(&self) -> MemoryDomain {
161        self.domain
162    }
163
164    pub const fn alignment(&self) -> u64 {
165        self.alignment
166    }
167
168    pub fn initial(&self) -> &[u8] {
169        &self.initial
170    }
171
172    pub fn expected(&self) -> &[u8] {
173        &self.expected
174    }
175
176    pub fn bytes(&self) -> u64 {
177        self.initial.len() as u64
178    }
179}
180
181/// Owned provider-specific data required by the transport-free standard suite.
182#[derive(Clone, Debug, PartialEq, Eq)]
183pub struct TargetDescription {
184    program: ProgramFixture,
185    bindings: Vec<BindingFixture>,
186    primary: usize,
187}
188
189impl TargetDescription {
190    /// Describe a program driven by exactly one writable, observable binding.
191    ///
192    /// # Panics
193    ///
194    /// Panics when `binding` is read-only. Read-only fixtures only make sense alongside a
195    /// writable one; use [`Self::with_bindings`] for programs with disjoint input and output
196    /// slots.
197    pub fn new(program: ProgramFixture, binding: BindingFixture) -> Self {
198        assert!(
199            binding.is_writable(),
200            "a single-binding target must be writable; use with_bindings for read-only inputs"
201        );
202        Self {
203            program,
204            bindings: vec![binding],
205            primary: 0,
206        }
207    }
208
209    /// Describe a program driven by one buffer per binding fixture.
210    ///
211    /// Slots must be unique and at least one fixture must be writable; the first writable
212    /// fixture becomes the primary binding that negative submission subcases perturb and that
213    /// buffer-transfer cases exercise. Executing cases bind every fixture in declared order and
214    /// verify every fixture's expected bytes after completion.
215    pub fn with_bindings(
216        program: ProgramFixture,
217        bindings: Vec<BindingFixture>,
218    ) -> Result<Self, TargetDescriptionError> {
219        if bindings.is_empty() {
220            return Err(TargetDescriptionError::NoBindings);
221        }
222        for (index, binding) in bindings.iter().enumerate() {
223            if bindings[..index]
224                .iter()
225                .any(|prior| prior.slot == binding.slot)
226            {
227                return Err(TargetDescriptionError::DuplicateBindingSlot);
228            }
229        }
230        let Some(primary) = bindings.iter().position(BindingFixture::is_writable) else {
231            return Err(TargetDescriptionError::NoObservableBinding);
232        };
233        Ok(Self {
234            program,
235            bindings,
236            primary,
237        })
238    }
239
240    pub const fn program(&self) -> &ProgramFixture {
241        &self.program
242    }
243
244    /// The primary binding: the first writable, observable fixture.
245    pub fn binding(&self) -> &BindingFixture {
246        &self.bindings[self.primary]
247    }
248
249    /// Every binding fixture in submission order.
250    pub fn bindings(&self) -> &[BindingFixture] {
251        &self.bindings
252    }
253
254    /// Index of the primary binding within [`Self::bindings`].
255    pub const fn primary_index(&self) -> usize {
256        self.primary
257    }
258}
259
260/// Invalid provider fixture supplied to the conformance suite.
261#[derive(Clone, Copy, Debug, PartialEq, Eq)]
262pub enum TargetDescriptionError {
263    EmptyArtifact,
264    ZeroResidentBytes,
265    EmptyBinding,
266    OutputLengthMismatch,
267    UnobservableOutput,
268    ReadOnlyBinding,
269    InvalidAlignment,
270    NoBindings,
271    DuplicateBindingSlot,
272    NoObservableBinding,
273}
274
275impl fmt::Display for TargetDescriptionError {
276    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
277        write!(formatter, "{self:?}")
278    }
279}
280
281impl std::error::Error for TargetDescriptionError {}
282
283/// Provider-owned resource totals sampled by an optional accounting hook.
284#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
285pub struct ResourceCounts {
286    pub contexts: u64,
287    pub buffers: u64,
288    pub programs: u64,
289    pub queues: u64,
290    pub events: u64,
291}
292
293impl ResourceCounts {
294    pub const fn is_empty(self) -> bool {
295        self.contexts == 0
296            && self.buffers == 0
297            && self.programs == 0
298            && self.queues == 0
299            && self.events == 0
300    }
301}
302
303/// Provider-reported submission path counters used to separate real accelerator work from staging.
304///
305/// A backend that exposes these diagnostics should report cumulative counts for one backend
306/// instance. Direct bindings are provider-owned buffers submitted without a bounce allocation.
307/// Shared/imported bindings are reserved for future external-memory paths. Staged direct bindings
308/// are buffers that should have been directly bound but were instead copied through a temporary
309/// submission buffer.
310#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
311pub struct SubmissionPathDiagnostics {
312    pub direct_bindings: u64,
313    pub shared_imported_bindings: u64,
314    pub staged_direct_bindings: u64,
315    pub staged_direct_bytes: u64,
316    pub explicit_transfer_bytes: u64,
317}
318
319impl SubmissionPathDiagnostics {
320    pub const fn has_hidden_direct_staging(self) -> bool {
321        self.staged_direct_bindings != 0 || self.staged_direct_bytes != 0
322    }
323
324    pub const fn saturating_delta(self, before: Self) -> Self {
325        Self {
326            direct_bindings: self.direct_bindings.saturating_sub(before.direct_bindings),
327            shared_imported_bindings: self
328                .shared_imported_bindings
329                .saturating_sub(before.shared_imported_bindings),
330            staged_direct_bindings: self
331                .staged_direct_bindings
332                .saturating_sub(before.staged_direct_bindings),
333            staged_direct_bytes: self
334                .staged_direct_bytes
335                .saturating_sub(before.staged_direct_bytes),
336            explicit_transfer_bytes: self
337                .explicit_transfer_bytes
338                .saturating_sub(before.explicit_transfer_bytes),
339        }
340    }
341}
342
343/// Provider-specific control needed by tests without weakening the [`Accelerator`] contract.
344pub trait ConformanceHooks<A: Accelerator> {
345    /// Advance one pending event to its successful terminal state.
346    fn complete_event(&self, backend: &A, event: &A::Event) -> Result<(), BackendError>;
347
348    /// Return live or indeterminate provider resource totals when the implementation exposes them.
349    fn resource_counts(&self, _backend: &A) -> Option<ResourceCounts> {
350        None
351    }
352
353    /// Return cumulative provider copy-path diagnostics when the implementation exposes them.
354    fn submission_path_diagnostics(&self, _backend: &A) -> Option<SubmissionPathDiagnostics> {
355        None
356    }
357}
358
359/// Why a conformance case is required or conditional.
360#[derive(Clone, Copy, Debug, PartialEq, Eq)]
361pub enum CaseRequirement {
362    Mandatory,
363    Capability(Capabilities),
364    AccountingHook,
365    DiagnosticsHook,
366}
367
368/// Explicit reason that one conditional case did not run.
369#[derive(Clone, Copy, Debug, PartialEq, Eq)]
370pub enum SkipReason {
371    CapabilityNotAdvertised(Capabilities),
372    AccountingUnavailable,
373    DiagnosticsUnavailable,
374}
375
376/// Outcome of one stable conformance case.
377#[derive(Clone, Debug, PartialEq, Eq)]
378pub enum CaseStatus {
379    Passed,
380    Skipped(SkipReason),
381    Failed(String),
382}
383
384/// Result for one independently constructed backend instance.
385#[derive(Clone, Debug, PartialEq, Eq)]
386pub struct CaseResult {
387    pub id: &'static str,
388    pub name: &'static str,
389    pub requirement: CaseRequirement,
390    pub status: CaseStatus,
391}
392
393/// Aggregate result from the standard semantic backend suite.
394#[derive(Clone, Debug, PartialEq, Eq)]
395pub struct ConformanceReport {
396    cases: Vec<CaseResult>,
397}
398
399impl ConformanceReport {
400    pub fn cases(&self) -> &[CaseResult] {
401        &self.cases
402    }
403
404    pub fn case(&self, id: &str) -> Option<&CaseResult> {
405        self.cases.iter().find(|case| case.id == id)
406    }
407
408    pub fn failures(&self) -> impl Iterator<Item = &CaseResult> {
409        self.cases
410            .iter()
411            .filter(|case| matches!(case.status, CaseStatus::Failed(_)))
412    }
413
414    pub fn passed(&self) -> bool {
415        self.failures().next().is_none()
416    }
417
418    /// Panic with every failed case, suitable for a backend crate's ordinary test target.
419    #[track_caller]
420    pub fn assert_conformant(&self) {
421        assert!(self.passed(), "{self}");
422    }
423}
424
425impl fmt::Display for ConformanceReport {
426    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
427        let mut failures = self.failures().peekable();
428        if failures.peek().is_none() {
429            return formatter.write_str("backend conformance passed");
430        }
431        formatter.write_str("backend conformance failed:")?;
432        for case in failures {
433            let CaseStatus::Failed(message) = &case.status else {
434                continue;
435            };
436            write!(formatter, "\n- {}: {}", case.id, message)?;
437        }
438        Ok(())
439    }
440}
441
442/// Run every mandatory and advertised capability-conditional semantic case.
443///
444/// `factory` is called once per case so an indeterminate result or accounting contradiction can
445/// never contaminate a later case. The returned report preserves skips and all failures instead of
446/// stopping at the first defect.
447pub fn run<A, F, H>(factory: F, target: &TargetDescription, hooks: &H) -> ConformanceReport
448where
449    A: Accelerator,
450    F: Fn() -> A,
451    H: ConformanceHooks<A>,
452{
453    ConformanceReport {
454        cases: cases::run_all(&factory, target, hooks),
455    }
456}