Skip to main content

xpile_backend/
lib.rs

1//! Backend trait — code-lane emission abstraction.
2//!
3//! Every target language in xpile (Rust, Ruchy, PTX, WGSL, SPIR-V, Lean)
4//! provides one type implementing [`Backend`]. The trait is intentionally
5//! narrow: take meta-HIR and a config, return an [`Artifact`].
6//!
7//! Sibling of `xpile-frontend::Frontend`. Architectural invariants
8//! codified in `contracts/xpile-backend-trait-v1.yaml`.
9
10use serde::{Deserialize, Serialize};
11use xpile_contracts::ContractId;
12use xpile_meta_hir::Module;
13
14/// Target language a backend can emit.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
16pub enum Target {
17    /// Idiomatic Rust source. Implemented by `xpile-rust-codegen`.
18    Rust,
19    /// Ruchy source. Implemented by `xpile-ruchy-codegen`.
20    Ruchy,
21    /// NVIDIA PTX text. Implemented by `xpile-ptx-codegen`.
22    Ptx,
23    /// WebGPU Shading Language. Implemented by `xpile-wgsl-codegen`.
24    Wgsl,
25    /// SPIR-V text or binary. Implemented by `xpile-spirv-codegen` (future).
26    Spirv,
27    /// Lean 4 executable code (def, partial def, inductive, ...).
28    /// Implemented by `xpile-lean-codegen`. The proof-lane Lean (theorems)
29    /// goes through `xpile-lean-contract-backend` instead.
30    Lean,
31    /// POSIX shell (sh / bash / zsh) — the bashrs merger domain.
32    /// Implemented by `bashrs-backend` (scaffold at v0.1.0; full emit
33    /// at v0.2.0 once the bashrs source folding lands). PMAT-037 /
34    /// XPILE-BASHRS-MERGER-001. See `sub/bashrs-merger.md` Layer A.
35    Shell,
36}
37
38/// Lowering profile — the two-mHIR asymmetric decision for Rust↔Ruchy.
39///
40/// See `docs/specifications/sub/bidirectional-ruchy.md` (planned).
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42pub enum Profile {
43    /// meta-HIR normalized for Rust emission (default for most targets).
44    RustOut,
45    /// meta-HIR normalized for Ruchy emission (pipeline operator
46    /// reconstructed at emission time).
47    RuchyOut,
48}
49
50/// Hardware profile for targets whose emission depends on hardware
51/// capabilities (PTX `compute_capability`, WGSL feature set, SPIR-V version).
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub enum HwProfile {
54    Ptx {
55        /// e.g., "sm_80", "sm_89", "sm_90".
56        compute_capability: String,
57    },
58    Wgsl {
59        /// e.g., ["timestamp-query", "f16"].
60        features: Vec<String>,
61    },
62    Spirv {
63        version: (u32, u32),
64    },
65}
66
67/// Configuration passed to [`Backend::lower`].
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct BackendConfig {
70    pub target: Target,
71    pub profile: Profile,
72    pub hardware: Option<HwProfile>,
73}
74
75/// Emitted artifact — primary source/IR text plus sidecar files plus
76/// the structural citation chain to Layer-5 compile contracts.
77///
78/// The `citations` field is the structural channel that closes the
79/// audit chain: every target-specific IR construct in `primary` cites
80/// a Layer-5 compile contract by ID. Recovery is via this field, NOT
81/// via regex over `primary` text. See
82/// `contracts/xpile-backend-trait-v1.yaml` equation `compile_contract_citation`.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct Artifact {
85    /// Emitted source / IR text (Rust source, Ruchy source, PTX text,
86    /// WGSL source, SPIR-V text, Lean source).
87    pub primary: String,
88    /// Optional binaries, manifests, debug maps, accompanying the primary.
89    pub sidecars: Vec<(String, Vec<u8>)>,
90    /// Layer-5 compile contracts sanctioning every target-specific
91    /// construct in `primary`. Structural — not regex-recoverable.
92    pub citations: Vec<ContractId>,
93    /// Multi-emitter quorum status (PMAT-262 / Section 29). At v0.1.0
94    /// every Backend impl emits exactly one artifact, so this is always
95    /// `QuorumStatus::Single { emitter: <backend_name> }`. Multi-emitter
96    /// backends (future rustc_codegen_nvvm + aprender-gpu quorum on PTX)
97    /// populate `QuorumStatus::Multi { ... }` with the diff_exec result.
98    ///
99    /// Defaults via serde to `Single { emitter: "unknown" }` for
100    /// backward-compatible deserialization of older JSON payloads.
101    #[serde(default = "default_quorum_status")]
102    pub quorum_status: QuorumStatus,
103}
104
105fn default_quorum_status() -> QuorumStatus {
106    QuorumStatus::Single {
107        emitter: "unknown".to_string(),
108    }
109}
110
111#[derive(Debug, thiserror::Error)]
112pub enum BackendError {
113    #[error("unsupported target: {0:?}")]
114    UnsupportedTarget(Target),
115    #[error("missing hardware profile for target {0:?}")]
116    MissingHardware(Target),
117    #[error("lowering error: {0}")]
118    Lower(String),
119    #[error("compile-contract citation missing for emitted construct: {0}")]
120    MissingCompileContractCitation(String),
121}
122
123/// Code-lane emission trait. See `docs/specifications/sub/backend-trait.md`.
124pub trait Backend: Send + Sync {
125    /// Human-readable backend name, e.g. "rust", "ptx", "wgsl".
126    fn name(&self) -> &'static str;
127
128    /// Targets this backend can emit. Each [`Target`] variant is
129    /// owned by exactly one Backend impl (`target_ownership` invariant).
130    fn targets(&self) -> &[Target];
131
132    /// Lower a meta-HIR module under the given config to an [`Artifact`].
133    ///
134    /// Invariants: deterministic per `(module, config)`; frame-pure
135    /// (no mutation of inputs); every target-specific IR construct
136    /// in `Artifact.primary` cited via `Artifact.citations`.
137    fn lower(&self, module: &Module, config: &BackendConfig) -> Result<Artifact, BackendError>;
138}
139
140// ─── PMAT-261 / Section 29: Multi-emitter quorum scaffolding ────────────
141//
142// Types codifying the design in
143// `docs/specifications/sub/layer5-multi-emitter-quorum.md`. Pure
144// scaffolding at PMAT-261 — no Backend impl yet uses these. Future PRs
145// (rustc_codegen_nvvm wiring, aprender-gpu bridge, DiffExec engine)
146// build against this stable API surface.
147
148/// Role of a backend emitter within a multi-emitter quorum.
149///
150/// `compile_targets.via.role` in the YAML schema corresponds to this
151/// enum. At most one `General` per quorum (the mandatory fallback);
152/// any number of `Specialist` (each with its own `shape_filter`).
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum EmitterRole {
156    /// Handles any contract-conforming input. Mandatory fallback —
157    /// `pv lint` (post-PMAT-262) will require at least one `General`
158    /// emitter per Layer-5 contract's `compile_targets.via`.
159    /// Examples: `rustc_codegen_nvvm` (PTX), `naga` (WGSL),
160    /// `rspirv` (SPIR-V).
161    General,
162    /// Handles a domain-specific subset via hand-tuned templates.
163    /// Optional — degrades gracefully to single-emitter when missing.
164    /// Examples: `aprender-gpu` (GEMM/MMA PTX kernels),
165    /// `bashrs-realistic` (corpus-tuned POSIX patterns).
166    Specialist,
167}
168
169/// Policy for combining outputs when both General and Specialist
170/// emitters fire on the same input. Configured per Layer-5 contract
171/// via `compile_targets.quorum_policy` in the YAML.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case", tag = "kind")]
174pub enum QuorumPolicy {
175    /// If the specialist handles the kernel, use its output. Falls
176    /// back to general otherwise. Single-vote Runtime stratum.
177    PreferSpecialist,
178    /// Emit via BOTH, run BOTH on test inputs, compare numerical
179    /// outputs within tolerance. Multi-vote Runtime stratum.
180    /// **Falsifies the contract on divergence** — this is the
181    /// stratum-upgrading policy that closes the §4 "Run=1 demo
182    /// fixture" caveat from audit-design.md.
183    DiffExec {
184        /// Maximum allowed absolute difference between corresponding
185        /// numerical outputs of the two emitters.
186        tolerance: f64,
187    },
188    /// Strict text-equality between PTX outputs. Useful for
189    /// regression-locking, NOT for falsification — different valid
190    /// PTX programs commonly produce identical execution results via
191    /// different instruction sequences.
192    Strict,
193}
194
195/// Status of a multi-emitter quorum vote, attached to an [`Artifact`]
196/// produced by a multi-emitter backend.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198#[serde(tag = "kind", rename_all = "snake_case")]
199pub enum QuorumStatus {
200    /// Only one emitter fired (specialist missing for this shape, or
201    /// quorum policy is `PreferSpecialist` and specialist matched).
202    /// Runtime stratum vote count: 1.
203    Single { emitter: String },
204    /// Both emitters fired; outputs were combined under the policy.
205    /// Runtime stratum vote count: 2.
206    Multi {
207        emitters: Vec<String>,
208        diff_exec: Option<DiffExecResult>,
209    },
210}
211
212/// Result of a `DiffExec` quorum policy execution. Two PTX (or WGSL/
213/// SPIR-V) programs were run on test inputs; the engine compared
214/// their numerical outputs.
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216#[serde(tag = "kind", rename_all = "snake_case")]
217pub enum DiffExecResult {
218    /// Outputs matched within tolerance. Records the max absolute
219    /// difference observed for audit trail.
220    Match { max_abs_diff: f64 },
221    /// Outputs diverged beyond tolerance. **Contract violation** —
222    /// CI fails. Records the divergence for diagnosis.
223    Divergent { max_abs_diff: f64, tolerance: f64 },
224    /// Engine did not run (e.g., test hardware unavailable). Vote
225    /// downgrades from Runtime to a placeholder. The substrate
226    /// records this rather than silently dropping it.
227    NotRun { reason: String },
228}
229
230/// A single emitter entry from `compile_targets.via` in the YAML
231/// schema. Mirrors the structured-record form spec'd in
232/// `sub/layer5-multi-emitter-quorum.md` §"Contract YAML schema extension".
233///
234/// At v0.1.0 the YAML schema is still a flat `[String]`; this struct
235/// is the v0.2.0+ target representation `pv lint` will deserialize.
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237pub struct ViaEntry {
238    /// Emitter name, e.g. "rustc_codegen_nvvm", "aprender-gpu".
239    pub emitter: String,
240    /// Role within the quorum.
241    pub role: EmitterRole,
242    /// Local crate that registers this emitter (for `role: general`).
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub crate_name: Option<String>,
245    /// Cross-repo binding target (for `role: specialist` cases where
246    /// the emitter lives in a different fleet repo, e.g. aprender).
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub cross_repo: Option<String>,
249    /// Optional shape filter — for specialists, identifies which
250    /// input shapes this emitter handles. Tied to a sub-contract
251    /// (e.g., `gemm_fp16_mma_64x128` matches aprender's
252    /// `C-COMPUTE-GEMM-FP16-MMA` contract).
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub shape_filter: Option<String>,
255}
256
257// ─── PMAT-263 / Section 29: TargetEmitter trait + MultiEmitterBackend ────
258//
259// Routing layer for multi-emitter backends. A [`MultiEmitterBackend`]
260// composes a general emitter (mandatory fallback) + an optional
261// specialist emitter under a [`QuorumPolicy`]. Single-emitter and
262// multi-emitter cases produce explicit `QuorumStatus` on the emitted
263// [`Artifact`].
264
265/// Plain text emitted by a single [`TargetEmitter`] before the
266/// multi-emitter routing decides what to put in the final [`Artifact`].
267/// Doesn't carry `QuorumStatus` (that's chosen by the wrapper).
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269pub struct EmittedText {
270    pub primary: String,
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    pub citations: Vec<ContractId>,
273}
274
275/// Single-emitter trait — sub-trait of a multi-emitter backend.
276/// One [`TargetEmitter`] handles emission for one logical path
277/// (general vs specialist). Each [`MultiEmitterBackend`] wraps
278/// one mandatory general emitter and optionally one specialist.
279pub trait TargetEmitter: Send + Sync {
280    /// Human-readable emitter name (used in [`QuorumStatus`]).
281    fn name(&self) -> &str;
282
283    /// Attempt to emit for this input. Specialists return `None`
284    /// when their shape filter doesn't match — the wrapper then
285    /// uses only the general emitter. General emitters should
286    /// always return `Some(...)` for any contract-conforming input.
287    fn try_emit(
288        &self,
289        module: &Module,
290        config: &BackendConfig,
291    ) -> Option<Result<EmittedText, BackendError>>;
292}
293
294/// Multi-emitter backend wrapper. Composes a general emitter
295/// (mandatory) + an optional specialist under a [`QuorumPolicy`].
296/// Implements [`Backend`] so it slots into the existing dispatch
297/// without touching `TranspileSession`.
298pub struct MultiEmitterBackend {
299    /// Single target this multi-emitter backend serves
300    /// (e.g., [`Target::Ptx`]).
301    pub target: Target,
302    /// Mandatory general emitter. Must handle any
303    /// contract-conforming input as a fallback.
304    pub general: Box<dyn TargetEmitter>,
305    /// Optional specialist emitter. Returns `None` from `try_emit`
306    /// when its shape filter doesn't match the input.
307    pub specialist: Option<Box<dyn TargetEmitter>>,
308    /// How to combine outputs when both emitters fire.
309    pub quorum_policy: QuorumPolicy,
310}
311
312impl MultiEmitterBackend {
313    pub fn new_single(target: Target, general: Box<dyn TargetEmitter>) -> Self {
314        Self {
315            target,
316            general,
317            specialist: None,
318            quorum_policy: QuorumPolicy::PreferSpecialist,
319        }
320    }
321
322    pub fn new_with_specialist(
323        target: Target,
324        general: Box<dyn TargetEmitter>,
325        specialist: Box<dyn TargetEmitter>,
326        quorum_policy: QuorumPolicy,
327    ) -> Self {
328        Self {
329            target,
330            general,
331            specialist: Some(specialist),
332            quorum_policy,
333        }
334    }
335}
336
337impl Backend for MultiEmitterBackend {
338    fn name(&self) -> &'static str {
339        // The wrapper name is generic; per-emitter names live in
340        // QuorumStatus on each emitted Artifact for audit recovery.
341        "multi-emitter"
342    }
343
344    fn targets(&self) -> &[Target] {
345        std::slice::from_ref(&self.target)
346    }
347
348    fn lower(&self, module: &Module, config: &BackendConfig) -> Result<Artifact, BackendError> {
349        let general_result = self.general.try_emit(module, config).ok_or_else(|| {
350            BackendError::Lower(format!(
351                "general emitter {} must always match contract-conforming input",
352                self.general.name()
353            ))
354        })??;
355
356        let specialist_result = self.specialist.as_ref().and_then(|s| {
357            s.try_emit(module, config)
358                .map(|r| (s.name().to_string(), r))
359        });
360
361        match specialist_result {
362            None => {
363                // Only general fired — Single-vote Runtime stratum.
364                Ok(Artifact {
365                    primary: general_result.primary,
366                    sidecars: Vec::new(),
367                    citations: general_result.citations,
368                    quorum_status: QuorumStatus::Single {
369                        emitter: self.general.name().to_string(),
370                    },
371                })
372            }
373            Some((specialist_name, specialist_emit)) => {
374                let specialist_text = specialist_emit?;
375                match &self.quorum_policy {
376                    QuorumPolicy::PreferSpecialist => {
377                        // Use specialist's output; general was emitted for
378                        // sanity but isn't reported as a vote.
379                        Ok(Artifact {
380                            primary: specialist_text.primary,
381                            sidecars: Vec::new(),
382                            citations: specialist_text.citations,
383                            quorum_status: QuorumStatus::Single {
384                                emitter: specialist_name,
385                            },
386                        })
387                    }
388                    QuorumPolicy::Strict => {
389                        // Text-equality check.
390                        let diff_exec = if general_result.primary == specialist_text.primary {
391                            Some(DiffExecResult::Match { max_abs_diff: 0.0 })
392                        } else {
393                            Some(DiffExecResult::Divergent {
394                                max_abs_diff: f64::INFINITY,
395                                tolerance: 0.0,
396                            })
397                        };
398                        Ok(Artifact {
399                            primary: general_result.primary.clone(),
400                            sidecars: vec![(
401                                "specialist_emission".to_string(),
402                                specialist_text.primary.into_bytes(),
403                            )],
404                            citations: general_result.citations,
405                            quorum_status: QuorumStatus::Multi {
406                                emitters: vec![self.general.name().to_string(), specialist_name],
407                                diff_exec,
408                            },
409                        })
410                    }
411                    QuorumPolicy::DiffExec { tolerance } => {
412                        // Actual numerical execution requires hw / emulator
413                        // — record NotRun until the DiffExec engine PR
414                        // (next phase) plugs in execution.
415                        Ok(Artifact {
416                            primary: general_result.primary.clone(),
417                            sidecars: vec![(
418                                "specialist_emission".to_string(),
419                                specialist_text.primary.into_bytes(),
420                            )],
421                            citations: general_result.citations,
422                            quorum_status: QuorumStatus::Multi {
423                                emitters: vec![self.general.name().to_string(), specialist_name],
424                                diff_exec: Some(DiffExecResult::NotRun {
425                                    reason: format!(
426                                        "DiffExec engine not yet implemented (tolerance was {tolerance})"
427                                    ),
428                                }),
429                            },
430                        })
431                    }
432                }
433            }
434        }
435    }
436}
437
438#[cfg(test)]
439mod quorum_scaffolding_tests {
440    use super::*;
441
442    #[test]
443    fn emitter_role_serde_round_trip() {
444        let general = EmitterRole::General;
445        let s = serde_json::to_string(&general).unwrap();
446        assert_eq!(s, "\"general\"");
447        let back: EmitterRole = serde_json::from_str(&s).unwrap();
448        assert_eq!(back, general);
449
450        let specialist = EmitterRole::Specialist;
451        let s = serde_json::to_string(&specialist).unwrap();
452        assert_eq!(s, "\"specialist\"");
453    }
454
455    #[test]
456    fn quorum_policy_diff_exec_carries_tolerance() {
457        let policy = QuorumPolicy::DiffExec { tolerance: 1.0e-3 };
458        let s = serde_json::to_string(&policy).unwrap();
459        assert!(s.contains("diff_exec"));
460        assert!(s.contains("0.001"));
461        let back: QuorumPolicy = serde_json::from_str(&s).unwrap();
462        assert_eq!(back, policy);
463    }
464
465    #[test]
466    fn quorum_status_multi_records_emitters_and_diff() {
467        let status = QuorumStatus::Multi {
468            emitters: vec!["rustc_codegen_nvvm".into(), "aprender-gpu".into()],
469            diff_exec: Some(DiffExecResult::Match {
470                max_abs_diff: 1.3e-4,
471            }),
472        };
473        let s = serde_json::to_string(&status).unwrap();
474        assert!(s.contains("multi"));
475        assert!(s.contains("rustc_codegen_nvvm"));
476        assert!(s.contains("aprender-gpu"));
477    }
478
479    #[test]
480    fn diff_exec_divergent_carries_both_diff_and_tolerance() {
481        let r = DiffExecResult::Divergent {
482            max_abs_diff: 0.5,
483            tolerance: 0.001,
484        };
485        let s = serde_json::to_string(&r).unwrap();
486        let back: DiffExecResult = serde_json::from_str(&s).unwrap();
487        assert_eq!(back, r);
488    }
489
490    #[test]
491    fn via_entry_general_has_no_specialist_fields() {
492        let v = ViaEntry {
493            emitter: "rustc_codegen_nvvm".into(),
494            role: EmitterRole::General,
495            crate_name: Some("xpile-ptx-codegen".into()),
496            cross_repo: None,
497            shape_filter: None,
498        };
499        let s = serde_json::to_string(&v).unwrap();
500        // Optional None fields skipped from output.
501        assert!(!s.contains("cross_repo"));
502        assert!(!s.contains("shape_filter"));
503        assert!(s.contains("general"));
504    }
505
506    #[test]
507    fn via_entry_specialist_carries_cross_repo_and_shape_filter() {
508        let v = ViaEntry {
509            emitter: "aprender-gpu".into(),
510            role: EmitterRole::Specialist,
511            crate_name: None,
512            cross_repo: Some("aprender".into()),
513            shape_filter: Some("gemm_fp16_mma_64x128".into()),
514        };
515        let s = serde_json::to_string(&v).unwrap();
516        assert!(s.contains("specialist"));
517        assert!(s.contains("aprender"));
518        assert!(s.contains("gemm_fp16_mma_64x128"));
519    }
520
521    /// PMAT-262: Artifact carries QuorumStatus; default deserialization
522    /// gracefully populates Single { emitter: "unknown" } for older JSON
523    /// payloads that predate the field.
524    #[test]
525    fn artifact_quorum_status_defaults_for_older_payloads() {
526        let legacy_json = r#"{"primary":"// test","sidecars":[],"citations":[]}"#;
527        let a: Artifact = serde_json::from_str(legacy_json).unwrap();
528        assert_eq!(
529            a.quorum_status,
530            QuorumStatus::Single {
531                emitter: "unknown".to_string()
532            }
533        );
534    }
535
536    /// PMAT-262: Artifact round-trips QuorumStatus::Single produced by
537    /// every single-emitter backend at v0.1.0.
538    #[test]
539    fn artifact_quorum_status_single_round_trips() {
540        let a = Artifact {
541            primary: "// test".into(),
542            sidecars: Vec::new(),
543            citations: Vec::new(),
544            quorum_status: QuorumStatus::Single {
545                emitter: "xpile-rust-codegen".to_string(),
546            },
547        };
548        let s = serde_json::to_string(&a).unwrap();
549        let back: Artifact = serde_json::from_str(&s).unwrap();
550        assert_eq!(back, a);
551    }
552
553    // ─── PMAT-263: MultiEmitterBackend routing tests with mock emitters ─
554
555    /// Mock emitter returning a fixed primary string and always matching
556    /// (use for `general` role). Cloneable to construct two copies for
557    /// match cases.
558    struct MockGeneral {
559        name: &'static str,
560        body: String,
561    }
562    impl TargetEmitter for MockGeneral {
563        fn name(&self) -> &str {
564            self.name
565        }
566        fn try_emit(
567            &self,
568            _module: &Module,
569            _config: &BackendConfig,
570        ) -> Option<Result<EmittedText, BackendError>> {
571            Some(Ok(EmittedText {
572                primary: self.body.clone(),
573                citations: Vec::new(),
574            }))
575        }
576    }
577
578    /// Mock specialist emitter — matches conditionally and returns a
579    /// configurable body.
580    struct MockSpecialist {
581        name: &'static str,
582        matches: bool,
583        body: String,
584    }
585    impl TargetEmitter for MockSpecialist {
586        fn name(&self) -> &str {
587            self.name
588        }
589        fn try_emit(
590            &self,
591            _module: &Module,
592            _config: &BackendConfig,
593        ) -> Option<Result<EmittedText, BackendError>> {
594            if self.matches {
595                Some(Ok(EmittedText {
596                    primary: self.body.clone(),
597                    citations: Vec::new(),
598                }))
599            } else {
600                None
601            }
602        }
603    }
604
605    fn dummy_module() -> Module {
606        Module {
607            name: "test".into(),
608            source_lang: xpile_meta_hir::SourceLang::Rust,
609            items: Vec::new(),
610            ffi_boundaries: Vec::new(),
611        }
612    }
613
614    fn dummy_config() -> BackendConfig {
615        BackendConfig {
616            target: Target::Ptx,
617            profile: Profile::RustOut,
618            hardware: None,
619        }
620    }
621
622    #[test]
623    fn multi_emitter_specialist_missing_falls_back_to_general() {
624        let backend = MultiEmitterBackend::new_single(
625            Target::Ptx,
626            Box::new(MockGeneral {
627                name: "general",
628                body: "general output".into(),
629            }),
630        );
631        let artifact = backend.lower(&dummy_module(), &dummy_config()).unwrap();
632        assert_eq!(artifact.primary, "general output");
633        assert_eq!(
634            artifact.quorum_status,
635            QuorumStatus::Single {
636                emitter: "general".to_string()
637            }
638        );
639    }
640
641    #[test]
642    fn multi_emitter_specialist_unmatched_falls_back_to_general() {
643        let backend = MultiEmitterBackend::new_with_specialist(
644            Target::Ptx,
645            Box::new(MockGeneral {
646                name: "general",
647                body: "general output".into(),
648            }),
649            Box::new(MockSpecialist {
650                name: "specialist",
651                matches: false,
652                body: "specialist output".into(),
653            }),
654            QuorumPolicy::DiffExec { tolerance: 1e-3 },
655        );
656        let artifact = backend.lower(&dummy_module(), &dummy_config()).unwrap();
657        assert_eq!(artifact.primary, "general output");
658        // Specialist returned None → Single vote.
659        assert_eq!(
660            artifact.quorum_status,
661            QuorumStatus::Single {
662                emitter: "general".to_string()
663            }
664        );
665    }
666
667    #[test]
668    fn multi_emitter_prefer_specialist_uses_specialist_output() {
669        let backend = MultiEmitterBackend::new_with_specialist(
670            Target::Ptx,
671            Box::new(MockGeneral {
672                name: "general",
673                body: "general".into(),
674            }),
675            Box::new(MockSpecialist {
676                name: "specialist",
677                matches: true,
678                body: "specialist tuned".into(),
679            }),
680            QuorumPolicy::PreferSpecialist,
681        );
682        let artifact = backend.lower(&dummy_module(), &dummy_config()).unwrap();
683        assert_eq!(artifact.primary, "specialist tuned");
684        assert_eq!(
685            artifact.quorum_status,
686            QuorumStatus::Single {
687                emitter: "specialist".to_string()
688            }
689        );
690    }
691
692    #[test]
693    fn multi_emitter_strict_match_records_zero_diff() {
694        let backend = MultiEmitterBackend::new_with_specialist(
695            Target::Ptx,
696            Box::new(MockGeneral {
697                name: "general",
698                body: "same output".into(),
699            }),
700            Box::new(MockSpecialist {
701                name: "specialist",
702                matches: true,
703                body: "same output".into(),
704            }),
705            QuorumPolicy::Strict,
706        );
707        let artifact = backend.lower(&dummy_module(), &dummy_config()).unwrap();
708        match artifact.quorum_status {
709            QuorumStatus::Multi {
710                emitters,
711                diff_exec,
712            } => {
713                assert_eq!(emitters, vec!["general", "specialist"]);
714                assert_eq!(diff_exec, Some(DiffExecResult::Match { max_abs_diff: 0.0 }));
715            }
716            _ => panic!("expected Multi quorum status"),
717        }
718        // Specialist's output recorded as sidecar for audit trail.
719        assert_eq!(artifact.sidecars.len(), 1);
720        assert_eq!(artifact.sidecars[0].0, "specialist_emission");
721    }
722
723    #[test]
724    fn multi_emitter_strict_divergence_records_infinity() {
725        let backend = MultiEmitterBackend::new_with_specialist(
726            Target::Ptx,
727            Box::new(MockGeneral {
728                name: "general",
729                body: "general output".into(),
730            }),
731            Box::new(MockSpecialist {
732                name: "specialist",
733                matches: true,
734                body: "different output".into(),
735            }),
736            QuorumPolicy::Strict,
737        );
738        let artifact = backend.lower(&dummy_module(), &dummy_config()).unwrap();
739        match artifact.quorum_status {
740            QuorumStatus::Multi { diff_exec, .. } => {
741                assert!(matches!(diff_exec, Some(DiffExecResult::Divergent { .. })));
742            }
743            _ => panic!("expected Multi quorum status"),
744        }
745    }
746
747    #[test]
748    fn multi_emitter_diff_exec_records_not_run_until_engine_plugged_in() {
749        let backend = MultiEmitterBackend::new_with_specialist(
750            Target::Ptx,
751            Box::new(MockGeneral {
752                name: "rustc_codegen_nvvm",
753                body: "ptx general".into(),
754            }),
755            Box::new(MockSpecialist {
756                name: "aprender-gpu",
757                matches: true,
758                body: "ptx specialist".into(),
759            }),
760            QuorumPolicy::DiffExec { tolerance: 1e-3 },
761        );
762        let artifact = backend.lower(&dummy_module(), &dummy_config()).unwrap();
763        match artifact.quorum_status {
764            QuorumStatus::Multi {
765                emitters,
766                diff_exec,
767            } => {
768                assert_eq!(emitters, vec!["rustc_codegen_nvvm", "aprender-gpu"]);
769                // DiffExec engine isn't plugged in yet — should record NotRun.
770                assert!(matches!(diff_exec, Some(DiffExecResult::NotRun { .. })));
771            }
772            _ => panic!("expected Multi quorum status"),
773        }
774    }
775
776    // ─── PMAT-266: Adversarial invariants for MultiEmitterBackend ───
777    //
778    // These tests pin down security-relevant contract behavior that the
779    // PMAT-263 happy-path tests don't cover: citation provenance, error
780    // propagation, hidden-divergence documentation, and observability of
781    // the `NotRun` reason. They guard against silent regressions in the
782    // routing layer that would weaken the Section 29 oracle.
783
784    /// Mock emitter with configurable citations — used to verify which
785    /// emitter's citations end up in the final Artifact.
786    struct MockGeneralWithCitations {
787        body: String,
788        citations: Vec<ContractId>,
789    }
790    impl TargetEmitter for MockGeneralWithCitations {
791        fn name(&self) -> &str {
792            "general-with-cites"
793        }
794        fn try_emit(
795            &self,
796            _module: &Module,
797            _config: &BackendConfig,
798        ) -> Option<Result<EmittedText, BackendError>> {
799            Some(Ok(EmittedText {
800                primary: self.body.clone(),
801                citations: self.citations.clone(),
802            }))
803        }
804    }
805
806    /// Mock specialist that always matches and carries configurable
807    /// citations distinct from `MockGeneralWithCitations`.
808    struct MockSpecialistWithCitations {
809        body: String,
810        citations: Vec<ContractId>,
811    }
812    impl TargetEmitter for MockSpecialistWithCitations {
813        fn name(&self) -> &str {
814            "specialist-with-cites"
815        }
816        fn try_emit(
817            &self,
818            _module: &Module,
819            _config: &BackendConfig,
820        ) -> Option<Result<EmittedText, BackendError>> {
821            Some(Ok(EmittedText {
822                primary: self.body.clone(),
823                citations: self.citations.clone(),
824            }))
825        }
826    }
827
828    /// Mock emitter that always fails — used to verify error
829    /// propagation from each role.
830    struct MockFailingEmitter {
831        name: &'static str,
832        err: String,
833    }
834    impl TargetEmitter for MockFailingEmitter {
835        fn name(&self) -> &str {
836            self.name
837        }
838        fn try_emit(
839            &self,
840            _module: &Module,
841            _config: &BackendConfig,
842        ) -> Option<Result<EmittedText, BackendError>> {
843            Some(Err(BackendError::Lower(self.err.clone())))
844        }
845    }
846
847    /// Mock emitter that returns `None` — for `general`, this is a
848    /// contract violation (general MUST match contract-conforming
849    /// input); the wrapper must surface it as a hard `BackendError`.
850    struct MockNoneEmitter;
851    impl TargetEmitter for MockNoneEmitter {
852        fn name(&self) -> &str {
853            "always-none"
854        }
855        fn try_emit(
856            &self,
857            _module: &Module,
858            _config: &BackendConfig,
859        ) -> Option<Result<EmittedText, BackendError>> {
860            None
861        }
862    }
863
864    #[test]
865    fn strict_divergence_preserves_general_citations_not_specialist() {
866        // Security invariant: under `Strict`, citations come from
867        // `general`. The proof lane relies on this — citations identify
868        // which contracts the artifact was authored against. A
869        // specialist that disagrees with general must not be able to
870        // silently swap its own citations into the audit trail.
871        let backend = MultiEmitterBackend::new_with_specialist(
872            Target::Ptx,
873            Box::new(MockGeneralWithCitations {
874                body: "general output".into(),
875                citations: vec![ContractId::new("C-GENERAL-CITED")],
876            }),
877            Box::new(MockSpecialistWithCitations {
878                body: "different output".into(),
879                citations: vec![ContractId::new("C-SPECIALIST-CITED")],
880            }),
881            QuorumPolicy::Strict,
882        );
883        let artifact = backend.lower(&dummy_module(), &dummy_config()).unwrap();
884        assert_eq!(artifact.citations.len(), 1);
885        assert_eq!(artifact.citations[0].as_str(), "C-GENERAL-CITED");
886        // Specialist's body is still recoverable from the sidecar even
887        // though its citations are dropped.
888        assert_eq!(artifact.sidecars.len(), 1);
889        assert_eq!(
890            artifact.sidecars[0].1,
891            b"different output".to_vec(),
892            "specialist body should be preserved in sidecar"
893        );
894    }
895
896    #[test]
897    fn prefer_specialist_hides_divergence_by_design() {
898        // Documented trade-off: `PreferSpecialist` is the
899        // single-vote-runtime stratum — it intentionally does NOT
900        // compare general vs specialist. Use `Strict` or `DiffExec`
901        // when divergence detection matters. This test pins down the
902        // behavior so a future "helpful" refactor can't accidentally
903        // turn this into a quiet divergence detector.
904        let backend = MultiEmitterBackend::new_with_specialist(
905            Target::Ptx,
906            Box::new(MockGeneralWithCitations {
907                body: "general thinks the answer is 42".into(),
908                citations: vec![ContractId::new("C-GENERAL")],
909            }),
910            Box::new(MockSpecialistWithCitations {
911                body: "specialist thinks the answer is 99".into(),
912                citations: vec![ContractId::new("C-SPECIALIST")],
913            }),
914            QuorumPolicy::PreferSpecialist,
915        );
916        let artifact = backend.lower(&dummy_module(), &dummy_config()).unwrap();
917        // Specialist's body wins; specialist's citations win;
918        // QuorumStatus reports Single (no divergence captured).
919        assert!(artifact.primary.contains("99"));
920        assert_eq!(artifact.citations[0].as_str(), "C-SPECIALIST");
921        match artifact.quorum_status {
922            QuorumStatus::Single { emitter } => {
923                assert_eq!(emitter, "specialist-with-cites");
924            }
925            other => panic!("expected Single quorum status, got {other:?}"),
926        }
927        // No sidecar — general's emission isn't even captured.
928        assert!(artifact.sidecars.is_empty());
929    }
930
931    #[test]
932    fn general_emitter_failure_propagates() {
933        // If `general` returns Some(Err(...)), the wrapper must
934        // propagate the error — never silently fall through to
935        // specialist. General is the mandatory fallback; its failure
936        // is the whole backend's failure.
937        let backend = MultiEmitterBackend::new_with_specialist(
938            Target::Ptx,
939            Box::new(MockFailingEmitter {
940                name: "general-broken",
941                err: "general blew up".into(),
942            }),
943            Box::new(MockSpecialist {
944                name: "specialist",
945                matches: true,
946                body: "specialist output".into(),
947            }),
948            QuorumPolicy::PreferSpecialist,
949        );
950        let err = backend.lower(&dummy_module(), &dummy_config()).unwrap_err();
951        match err {
952            BackendError::Lower(msg) => assert!(msg.contains("general blew up")),
953            other => panic!("expected Lower error, got {other:?}"),
954        }
955    }
956
957    #[test]
958    fn specialist_emitter_failure_propagates_when_matched() {
959        // If `specialist` matches and then errors, propagate. This is
960        // a real partial-failure mode for shape-tuned emitters
961        // (matched on shape but failed during lowering).
962        let backend = MultiEmitterBackend::new_with_specialist(
963            Target::Ptx,
964            Box::new(MockGeneral {
965                name: "general",
966                body: "general output".into(),
967            }),
968            Box::new(MockFailingEmitter {
969                name: "specialist-broken",
970                err: "specialist blew up after matching".into(),
971            }),
972            QuorumPolicy::Strict,
973        );
974        let err = backend.lower(&dummy_module(), &dummy_config()).unwrap_err();
975        match err {
976            BackendError::Lower(msg) => {
977                assert!(msg.contains("specialist blew up after matching"))
978            }
979            other => panic!("expected Lower error, got {other:?}"),
980        }
981    }
982
983    #[test]
984    fn general_returning_none_is_a_hard_contract_violation() {
985        // `general` returning `None` from `try_emit` means it refused
986        // to handle contract-conforming input — that's a hard error.
987        // (Specialists are allowed to return None; general isn't.)
988        let backend = MultiEmitterBackend::new_single(Target::Ptx, Box::new(MockNoneEmitter));
989        let err = backend.lower(&dummy_module(), &dummy_config()).unwrap_err();
990        match err {
991            BackendError::Lower(msg) => {
992                assert!(
993                    msg.contains("always-none"),
994                    "error should name the offending emitter; got: {msg}"
995                );
996                assert!(msg.contains("must always match"));
997            }
998            other => panic!("expected Lower error, got {other:?}"),
999        }
1000    }
1001
1002    #[test]
1003    fn diff_exec_not_run_reason_records_tolerance_for_observability() {
1004        // The `NotRun` reason is the user-facing breadcrumb pointing
1005        // at "DiffExec engine not yet wired" — and it must carry the
1006        // configured tolerance so debug output is actionable.
1007        let backend = MultiEmitterBackend::new_with_specialist(
1008            Target::Ptx,
1009            Box::new(MockGeneral {
1010                name: "general",
1011                body: "g".into(),
1012            }),
1013            Box::new(MockSpecialist {
1014                name: "specialist",
1015                matches: true,
1016                body: "s".into(),
1017            }),
1018            QuorumPolicy::DiffExec { tolerance: 2.5e-4 },
1019        );
1020        let artifact = backend.lower(&dummy_module(), &dummy_config()).unwrap();
1021        match artifact.quorum_status {
1022            QuorumStatus::Multi {
1023                diff_exec: Some(DiffExecResult::NotRun { reason }),
1024                ..
1025            } => {
1026                assert!(
1027                    reason.contains("0.00025")
1028                        || reason.contains("2.5e-4")
1029                        || reason.contains("0.000250"),
1030                    "tolerance should appear in NotRun reason; got: {reason}"
1031                );
1032            }
1033            other => panic!("expected Multi NotRun status, got {other:?}"),
1034        }
1035    }
1036
1037    #[test]
1038    fn diff_exec_does_not_short_circuit_on_text_equality() {
1039        // Architectural invariant: even when general and specialist
1040        // emit byte-identical text, `DiffExec` policy must still
1041        // record `NotRun` (because the real engine compares numerical
1042        // outputs after execution, not source text). A future
1043        // optimization that says "skip diff if text matches" would
1044        // break this invariant — the engine's job is to check the
1045        // RUNTIME behavior, and identical source could still produce
1046        // divergent runtime values on different hardware.
1047        let backend = MultiEmitterBackend::new_with_specialist(
1048            Target::Ptx,
1049            Box::new(MockGeneral {
1050                name: "general",
1051                body: "byte identical".into(),
1052            }),
1053            Box::new(MockSpecialist {
1054                name: "specialist",
1055                matches: true,
1056                body: "byte identical".into(),
1057            }),
1058            QuorumPolicy::DiffExec { tolerance: 1e-6 },
1059        );
1060        let artifact = backend.lower(&dummy_module(), &dummy_config()).unwrap();
1061        match artifact.quorum_status {
1062            QuorumStatus::Multi { diff_exec, .. } => {
1063                assert!(
1064                    matches!(diff_exec, Some(DiffExecResult::NotRun { .. })),
1065                    "DiffExec policy must NOT short-circuit on text equality \
1066                     — engine compares runtime values, not source text"
1067                );
1068            }
1069            other => panic!("expected Multi quorum status, got {other:?}"),
1070        }
1071    }
1072}