Skip to main content

myelin_accelerator/
capability.rs

1// Copyright 2026 Raul Montoya Cardenas
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Structured CUDA capability probes and fallback reason codes.
5//!
6//! Call [`probe_capabilities`] before launching work. Selection follows one
7//! documented policy ([`ExecutionPolicy`]):
8//!
9//! * [`ExecutionPolicy::PreferGpu`] — caller-approved CPU fallback, recorded
10//!   as a [`FallbackRecord`].
11//! * [`ExecutionPolicy::RequireGpu`] — fail closed; never execute on CPU.
12//!
13//! Decision table (first matching row wins):
14//!
15//! | `cuda_built` | runtime | device | CC meets PTX target | PTX compiled | kernel runtime | selected | reason |
16//! |--------------|---------|--------|-----------|--------------|----------------|----------|--------|
17//! | false | * | * | * | * | * | `Cpu` | `cuda_feature_not_built` |
18//! | true | false | * | * | * | * | `Cpu` | `driver_runtime_failure` |
19//! | true | true | false | * | * | * | `Cpu` | `device_unavailable` |
20//! | true | true | true | missing | * | * | `Cpu` | `driver_runtime_failure` |
21//! | true | true | true | false | * | * | `Cpu` | `unsupported_hardware` |
22//! | true | true | true | true | false | * | `Cpu` | `cuda_feature_not_built` |
23//! | true | true | true | true | true | any `false` | `Cpu` | `kernel_specialization_unavailable` |
24//! | true | true | true | true | true | any unknown | `Cpu` | `kernel_specialization_unavailable` |
25//! | true | true | true | true | true | all `true` | `Cuda` | — |
26//!
27//! `invalid_input` is a request-level reason (bad launch arguments), not a
28//! host-probe outcome. Stream setup can still fail after a successful probe
29//! with `driver_runtime_failure` while leaving observed runtime/device/kernel
30//! facts intact.
31
32use std::fmt;
33
34/// Selected implementation for a probe or accelerator instance.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum Backend {
37    /// CUDA device kernels (requires the `cuda` feature and a usable GPU).
38    Cuda,
39    /// Host/CPU path (stub buffers, no device launches).
40    Cpu,
41}
42
43impl Backend {
44    /// Stable telemetry token.
45    pub const fn code(self) -> &'static str {
46        match self {
47            Self::Cuda => "cuda",
48            Self::Cpu => "cpu",
49        }
50    }
51}
52
53impl fmt::Display for Backend {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.write_str(self.code())
56    }
57}
58
59/// How public entry points choose a backend when GPU is unusable.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum ExecutionPolicy {
62    /// Use GPU when capable; otherwise CPU with an explicit [`FallbackRecord`].
63    PreferGpu,
64    /// Require GPU. Construction and launch fail; CPU is never selected.
65    RequireGpu,
66}
67
68impl ExecutionPolicy {
69    /// Stable telemetry token.
70    pub const fn code(self) -> &'static str {
71        match self {
72            Self::PreferGpu => "prefer_gpu",
73            Self::RequireGpu => "require_gpu",
74        }
75    }
76}
77
78impl fmt::Display for ExecutionPolicy {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.write_str(self.code())
81    }
82}
83
84/// Stable reason code explaining why GPU was not selected (or why a request failed).
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86#[non_exhaustive]
87pub enum FallbackReason {
88    /// Built without the `cuda` Cargo feature (PTX/kernels not compiled in).
89    CudaFeatureNotBuilt,
90    /// CUDA driver/runtime init or post-init stream setup failed.
91    DriverRuntimeFailure,
92    /// No accessible CUDA device.
93    DeviceUnavailable,
94    /// Device compute capability is below [`REQUIRED_COMPUTE_CAPABILITY`].
95    UnsupportedHardware,
96    /// Required PTX family or kernel symbol is missing after JIT.
97    KernelSpecializationUnavailable,
98    /// Caller-supplied launch arguments are invalid.
99    InvalidInput,
100}
101
102impl FallbackReason {
103    /// Stable snake_case code for tests and telemetry.
104    pub const fn code(self) -> &'static str {
105        match self {
106            Self::CudaFeatureNotBuilt => "cuda_feature_not_built",
107            Self::DriverRuntimeFailure => "driver_runtime_failure",
108            Self::DeviceUnavailable => "device_unavailable",
109            Self::UnsupportedHardware => "unsupported_hardware",
110            Self::KernelSpecializationUnavailable => "kernel_specialization_unavailable",
111            Self::InvalidInput => "invalid_input",
112        }
113    }
114}
115
116impl fmt::Display for FallbackReason {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.write_str(self.code())
119    }
120}
121
122/// CUDA compute capability (`major.minor`), e.g. Blackwell `12.0`.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
124pub struct ComputeCapability {
125    pub major: u32,
126    pub minor: u32,
127}
128
129/// Compute-capability floor encoded by the PTX target selected at build time.
130///
131/// This is `12.0` for the default `sm_120` build and follows an explicit
132/// `MYELIN_CUDA_ARCH` override.
133pub const REQUIRED_COMPUTE_CAPABILITY: ComputeCapability =
134    include!(concat!(env!("OUT_DIR"), "/compiled_cuda_capability.rs"));
135
136impl ComputeCapability {
137    /// Same as [`REQUIRED_COMPUTE_CAPABILITY`].
138    pub const REQUIRED: Self = REQUIRED_COMPUTE_CAPABILITY;
139
140    /// `true` when this capability meets the compiled PTX target.
141    ///
142    /// The default floor is `12.0`; `MYELIN_CUDA_ARCH` can select another
143    /// target at build time.
144    pub const fn meets_minimum(self) -> bool {
145        (self.major as u64) * 10 + self.minor as u64
146            >= (Self::REQUIRED.major as u64) * 10 + Self::REQUIRED.minor as u64
147    }
148}
149
150impl fmt::Display for ComputeCapability {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        write!(f, "{}.{}", self.major, self.minor)
153    }
154}
155
156/// Compile-time and optional runtime availability of the four PTX families.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
158pub struct KernelAvailability {
159    /// PTX for the first-party kernels was compiled into this binary.
160    pub compiled: bool,
161    /// Spiking-network family after JIT, if attempted.
162    pub spiking_network: Option<bool>,
163    /// Vector-similarity family after JIT, if attempted.
164    pub vector_similarity: Option<bool>,
165    /// SAT-solver family after JIT, if attempted.
166    pub satsolver: Option<bool>,
167    /// Ternary GEMV/GEMM family after JIT, if attempted.
168    pub ternary_gemm: Option<bool>,
169}
170
171impl KernelAvailability {
172    /// Nothing compiled in (CPU-only / stub build).
173    pub const fn not_compiled() -> Self {
174        Self {
175            compiled: false,
176            spiking_network: None,
177            vector_similarity: None,
178            satsolver: None,
179            ternary_gemm: None,
180        }
181    }
182
183    /// PTX is embedded; driver JIT has not been attempted.
184    pub const fn compiled_unverified() -> Self {
185        Self {
186            compiled: true,
187            spiking_network: None,
188            vector_similarity: None,
189            satsolver: None,
190            ternary_gemm: None,
191        }
192    }
193
194    /// All four families JIT-loaded successfully.
195    pub const fn all_available() -> Self {
196        Self {
197            compiled: true,
198            spiking_network: Some(true),
199            vector_similarity: Some(true),
200            satsolver: Some(true),
201            ternary_gemm: Some(true),
202        }
203    }
204
205    /// PTX is present but runtime specialization failed.
206    pub const fn all_unavailable() -> Self {
207        Self {
208            compiled: true,
209            spiking_network: Some(false),
210            vector_similarity: Some(false),
211            satsolver: Some(false),
212            ternary_gemm: Some(false),
213        }
214    }
215
216    /// `true` when a runtime probe reported at least one missing family.
217    pub const fn any_runtime_unavailable(self) -> bool {
218        matches!(self.spiking_network, Some(false))
219            || matches!(self.vector_similarity, Some(false))
220            || matches!(self.satsolver, Some(false))
221            || matches!(self.ternary_gemm, Some(false))
222    }
223
224    /// `true` only after all required PTX families JIT-loaded successfully.
225    pub const fn all_runtime_available(self) -> bool {
226        matches!(self.spiking_network, Some(true))
227            && matches!(self.vector_similarity, Some(true))
228            && matches!(self.satsolver, Some(true))
229            && matches!(self.ternary_gemm, Some(true))
230    }
231}
232
233impl Default for KernelAvailability {
234    fn default() -> Self {
235        Self::not_compiled()
236    }
237}
238
239/// Observable inputs to [`evaluate_capabilities`].
240///
241/// Production code uses [`probe_capabilities`]; tests inject mocks here.
242#[derive(Debug, Clone, PartialEq, Eq, Hash)]
243pub struct CapabilityFacts {
244    pub cuda_built: bool,
245    pub runtime_available: bool,
246    pub device_available: bool,
247    pub compute_capability: Option<ComputeCapability>,
248    pub kernels: KernelAvailability,
249}
250
251impl CapabilityFacts {
252    /// Honest CPU-only / not-built snapshot.
253    pub const fn not_built() -> Self {
254        Self {
255            cuda_built: false,
256            runtime_available: false,
257            device_available: false,
258            compute_capability: None,
259            kernels: KernelAvailability::not_compiled(),
260        }
261    }
262}
263
264/// Recorded CPU (or reduced-kernel) selection with a stable reason.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct FallbackRecord {
267    pub reason: FallbackReason,
268    pub selected_backend: Backend,
269    /// Path-free, secret-free diagnostic suitable for tests and telemetry.
270    pub detail: String,
271}
272
273impl FallbackRecord {
274    /// CPU fallback with a sanitized detail string.
275    pub fn cpu(reason: FallbackReason, detail: impl Into<String>) -> Self {
276        Self {
277            reason,
278            selected_backend: Backend::Cpu,
279            detail: sanitize_diagnostic(&detail.into()),
280        }
281    }
282}
283
284/// Typed capability snapshot for callers and telemetry.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct CapabilityReport {
287    pub cuda_built: bool,
288    pub runtime_available: bool,
289    pub device_available: bool,
290    pub compute_capability: Option<ComputeCapability>,
291    pub kernels: KernelAvailability,
292    pub selected_backend: Backend,
293    pub fallback: Option<FallbackRecord>,
294}
295
296impl CapabilityReport {
297    /// `true` when CUDA is the selected backend and no fallback was recorded.
298    pub fn gpu_usable(&self) -> bool {
299        self.selected_backend == Backend::Cuda && self.fallback.is_none()
300    }
301
302    /// Apply an execution policy to this probe.
303    ///
304    /// [`ExecutionPolicy::PreferGpu`] returns the selected backend (possibly
305    /// CPU). [`ExecutionPolicy::RequireGpu`] returns `Err` instead of CPU.
306    pub fn select_backend(&self, policy: ExecutionPolicy) -> Result<Backend, FallbackRecord> {
307        match policy {
308            ExecutionPolicy::PreferGpu => Ok(self.selected_backend),
309            ExecutionPolicy::RequireGpu => {
310                if self.gpu_usable() {
311                    Ok(Backend::Cuda)
312                } else {
313                    Err(self.fallback.clone().unwrap_or_else(|| {
314                        FallbackRecord::cpu(
315                            FallbackReason::DeviceUnavailable,
316                            "GPU was required but is not usable",
317                        )
318                    }))
319                }
320            }
321        }
322    }
323}
324
325/// Probe this process, including driver/device checks and required PTX JIT.
326///
327/// A CUDA backend is reported usable only after constructing an accelerator
328/// has verified every required kernel family.
329///
330/// With the `cuda` feature and an available GPU, every call creates a CUDA
331/// context and loads/JIT-validates all four PTX modules. Cache the returned
332/// report instead of probing repeatedly.
333#[must_use]
334pub fn probe_capabilities() -> CapabilityReport {
335    #[cfg(feature = "cuda")]
336    let _restore_current_context = crate::gpu::context::CurrentContextGuard::capture();
337    crate::GpuAccelerator::new().capabilities().clone()
338}
339
340/// Pure decision function over [`CapabilityFacts`] (mocked tests welcome).
341#[must_use]
342pub fn evaluate_capabilities(facts: &CapabilityFacts) -> CapabilityReport {
343    let (selected_backend, fallback) = select_from_facts(facts);
344    CapabilityReport {
345        cuda_built: facts.cuda_built,
346        runtime_available: facts.runtime_available,
347        device_available: facts.device_available,
348        compute_capability: facts.compute_capability,
349        kernels: facts.kernels,
350        selected_backend,
351        fallback,
352    }
353}
354
355/// Overlay a construction-time fallback onto observed [`CapabilityFacts`].
356///
357/// After every kernel family has JIT-loaded, `DriverRuntimeFailure` is treated
358/// as a later stream/setup failure: runtime and device stay available.
359pub(crate) fn apply_failure_to_facts(facts: &mut CapabilityFacts, reason: FallbackReason) {
360    match reason {
361        FallbackReason::CudaFeatureNotBuilt => {
362            facts.cuda_built = false;
363            facts.kernels = KernelAvailability::not_compiled();
364        }
365        FallbackReason::DriverRuntimeFailure => {
366            if !facts.kernels.all_runtime_available() {
367                facts.runtime_available = false;
368            }
369        }
370        FallbackReason::DeviceUnavailable => {
371            facts.device_available = false;
372        }
373        FallbackReason::UnsupportedHardware
374        | FallbackReason::KernelSpecializationUnavailable
375        | FallbackReason::InvalidInput => {}
376    }
377}
378
379fn select_from_facts(facts: &CapabilityFacts) -> (Backend, Option<FallbackRecord>) {
380    if !facts.cuda_built {
381        return cpu(
382            FallbackReason::CudaFeatureNotBuilt,
383            "crate built without the cuda feature",
384        );
385    }
386    if !facts.runtime_available {
387        return cpu(
388            FallbackReason::DriverRuntimeFailure,
389            "CUDA driver or runtime init failed",
390        );
391    }
392    if !facts.device_available {
393        return cpu(
394            FallbackReason::DeviceUnavailable,
395            "no CUDA device is accessible",
396        );
397    }
398    match facts.compute_capability {
399        None => {
400            return cpu(
401                FallbackReason::DriverRuntimeFailure,
402                "compute capability query failed",
403            );
404        }
405        Some(cc) if !cc.meets_minimum() => {
406            return cpu(
407                FallbackReason::UnsupportedHardware,
408                format!(
409                    "compute capability {cc} is below required {}",
410                    ComputeCapability::REQUIRED
411                ),
412            );
413        }
414        Some(_) => {}
415    }
416    if !facts.kernels.compiled {
417        return cpu(
418            FallbackReason::CudaFeatureNotBuilt,
419            "required PTX families were not compiled into this binary",
420        );
421    }
422    if !facts.kernels.all_runtime_available() {
423        return cpu(
424            FallbackReason::KernelSpecializationUnavailable,
425            "required kernel specialization is unavailable",
426        );
427    }
428    (Backend::Cuda, None)
429}
430
431fn cpu(reason: FallbackReason, detail: impl Into<String>) -> (Backend, Option<FallbackRecord>) {
432    (Backend::Cpu, Some(FallbackRecord::cpu(reason, detail)))
433}
434
435/// Strip absolute user paths and obvious secret assignments from diagnostics.
436#[must_use]
437pub fn sanitize_diagnostic(input: &str) -> String {
438    if input.is_empty() {
439        return String::new();
440    }
441    let tokens = diagnostic_tokens(input);
442    let mut out = Vec::with_capacity(tokens.len());
443    let mut index = 0;
444    while index < tokens.len() {
445        if let Some((redacted, consumed)) = redact_authorization_sequence(&tokens[index..]) {
446            out.extend(redacted);
447            index += consumed;
448            continue;
449        }
450        if let Some((redacted, consumed)) = redact_secret_sequence(&tokens[index..]) {
451            out.extend(redacted);
452            index += consumed;
453            continue;
454        }
455        if json_secret_key_without_value(tokens[index]) && index + 1 < tokens.len() {
456            out.push(sanitize_token(tokens[index]));
457            out.push(redact_following_value_token(tokens[index + 1]));
458            index += 2;
459            continue;
460        }
461        out.push(sanitize_token(tokens[index]));
462        index += 1;
463    }
464    out.join(" ")
465}
466
467fn redact_secret_sequence(tokens: &[&str]) -> Option<(Vec<String>, usize)> {
468    let (_, core, suffix) = split_wrapping_punct(tokens[0]);
469    if core.contains('=')
470        || core
471            .split_once(':')
472            .is_some_and(|(_, value)| !value.is_empty())
473        || json_assignment_parts(core).is_some()
474    {
475        return None;
476    }
477    let key = core
478        .trim_end_matches(':')
479        .trim_matches(|ch| matches!(ch, '"' | '\''))
480        .trim_start_matches('-');
481    if key.is_empty()
482        || key.chars().any(char::is_whitespace)
483        || !is_secret_key(&key.to_ascii_lowercase())
484    {
485        return None;
486    }
487
488    let key_has_separator = core.ends_with(':') || suffix.contains(':');
489    if key_has_separator {
490        let value = tokens.get(1)?;
491        return Some((
492            vec![
493                sanitize_token(tokens[0]),
494                redact_following_value_token(value),
495            ],
496            2,
497        ));
498    }
499
500    if matches!(tokens.get(1).copied(), Some("=" | ":")) {
501        let value = tokens.get(2)?;
502        return Some((
503            vec![
504                sanitize_token(tokens[0]),
505                sanitize_token(tokens[1]),
506                redact_following_value_token(value),
507            ],
508            3,
509        ));
510    }
511
512    let value = tokens.get(1)?;
513    Some((
514        vec![
515            sanitize_token(tokens[0]),
516            redact_following_value_token(value),
517        ],
518        2,
519    ))
520}
521
522fn diagnostic_tokens(input: &str) -> Vec<&str> {
523    let mut tokens = Vec::new();
524    let mut start = None;
525    let mut quote = None;
526
527    for (index, ch) in input.char_indices() {
528        if start.is_none() {
529            if ch.is_whitespace() {
530                continue;
531            }
532            start = Some(index);
533        }
534
535        match quote {
536            Some(open) if ch == open => quote = None,
537            Some(_) => {}
538            None if matches!(ch, '"' | '\'')
539                && quote_opens_group(input, start.expect("token start is set"), index) =>
540            {
541                quote = Some(ch);
542            }
543            None if ch.is_whitespace() => {
544                if let Some(token_start) = start.take() {
545                    tokens.push(&input[token_start..index]);
546                }
547            }
548            None => {}
549        }
550    }
551
552    if let Some(token_start) = start {
553        tokens.push(&input[token_start..]);
554    }
555    tokens
556}
557
558fn quote_opens_group(input: &str, token_start: usize, quote_index: usize) -> bool {
559    let before_quote = &input[token_start..quote_index];
560    before_quote.ends_with('=') || before_quote.chars().all(|ch| matches!(ch, '(' | '[' | '{'))
561}
562
563fn sanitize_token(tok: &str) -> String {
564    if tok.contains([',', ';']) {
565        let mut sanitized = String::with_capacity(tok.len());
566        let mut field_start = 0;
567        for (index, separator) in tok.match_indices([',', ';']) {
568            sanitized.push_str(&sanitize_token(&tok[field_start..index]));
569            sanitized.push_str(separator);
570            field_start = index + separator.len();
571        }
572        sanitized.push_str(&sanitize_token(&tok[field_start..]));
573        return sanitized;
574    }
575    let (prefix, core, suffix) = split_wrapping_punct(tok);
576    let lower = core.to_ascii_lowercase();
577    if is_user_path(&lower) {
578        return format!("{prefix}<path>{suffix}");
579    }
580    if let Some(redacted) = redact_authorization_core(core) {
581        return format!("{prefix}{redacted}{suffix}");
582    }
583    if let Some(redacted) = redact_json_assignment(core) {
584        return format!("{prefix}{redacted}{suffix}");
585    }
586    if let Some((key, value)) = lower.split_once('=') {
587        let orig_key = core.split_once('=').map(|(k, _)| k).unwrap_or(core);
588        let value_quote = value.chars().next().filter(|ch| matches!(ch, '"' | '\''));
589        let bare_value = value_quote
590            .and_then(|quote| value.strip_prefix(quote))
591            .unwrap_or(value);
592        let assignment_suffix = value_quote
593            .and_then(|quote| suffix.strip_prefix(quote))
594            .unwrap_or(suffix);
595        if is_secret_key(key.trim_start_matches('-')) {
596            return format!("{prefix}{orig_key}=<redacted>{assignment_suffix}");
597        }
598        if is_user_path(bare_value) {
599            return format!("{prefix}{orig_key}=<path>{assignment_suffix}");
600        }
601    }
602    if let Some((key, value)) = lower.split_once(':')
603        && !value.is_empty()
604    {
605        let orig_key = core.split_once(':').map(|(key, _)| key).unwrap_or(core);
606        let normalized_key = key.trim_start_matches('-');
607        if is_secret_key(normalized_key) {
608            return format!("{prefix}{orig_key}:<redacted>{suffix}");
609        }
610        if is_user_path(value) {
611            return format!("{prefix}{orig_key}:<path>{suffix}");
612        }
613    }
614    tok.to_string()
615}
616
617fn redact_authorization_sequence(tokens: &[&str]) -> Option<(Vec<String>, usize)> {
618    let (header_prefix, header_core, header_suffix) = split_wrapping_punct(tokens[0]);
619    if attached_authorization_scheme(header_core) {
620        if tokens.len() < 2 {
621            return None;
622        }
623        return Some((
624            vec![
625                format!("{header_prefix}{header_core}{header_suffix}"),
626                redact_following_value_token(tokens[1]),
627            ],
628            2,
629        ));
630    }
631    if !authorization_header_token(header_core, header_suffix) {
632        return None;
633    }
634    if redact_authorization_core(header_core).is_some() {
635        return None;
636    }
637    if tokens.len() < 2 || !is_http_auth_scheme_token(tokens[1]) {
638        return None;
639    }
640    let mut redacted = vec![
641        format!("{header_prefix}{header_core}{header_suffix}"),
642        sanitize_token(tokens[1]),
643    ];
644    if tokens.len() >= 3 && !json_secret_key_without_value(tokens[2]) {
645        let (_, scheme_core, _) = split_wrapping_punct(tokens[1]);
646        if scheme_core.split_whitespace().nth(1).is_none() {
647            redacted.push(redact_following_value_token(tokens[2]));
648            return Some((redacted, 3));
649        }
650    }
651    Some((redacted, 2))
652}
653
654fn attached_authorization_scheme(core: &str) -> bool {
655    core.to_ascii_lowercase()
656        .strip_prefix("authorization:")
657        .is_some_and(|rest| matches!(rest, "bearer" | "basic"))
658}
659
660fn authorization_header_token(core: &str, suffix: &str) -> bool {
661    let lower = core.to_ascii_lowercase();
662    if lower == "authorization" {
663        return suffix.starts_with(':');
664    }
665    lower
666        .strip_prefix("authorization:")
667        .map(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
668        .unwrap_or(false)
669}
670
671fn is_http_auth_scheme_token(tok: &str) -> bool {
672    let (_, core, _) = split_wrapping_punct(tok);
673    let scheme = core
674        .split(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\''))
675        .find(|part| !part.is_empty())
676        .unwrap_or(core);
677    scheme.eq_ignore_ascii_case("bearer") || scheme.eq_ignore_ascii_case("basic")
678}
679
680fn redact_authorization_core(core: &str) -> Option<String> {
681    let lower = core.to_ascii_lowercase();
682    const HEADER: &str = "authorization:";
683    let after_header = lower.strip_prefix(HEADER)?;
684    let trim_len = after_header.len() - after_header.trim_start().len();
685    let rest_lower = after_header.trim_start();
686    for scheme in ["bearer", "basic"] {
687        let Some(after_scheme) = rest_lower.strip_prefix(scheme) else {
688            continue;
689        };
690        if after_scheme.is_empty() {
691            return None;
692        }
693        let sep = after_scheme.chars().next()?;
694        if !(sep.is_whitespace() || matches!(sep, '"' | '\'')) {
695            continue;
696        }
697        let prefix_len = HEADER.len() + trim_len + scheme.len();
698        let orig_prefix = &core[..prefix_len];
699        if sep == '"' {
700            return Some(format!("{orig_prefix}\"<redacted>\""));
701        }
702        if sep == '\'' {
703            return Some(format!("{orig_prefix}'<redacted>'"));
704        }
705        return Some(format!("{orig_prefix} <redacted>"));
706    }
707    None
708}
709
710fn json_assignment_parts(core: &str) -> Option<(&str, &'static str, &str)> {
711    const SEPARATORS: [&str; 6] = [r#"":""#, "':'", r#"":'"#, r#"':""#, r#"":"#, "':"];
712    for sep in SEPARATORS {
713        if let Some((key, value)) = core.split_once(sep) {
714            if key.is_empty() || key.chars().any(char::is_whitespace) {
715                continue;
716            }
717            return Some((key, sep, value));
718        }
719    }
720    None
721}
722
723fn redact_json_assignment(core: &str) -> Option<String> {
724    let (key, sep, value) = json_assignment_parts(core)?;
725    let key_lower = key
726        .trim_matches(|ch| matches!(ch, '"' | '\''))
727        .to_ascii_lowercase();
728    let (bare_value, remainder) = split_json_value(value, sep);
729    let replacement = if is_secret_key(&key_lower) {
730        "<redacted>"
731    } else if is_user_path(&bare_value.to_ascii_lowercase()) {
732        "<path>"
733    } else {
734        return None;
735    };
736    Some(format!("{key}{sep}{replacement}{remainder}"))
737}
738
739fn split_json_value<'a>(value: &'a str, sep: &str) -> (&'a str, &'a str) {
740    if let Some(quote) = sep.chars().last().filter(|ch| matches!(ch, '"' | '\''))
741        && let Some(idx) = value.find(quote)
742    {
743        return (&value[..idx], &value[idx..]);
744    }
745    (value, "")
746}
747
748fn json_secret_key_without_value(tok: &str) -> bool {
749    let (_, core, suffix) = split_wrapping_punct(tok);
750    if json_assignment_parts(core).is_some() {
751        return false;
752    }
753    let had_colon = suffix.contains(':') || core.ends_with(':');
754    if !had_colon {
755        return false;
756    }
757    let key = core
758        .trim_end_matches(':')
759        .trim_matches(|ch| matches!(ch, '"' | '\''));
760    if key.is_empty() || key.chars().any(char::is_whitespace) {
761        return false;
762    }
763    is_secret_key(&key.to_ascii_lowercase())
764}
765
766fn redact_following_value_token(tok: &str) -> String {
767    let (prefix, core, suffix) = split_wrapping_punct(tok);
768    if is_user_path(&core.to_ascii_lowercase()) {
769        return format!("{prefix}<path>{suffix}");
770    }
771    format!("{prefix}<redacted>{suffix}")
772}
773
774fn split_wrapping_punct(tok: &str) -> (&str, &str, &str) {
775    let prefix_len: usize = tok
776        .chars()
777        .take_while(|c| matches!(c, '(' | '[' | '{' | '"' | '\''))
778        .map(char::len_utf8)
779        .sum();
780    let suffix_len: usize = tok
781        .chars()
782        .rev()
783        .take_while(|c| matches!(c, ')' | ']' | '}' | '"' | '\'' | ',' | ';' | '.' | ':'))
784        .map(char::len_utf8)
785        .sum();
786    if prefix_len + suffix_len >= tok.len() {
787        return ("", tok, "");
788    }
789    let core_end = tok.len() - suffix_len;
790    (
791        &tok[..prefix_len],
792        &tok[prefix_len..core_end],
793        &tok[core_end..],
794    )
795}
796
797fn is_user_path(lower: &str) -> bool {
798    lower.starts_with('/')
799        || lower.starts_with(r"\\")
800        || lower.contains("/.ssh/")
801        || lower.contains("/.aws/")
802        || (lower.len() >= 3
803            && lower.as_bytes()[1] == b':'
804            && (lower.as_bytes()[2] == b'\\' || lower.as_bytes()[2] == b'/'))
805}
806
807fn is_secret_key(key: &str) -> bool {
808    let key = key.replace('-', "_");
809    matches!(
810        key.as_str(),
811        "token"
812            | "password"
813            | "secret"
814            | "authorization"
815            | "api_key"
816            | "credential"
817            | "access_key"
818            | "secret_key"
819    ) || key.ends_with("token")
820        || key.ends_with("password")
821        || key.ends_with("secret")
822        || key.ends_with("api_key")
823        || key.ends_with("access_key")
824        || key.ends_with("secret_key")
825        || key.ends_with("credential")
826        || key.ends_with("authorization")
827        || key.contains("_secret_")
828        || key.contains("_access_key")
829        || key.contains("_api_key")
830        || key.contains("_secret_key")
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    struct Case {
838        name: &'static str,
839        facts: CapabilityFacts,
840        backend: Backend,
841        reason: Option<FallbackReason>,
842    }
843
844    fn facts(
845        cuda_built: bool,
846        runtime: bool,
847        device: bool,
848        cc: Option<ComputeCapability>,
849        kernels: KernelAvailability,
850    ) -> CapabilityFacts {
851        CapabilityFacts {
852            cuda_built,
853            runtime_available: runtime,
854            device_available: device,
855            compute_capability: cc,
856            kernels,
857        }
858    }
859
860    #[test]
861    fn decision_table_covers_build_runtime_device_kernel_outcomes() {
862        let sm89 = ComputeCapability { major: 8, minor: 9 };
863        let sm120 = ComputeCapability {
864            major: 12,
865            minor: 0,
866        };
867        let sm121 = ComputeCapability {
868            major: 12,
869            minor: 1,
870        };
871
872        let cases = [
873            Case {
874                name: "cpu-only build",
875                facts: CapabilityFacts::not_built(),
876                backend: Backend::Cpu,
877                reason: Some(FallbackReason::CudaFeatureNotBuilt),
878            },
879            Case {
880                name: "driver/runtime failure",
881                facts: facts(
882                    true,
883                    false,
884                    false,
885                    None,
886                    KernelAvailability::compiled_unverified(),
887                ),
888                backend: Backend::Cpu,
889                reason: Some(FallbackReason::DriverRuntimeFailure),
890            },
891            Case {
892                name: "device unavailable",
893                facts: facts(
894                    true,
895                    true,
896                    false,
897                    None,
898                    KernelAvailability::compiled_unverified(),
899                ),
900                backend: Backend::Cpu,
901                reason: Some(FallbackReason::DeviceUnavailable),
902            },
903            Case {
904                name: "CC query failed",
905                facts: facts(
906                    true,
907                    true,
908                    true,
909                    None,
910                    KernelAvailability::compiled_unverified(),
911                ),
912                backend: Backend::Cpu,
913                reason: Some(FallbackReason::DriverRuntimeFailure),
914            },
915            Case {
916                name: "unsupported hardware",
917                facts: facts(
918                    true,
919                    true,
920                    true,
921                    Some(sm89),
922                    KernelAvailability::compiled_unverified(),
923                ),
924                backend: Backend::Cpu,
925                reason: Some(FallbackReason::UnsupportedHardware),
926            },
927            Case {
928                name: "kernel specialization missing",
929                facts: facts(
930                    true,
931                    true,
932                    true,
933                    Some(sm120),
934                    KernelAvailability::all_unavailable(),
935                ),
936                backend: Backend::Cpu,
937                reason: Some(FallbackReason::KernelSpecializationUnavailable),
938            },
939            Case {
940                name: "compiled but JIT not attempted (probe)",
941                facts: facts(
942                    true,
943                    true,
944                    true,
945                    Some(sm120),
946                    KernelAvailability::compiled_unverified(),
947                ),
948                backend: Backend::Cpu,
949                reason: Some(FallbackReason::KernelSpecializationUnavailable),
950            },
951            Case {
952                name: "all kernels available",
953                facts: facts(
954                    true,
955                    true,
956                    true,
957                    Some(sm121),
958                    KernelAvailability::all_available(),
959                ),
960                backend: Backend::Cuda,
961                reason: None,
962            },
963            Case {
964                name: "PTX not compiled despite cuda_built flag",
965                facts: facts(
966                    true,
967                    true,
968                    true,
969                    Some(sm120),
970                    KernelAvailability::not_compiled(),
971                ),
972                backend: Backend::Cpu,
973                reason: Some(FallbackReason::CudaFeatureNotBuilt),
974            },
975        ];
976
977        for case in cases {
978            let report = evaluate_capabilities(&case.facts);
979            assert_eq!(
980                report.selected_backend, case.backend,
981                "{}: backend",
982                case.name
983            );
984            assert_eq!(
985                report.fallback.as_ref().map(|f| f.reason),
986                case.reason,
987                "{}: reason",
988                case.name
989            );
990            if let Some(fb) = &report.fallback {
991                assert_eq!(fb.selected_backend, Backend::Cpu, "{}", case.name);
992                assert_eq!(fb.reason.code(), fb.reason.to_string());
993                assert!(!fb.detail.contains("/home/"), "{}", case.name);
994            } else {
995                assert!(report.gpu_usable(), "{}", case.name);
996            }
997        }
998    }
999
1000    #[test]
1001    fn require_gpu_policy_fails_closed_on_cpu_selection() {
1002        let report = evaluate_capabilities(&CapabilityFacts::not_built());
1003        let err = report
1004            .select_backend(ExecutionPolicy::RequireGpu)
1005            .unwrap_err();
1006        assert_eq!(err.reason, FallbackReason::CudaFeatureNotBuilt);
1007        assert_eq!(err.selected_backend, Backend::Cpu);
1008        assert_eq!(
1009            report.select_backend(ExecutionPolicy::PreferGpu).unwrap(),
1010            Backend::Cpu
1011        );
1012    }
1013
1014    #[test]
1015    fn require_gpu_policy_accepts_cuda_selection() {
1016        let report = evaluate_capabilities(&facts(
1017            true,
1018            true,
1019            true,
1020            Some(ComputeCapability::REQUIRED),
1021            KernelAvailability::all_available(),
1022        ));
1023        assert_eq!(
1024            report.select_backend(ExecutionPolicy::RequireGpu).unwrap(),
1025            Backend::Cuda
1026        );
1027    }
1028
1029    #[test]
1030    fn fallback_reason_codes_are_stable() {
1031        let expected = [
1032            (
1033                FallbackReason::CudaFeatureNotBuilt,
1034                "cuda_feature_not_built",
1035            ),
1036            (
1037                FallbackReason::DriverRuntimeFailure,
1038                "driver_runtime_failure",
1039            ),
1040            (FallbackReason::DeviceUnavailable, "device_unavailable"),
1041            (FallbackReason::UnsupportedHardware, "unsupported_hardware"),
1042            (
1043                FallbackReason::KernelSpecializationUnavailable,
1044                "kernel_specialization_unavailable",
1045            ),
1046            (FallbackReason::InvalidInput, "invalid_input"),
1047        ];
1048        for (reason, code) in expected {
1049            assert_eq!(reason.code(), code);
1050            assert_eq!(reason.to_string(), code);
1051        }
1052    }
1053
1054    #[test]
1055    fn stream_setup_driver_failure_keeps_observed_runtime_and_device() {
1056        let mut facts = facts(
1057            true,
1058            true,
1059            true,
1060            Some(ComputeCapability::REQUIRED),
1061            KernelAvailability::all_available(),
1062        );
1063        apply_failure_to_facts(&mut facts, FallbackReason::DriverRuntimeFailure);
1064
1065        assert!(facts.runtime_available);
1066        assert!(facts.device_available);
1067        assert!(facts.kernels.all_runtime_available());
1068
1069        let mut report = evaluate_capabilities(&facts);
1070        report.selected_backend = Backend::Cpu;
1071        report.fallback = Some(FallbackRecord::cpu(
1072            FallbackReason::DriverRuntimeFailure,
1073            "stream creation failed",
1074        ));
1075
1076        assert!(report.runtime_available);
1077        assert!(report.device_available);
1078        assert_eq!(report.selected_backend, Backend::Cpu);
1079        assert!(!report.gpu_usable());
1080        assert_eq!(
1081            report.fallback.as_ref().map(|fb| fb.reason),
1082            Some(FallbackReason::DriverRuntimeFailure)
1083        );
1084    }
1085
1086    #[test]
1087    fn driver_runtime_failure_before_jit_still_clears_runtime() {
1088        let mut facts = facts(
1089            true,
1090            true,
1091            true,
1092            Some(ComputeCapability::REQUIRED),
1093            KernelAvailability::compiled_unverified(),
1094        );
1095        apply_failure_to_facts(&mut facts, FallbackReason::DriverRuntimeFailure);
1096        assert!(!facts.runtime_available);
1097    }
1098
1099    #[test]
1100    fn compute_capability_floor_tracks_compiled_target() {
1101        let required = ComputeCapability::REQUIRED;
1102        let arch_digits: String = env!("MYELIN_COMPILED_CUDA_ARCH")
1103            .chars()
1104            .skip_while(|ch| !ch.is_ascii_digit())
1105            .take_while(char::is_ascii_digit)
1106            .collect();
1107        let encoded = arch_digits.parse::<u32>().expect("validated build target");
1108        assert_eq!(required.major, encoded / 10);
1109        assert_eq!(required.minor, encoded % 10);
1110        assert!(required.meets_minimum());
1111        assert!(
1112            ComputeCapability {
1113                major: required.major + 1,
1114                minor: 0,
1115            }
1116            .meets_minimum()
1117        );
1118        if required.major > 0 {
1119            assert!(
1120                !ComputeCapability {
1121                    major: required.major - 1,
1122                    minor: 9,
1123                }
1124                .meets_minimum()
1125            );
1126        }
1127    }
1128
1129    #[test]
1130    fn sanitize_diagnostic_redacts_user_paths_and_secrets() {
1131        let raw = "failed /home/alice/.ssh/id_rsa token=supersecret C:\\Users\\bob\\key.pem ok";
1132        let clean = sanitize_diagnostic(raw);
1133        assert!(!clean.contains("/home/alice"));
1134        assert!(!clean.contains("supersecret"));
1135        assert!(!clean.contains("bob"));
1136        assert!(clean.contains("<path>"));
1137        assert!(clean.contains("token=<redacted>"));
1138        assert!(clean.contains("ok"));
1139    }
1140
1141    #[test]
1142    fn sanitize_diagnostic_keeps_stable_reason_tokens() {
1143        let s = sanitize_diagnostic("cuda_feature_not_built sm_120 driver_runtime_failure");
1144        assert_eq!(s, "cuda_feature_not_built sm_120 driver_runtime_failure");
1145    }
1146
1147    #[test]
1148    fn sanitize_diagnostic_redacts_paths_after_assignment_keys() {
1149        let clean = sanitize_diagnostic(
1150            "path=/home/alice/private.ptx file=/Users/bob/key cache=C:\\Users\\eve\\cache.bin",
1151        );
1152        assert_eq!(clean, "path=<path> file=<path> cache=<path>");
1153    }
1154
1155    #[test]
1156    fn sanitize_diagnostic_redacts_quoted_assignment_paths() {
1157        let cases = [
1158            (
1159                r#"module="/home/alice/private.ptx" tail"#,
1160                "module=<path> tail",
1161            ),
1162            (
1163                r#"module="/home/alice/My Models/private.ptx" tail"#,
1164                "module=<path> tail",
1165            ),
1166            (
1167                r#"file='C:\Users\bob\private.ptx' tail"#,
1168                "file=<path> tail",
1169            ),
1170        ];
1171
1172        for (input, expected) in cases {
1173            assert_eq!(sanitize_diagnostic(input), expected, "input={input}");
1174        }
1175    }
1176
1177    #[test]
1178    fn sanitize_diagnostic_does_not_treat_contractions_as_quotes() {
1179        assert_eq!(
1180            sanitize_diagnostic("driver can't load /home/alice/private.ptx"),
1181            "driver can't load <path>"
1182        );
1183    }
1184
1185    #[test]
1186    fn sanitize_diagnostic_redacts_namespaced_credentials() {
1187        let clean = sanitize_diagnostic(
1188            "AWS_SECRET_ACCESS_KEY=abc MY_API_KEY=def service_authorization=ghi AWS_ACCESS_KEY_ID=AKIA x-api-key=xyz",
1189        );
1190        assert_eq!(
1191            clean,
1192            "AWS_SECRET_ACCESS_KEY=<redacted> MY_API_KEY=<redacted> service_authorization=<redacted> AWS_ACCESS_KEY_ID=<redacted> x-api-key=<redacted>"
1193        );
1194    }
1195
1196    #[test]
1197    fn sanitize_diagnostic_redacts_separated_credentials() {
1198        let cases = [
1199            ("password = hunter2 ok", "password = <redacted> ok"),
1200            ("token: hunter2 ok", "token: <redacted> ok"),
1201            ("--api-key hunter2 ok", "--api-key <redacted> ok"),
1202            (
1203                "AWS_SECRET_ACCESS_KEY = abc ok",
1204                "AWS_SECRET_ACCESS_KEY = <redacted> ok",
1205            ),
1206            ("token:hunter2 ok", "token:<redacted> ok"),
1207            ("api-key:supersecret ok", "api-key:<redacted> ok"),
1208            (
1209                "state=bad,password=hunter2 ok",
1210                "state=bad,password=<redacted> ok",
1211            ),
1212            (
1213                "state=bad;file=/workspace/alice/private.ptx ok",
1214                "state=bad;file=<path> ok",
1215            ),
1216        ];
1217
1218        for (input, expected) in cases {
1219            assert_eq!(sanitize_diagnostic(input), expected, "input={input}");
1220            assert!(
1221                !sanitize_diagnostic(input).contains("hunter2"),
1222                "input={input}"
1223            );
1224        }
1225    }
1226
1227    #[test]
1228    fn sanitize_diagnostic_redacts_arbitrary_absolute_paths() {
1229        let clean = sanitize_diagnostic(
1230            "failed /workspace/alice/private.ptx file=/mnt/data/model.ptx system=/usr/local/cuda",
1231        );
1232        assert_eq!(clean, "failed <path> file=<path> system=<path>");
1233    }
1234
1235    #[test]
1236    fn sanitize_diagnostic_redacts_windows_absolute_paths_without_users() {
1237        let cases = [
1238            (
1239                r"failed C:\workspace\alice\private.ptx ok",
1240                "failed <path> ok",
1241            ),
1242            (r"file=C:\builds\alice\secret.ptx", "file=<path>"),
1243            (
1244                r"cache=D:/builds/alice/cache.bin leftover",
1245                "cache=<path> leftover",
1246            ),
1247            (
1248                r"failed \\server\share\alice\private.ptx ok",
1249                "failed <path> ok",
1250            ),
1251            (r"file=\\server\share\alice\private.ptx", "file=<path>"),
1252            (r"device=\\?\C:\private.ptx", "device=<path>"),
1253            (
1254                r#"module="C:\workspace\alice\private.ptx" tail"#,
1255                "module=<path> tail",
1256            ),
1257        ];
1258        for (input, expected) in cases {
1259            assert_eq!(sanitize_diagnostic(input), expected, "input={input}");
1260            assert!(
1261                !sanitize_diagnostic(input)
1262                    .to_ascii_lowercase()
1263                    .contains("alice"),
1264                "input={input}"
1265            );
1266        }
1267    }
1268
1269    #[test]
1270    fn sanitize_diagnostic_redacts_http_authorization_headers() {
1271        let cases = [
1272            (
1273                "probe Authorization: Bearer supersecret ok",
1274                "probe Authorization: Bearer <redacted> ok",
1275            ),
1276            (
1277                "probe Authorization: Basic dXNlcjpwYXNz ok",
1278                "probe Authorization: Basic <redacted> ok",
1279            ),
1280            (
1281                r#"probe "Authorization: Bearer supersecret" ok"#,
1282                r#"probe "Authorization: Bearer <redacted>" ok"#,
1283            ),
1284            (
1285                "probe authorization: bearer SUPERSECRET ok",
1286                "probe authorization: bearer <redacted> ok",
1287            ),
1288            (
1289                "probe Authorization:Bearer supersecret ok",
1290                "probe Authorization:Bearer <redacted> ok",
1291            ),
1292            (
1293                "probe Authorization:Basic dXNlcjpwYXNz ok",
1294                "probe Authorization:Basic <redacted> ok",
1295            ),
1296        ];
1297        for (input, expected) in cases {
1298            assert_eq!(sanitize_diagnostic(input), expected, "input={input}");
1299            assert!(
1300                !sanitize_diagnostic(input)
1301                    .to_ascii_lowercase()
1302                    .contains("supersecret"),
1303                "input={input}"
1304            );
1305        }
1306    }
1307
1308    #[test]
1309    fn sanitize_diagnostic_redacts_json_credential_fields() {
1310        let cases = [
1311            (
1312                r#"{"authToken":"supersecret","ok":true}"#,
1313                r#"{"authToken":"<redacted>","ok":true}"#,
1314            ),
1315            (
1316                r#"{"password":"hunter2"} tail"#,
1317                r#"{"password":"<redacted>"} tail"#,
1318            ),
1319            (
1320                r#"{"authToken": "supersecret"} tail"#,
1321                r#"{"authToken": "<redacted>"} tail"#,
1322            ),
1323            (
1324                r#"{"file":"/home/alice/private.ptx"}"#,
1325                r#"{"file":"<path>"}"#,
1326            ),
1327        ];
1328        for (input, expected) in cases {
1329            assert_eq!(sanitize_diagnostic(input), expected, "input={input}");
1330        }
1331    }
1332}