Skip to main content

pi/
hostcall_io_uring_lane.rs

1//! Deterministic io_uring lane policy for hostcall dispatch.
2//!
3//! This module intentionally models policy decisions only. It does not perform
4//! syscalls or ring operations directly; integration code can consume the
5//! decisions and wire them into runtime-specific execution paths.
6
7use serde::{Deserialize, Serialize};
8
9/// Dispatch lane selected for a hostcall attempt.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum HostcallDispatchLane {
13    Fast,
14    IoUring,
15    Compat,
16}
17
18impl HostcallDispatchLane {
19    #[must_use]
20    pub const fn as_str(self) -> &'static str {
21        match self {
22            Self::Fast => "fast",
23            Self::IoUring => "io_uring",
24            Self::Compat => "compat",
25        }
26    }
27}
28
29/// Optional signal indicating whether a hostcall is likely IO-dominant.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum HostcallIoHint {
33    Unknown,
34    IoHeavy,
35    CpuBound,
36}
37
38impl HostcallIoHint {
39    #[must_use]
40    pub const fn is_io_heavy(self) -> bool {
41        matches!(self, Self::IoHeavy)
42    }
43}
44
45/// Normalized capability classes used by lane policy.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum HostcallCapabilityClass {
49    Filesystem,
50    Network,
51    Execution,
52    Session,
53    Events,
54    Environment,
55    Tool,
56    Ui,
57    Telemetry,
58    Unknown,
59}
60
61impl HostcallCapabilityClass {
62    #[must_use]
63    pub fn from_capability(value: &str) -> Self {
64        match value.trim().to_ascii_lowercase().as_str() {
65            "read" | "write" | "filesystem" | "fs" => Self::Filesystem,
66            "http" | "network" => Self::Network,
67            "exec" | "execution" => Self::Execution,
68            "session" => Self::Session,
69            "events" => Self::Events,
70            "env" | "environment" => Self::Environment,
71            "tool" => Self::Tool,
72            "ui" => Self::Ui,
73            "log" | "telemetry" => Self::Telemetry,
74            _ => Self::Unknown,
75        }
76    }
77}
78
79/// Explicit fallback reason when io_uring is not selected.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum IoUringFallbackReason {
83    CompatKillSwitch,
84    IoUringDisabled,
85    IoUringUnavailable,
86    MissingIoHint,
87    UnsupportedCapability,
88    QueueDepthBudgetExceeded,
89}
90
91impl IoUringFallbackReason {
92    #[must_use]
93    pub const fn as_code(self) -> &'static str {
94        match self {
95            Self::CompatKillSwitch => "forced_compat_kill_switch",
96            Self::IoUringDisabled => "io_uring_disabled",
97            Self::IoUringUnavailable => "io_uring_unavailable",
98            Self::MissingIoHint => "io_hint_missing",
99            Self::UnsupportedCapability => "io_uring_capability_not_supported",
100            Self::QueueDepthBudgetExceeded => "io_uring_queue_depth_budget_exceeded",
101        }
102    }
103}
104
105/// Runtime-tunable policy knobs for io_uring lane selection.
106#[allow(clippy::struct_excessive_bools)]
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108pub struct IoUringLanePolicyConfig {
109    pub enabled: bool,
110    pub ring_available: bool,
111    pub max_queue_depth: usize,
112    pub allow_filesystem: bool,
113    pub allow_network: bool,
114}
115
116impl IoUringLanePolicyConfig {
117    /// Conservative profile suitable for production defaults.
118    #[must_use]
119    pub const fn conservative() -> Self {
120        Self {
121            enabled: false,
122            ring_available: false,
123            max_queue_depth: 256,
124            allow_filesystem: true,
125            allow_network: true,
126        }
127    }
128
129    #[must_use]
130    pub const fn allow_for_capability(self, capability: HostcallCapabilityClass) -> bool {
131        match capability {
132            HostcallCapabilityClass::Filesystem => self.allow_filesystem,
133            HostcallCapabilityClass::Network => self.allow_network,
134            _ => false,
135        }
136    }
137}
138
139impl Default for IoUringLanePolicyConfig {
140    fn default() -> Self {
141        Self::conservative()
142    }
143}
144
145/// Inputs consumed by [`decide_io_uring_lane`].
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct IoUringLaneDecisionInput {
148    pub capability: HostcallCapabilityClass,
149    pub io_hint: HostcallIoHint,
150    pub queue_depth: usize,
151    pub force_compat_lane: bool,
152}
153
154/// Deterministic lane decision output.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156pub struct IoUringLaneDecision {
157    pub lane: HostcallDispatchLane,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub fallback_reason: Option<IoUringFallbackReason>,
160}
161
162impl IoUringLaneDecision {
163    #[must_use]
164    pub const fn io_uring() -> Self {
165        Self {
166            lane: HostcallDispatchLane::IoUring,
167            fallback_reason: None,
168        }
169    }
170
171    #[must_use]
172    pub const fn compat(reason: IoUringFallbackReason) -> Self {
173        Self {
174            lane: HostcallDispatchLane::Compat,
175            fallback_reason: Some(reason),
176        }
177    }
178
179    #[must_use]
180    pub const fn fast(reason: IoUringFallbackReason) -> Self {
181        Self {
182            lane: HostcallDispatchLane::Fast,
183            fallback_reason: Some(reason),
184        }
185    }
186
187    #[must_use]
188    pub fn fallback_code(self) -> Option<&'static str> {
189        self.fallback_reason.map(IoUringFallbackReason::as_code)
190    }
191}
192
193/// Deterministic telemetry envelope for lane decision auditing.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[allow(clippy::struct_excessive_bools)]
196pub struct IoUringLaneTelemetry {
197    pub lane: HostcallDispatchLane,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub fallback_reason: Option<IoUringFallbackReason>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub fallback_code: Option<String>,
202    pub capability: HostcallCapabilityClass,
203    pub io_hint: HostcallIoHint,
204    pub queue_depth: usize,
205    pub queue_depth_budget: usize,
206    pub queue_depth_budget_remaining: usize,
207    pub force_compat_lane: bool,
208    pub policy_enabled: bool,
209    pub ring_available: bool,
210    pub capability_allowed: bool,
211    pub queue_depth_within_budget: bool,
212}
213
214/// Build deterministic telemetry for a lane decision.
215#[must_use]
216pub fn build_io_uring_lane_telemetry(
217    config: IoUringLanePolicyConfig,
218    input: IoUringLaneDecisionInput,
219    decision: IoUringLaneDecision,
220) -> IoUringLaneTelemetry {
221    let capability_allowed = config.allow_for_capability(input.capability);
222    let queue_depth_within_budget = input.queue_depth < config.max_queue_depth;
223    let queue_depth_budget_remaining = config.max_queue_depth.saturating_sub(input.queue_depth);
224    IoUringLaneTelemetry {
225        lane: decision.lane,
226        fallback_reason: decision.fallback_reason,
227        fallback_code: decision.fallback_code().map(ToString::to_string),
228        capability: input.capability,
229        io_hint: input.io_hint,
230        queue_depth: input.queue_depth,
231        queue_depth_budget: config.max_queue_depth,
232        queue_depth_budget_remaining,
233        force_compat_lane: input.force_compat_lane,
234        policy_enabled: config.enabled,
235        ring_available: config.ring_available,
236        capability_allowed,
237        queue_depth_within_budget,
238    }
239}
240
241/// Decide lane and produce deterministic telemetry in one call.
242#[must_use]
243pub fn decide_io_uring_lane_with_telemetry(
244    config: IoUringLanePolicyConfig,
245    input: IoUringLaneDecisionInput,
246) -> (IoUringLaneDecision, IoUringLaneTelemetry) {
247    let decision = decide_io_uring_lane(config, input);
248    let telemetry = build_io_uring_lane_telemetry(config, input, decision);
249    (decision, telemetry)
250}
251
252/// Decide whether the hostcall should run via the io_uring lane.
253///
254/// Decision ordering is intentionally strict and deterministic:
255/// 1) explicit compatibility kill-switch
256/// 2) policy enabled flag
257/// 3) ring availability
258/// 4) IO-heavy hint presence
259/// 5) capability allowlist
260/// 6) queue depth budget
261#[must_use]
262pub const fn decide_io_uring_lane(
263    config: IoUringLanePolicyConfig,
264    input: IoUringLaneDecisionInput,
265) -> IoUringLaneDecision {
266    if input.force_compat_lane {
267        return IoUringLaneDecision::compat(IoUringFallbackReason::CompatKillSwitch);
268    }
269    if !config.enabled {
270        return IoUringLaneDecision::fast(IoUringFallbackReason::IoUringDisabled);
271    }
272    if !config.ring_available {
273        return IoUringLaneDecision::fast(IoUringFallbackReason::IoUringUnavailable);
274    }
275    if !input.io_hint.is_io_heavy() {
276        return IoUringLaneDecision::fast(IoUringFallbackReason::MissingIoHint);
277    }
278    if !config.allow_for_capability(input.capability) {
279        return IoUringLaneDecision::fast(IoUringFallbackReason::UnsupportedCapability);
280    }
281    if input.queue_depth >= config.max_queue_depth {
282        return IoUringLaneDecision::fast(IoUringFallbackReason::QueueDepthBudgetExceeded);
283    }
284    IoUringLaneDecision::io_uring()
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn enabled_config() -> IoUringLanePolicyConfig {
292        IoUringLanePolicyConfig {
293            enabled: true,
294            ring_available: true,
295            max_queue_depth: 8,
296            allow_filesystem: true,
297            allow_network: true,
298        }
299    }
300
301    #[test]
302    fn capability_aliases_map_to_expected_classes() {
303        assert_eq!(
304            HostcallCapabilityClass::from_capability("read"),
305            HostcallCapabilityClass::Filesystem
306        );
307        assert_eq!(
308            HostcallCapabilityClass::from_capability("http"),
309            HostcallCapabilityClass::Network
310        );
311        assert_eq!(
312            HostcallCapabilityClass::from_capability("session"),
313            HostcallCapabilityClass::Session
314        );
315        assert_eq!(
316            HostcallCapabilityClass::from_capability("unknown-cap"),
317            HostcallCapabilityClass::Unknown
318        );
319    }
320
321    #[test]
322    fn selects_io_uring_for_io_heavy_allowed_capability_with_budget_headroom() {
323        let decision = decide_io_uring_lane(
324            enabled_config(),
325            IoUringLaneDecisionInput {
326                capability: HostcallCapabilityClass::Network,
327                io_hint: HostcallIoHint::IoHeavy,
328                queue_depth: 3,
329                force_compat_lane: false,
330            },
331        );
332        assert_eq!(decision.lane, HostcallDispatchLane::IoUring);
333        assert!(decision.fallback_reason.is_none());
334    }
335
336    #[test]
337    fn kill_switch_forces_compat_lane() {
338        let decision = decide_io_uring_lane(
339            enabled_config(),
340            IoUringLaneDecisionInput {
341                capability: HostcallCapabilityClass::Filesystem,
342                io_hint: HostcallIoHint::IoHeavy,
343                queue_depth: 0,
344                force_compat_lane: true,
345            },
346        );
347        assert_eq!(decision.lane, HostcallDispatchLane::Compat);
348        assert_eq!(
349            decision.fallback_reason,
350            Some(IoUringFallbackReason::CompatKillSwitch)
351        );
352        assert_eq!(decision.fallback_code(), Some("forced_compat_kill_switch"));
353    }
354
355    #[test]
356    fn disabled_policy_falls_back_to_fast_lane() {
357        let mut config = enabled_config();
358        config.enabled = false;
359        let decision = decide_io_uring_lane(
360            config,
361            IoUringLaneDecisionInput {
362                capability: HostcallCapabilityClass::Network,
363                io_hint: HostcallIoHint::IoHeavy,
364                queue_depth: 0,
365                force_compat_lane: false,
366            },
367        );
368        assert_eq!(decision.lane, HostcallDispatchLane::Fast);
369        assert_eq!(
370            decision.fallback_reason,
371            Some(IoUringFallbackReason::IoUringDisabled)
372        );
373    }
374
375    #[test]
376    fn unavailable_ring_falls_back_to_fast_lane() {
377        let mut config = enabled_config();
378        config.ring_available = false;
379        let decision = decide_io_uring_lane(
380            config,
381            IoUringLaneDecisionInput {
382                capability: HostcallCapabilityClass::Network,
383                io_hint: HostcallIoHint::IoHeavy,
384                queue_depth: 0,
385                force_compat_lane: false,
386            },
387        );
388        assert_eq!(
389            decision.fallback_reason,
390            Some(IoUringFallbackReason::IoUringUnavailable)
391        );
392    }
393
394    #[test]
395    fn non_io_hint_falls_back_to_fast_lane() {
396        let decision = decide_io_uring_lane(
397            enabled_config(),
398            IoUringLaneDecisionInput {
399                capability: HostcallCapabilityClass::Network,
400                io_hint: HostcallIoHint::CpuBound,
401                queue_depth: 0,
402                force_compat_lane: false,
403            },
404        );
405        assert_eq!(decision.lane, HostcallDispatchLane::Fast);
406        assert_eq!(
407            decision.fallback_reason,
408            Some(IoUringFallbackReason::MissingIoHint)
409        );
410    }
411
412    #[test]
413    fn unsupported_capability_falls_back_to_fast_lane() {
414        let decision = decide_io_uring_lane(
415            enabled_config(),
416            IoUringLaneDecisionInput {
417                capability: HostcallCapabilityClass::Session,
418                io_hint: HostcallIoHint::IoHeavy,
419                queue_depth: 0,
420                force_compat_lane: false,
421            },
422        );
423        assert_eq!(decision.lane, HostcallDispatchLane::Fast);
424        assert_eq!(
425            decision.fallback_reason,
426            Some(IoUringFallbackReason::UnsupportedCapability)
427        );
428    }
429
430    #[test]
431    fn queue_depth_budget_exceeded_falls_back_to_fast_lane() {
432        let decision = decide_io_uring_lane(
433            enabled_config(),
434            IoUringLaneDecisionInput {
435                capability: HostcallCapabilityClass::Filesystem,
436                io_hint: HostcallIoHint::IoHeavy,
437                queue_depth: 8,
438                force_compat_lane: false,
439            },
440        );
441        assert_eq!(decision.lane, HostcallDispatchLane::Fast);
442        assert_eq!(
443            decision.fallback_reason,
444            Some(IoUringFallbackReason::QueueDepthBudgetExceeded)
445        );
446    }
447
448    #[test]
449    fn telemetry_builder_omits_fallback_fields_for_io_uring_success() {
450        let config = enabled_config();
451        let input = IoUringLaneDecisionInput {
452            capability: HostcallCapabilityClass::Filesystem,
453            io_hint: HostcallIoHint::IoHeavy,
454            queue_depth: 2,
455            force_compat_lane: false,
456        };
457        let decision = decide_io_uring_lane(config, input);
458        let telemetry = build_io_uring_lane_telemetry(config, input, decision);
459        assert_eq!(telemetry.lane, HostcallDispatchLane::IoUring);
460        assert_eq!(telemetry.fallback_reason, None);
461        assert_eq!(telemetry.fallback_code, None);
462        assert!(telemetry.capability_allowed);
463        assert!(telemetry.queue_depth_within_budget);
464
465        let value = serde_json::to_value(&telemetry).expect("serialize telemetry");
466        let obj = value.as_object().expect("telemetry object");
467        assert!(!obj.contains_key("fallback_reason"));
468        assert!(!obj.contains_key("fallback_code"));
469        assert_eq!(obj.get("queue_depth_budget"), Some(&serde_json::json!(8)));
470        assert_eq!(
471            obj.get("queue_depth_budget_remaining"),
472            Some(&serde_json::json!(6))
473        );
474    }
475
476    #[test]
477    fn telemetry_builder_includes_fallback_fields_for_fast_fallback() {
478        let config = IoUringLanePolicyConfig {
479            enabled: true,
480            ring_available: true,
481            max_queue_depth: 8,
482            allow_filesystem: false,
483            allow_network: true,
484        };
485        let input = IoUringLaneDecisionInput {
486            capability: HostcallCapabilityClass::Filesystem,
487            io_hint: HostcallIoHint::IoHeavy,
488            queue_depth: 0,
489            force_compat_lane: false,
490        };
491        let decision = decide_io_uring_lane(config, input);
492        let telemetry = build_io_uring_lane_telemetry(config, input, decision);
493        assert_eq!(telemetry.lane, HostcallDispatchLane::Fast);
494        assert_eq!(
495            telemetry.fallback_reason,
496            Some(IoUringFallbackReason::UnsupportedCapability)
497        );
498        assert_eq!(
499            telemetry.fallback_code.as_deref(),
500            Some("io_uring_capability_not_supported")
501        );
502        assert!(!telemetry.capability_allowed);
503
504        let value = serde_json::to_value(&telemetry).expect("serialize telemetry");
505        let obj = value.as_object().expect("telemetry object");
506        assert_eq!(
507            obj.get("fallback_code"),
508            Some(&serde_json::json!("io_uring_capability_not_supported"))
509        );
510    }
511
512    #[test]
513    #[allow(clippy::too_many_lines)]
514    fn fallback_reason_matrix_reports_expected_lane_and_code() {
515        struct Case {
516            name: &'static str,
517            config: IoUringLanePolicyConfig,
518            input: IoUringLaneDecisionInput,
519            expected_lane: HostcallDispatchLane,
520            expected_reason: IoUringFallbackReason,
521        }
522
523        let mut disabled = enabled_config();
524        disabled.enabled = false;
525
526        let mut unavailable = enabled_config();
527        unavailable.ring_available = false;
528
529        let mut unsupported_capability = enabled_config();
530        unsupported_capability.allow_network = false;
531
532        let cases = [
533            Case {
534                name: "compat kill-switch",
535                config: enabled_config(),
536                input: IoUringLaneDecisionInput {
537                    capability: HostcallCapabilityClass::Filesystem,
538                    io_hint: HostcallIoHint::IoHeavy,
539                    queue_depth: 0,
540                    force_compat_lane: true,
541                },
542                expected_lane: HostcallDispatchLane::Compat,
543                expected_reason: IoUringFallbackReason::CompatKillSwitch,
544            },
545            Case {
546                name: "disabled",
547                config: disabled,
548                input: IoUringLaneDecisionInput {
549                    capability: HostcallCapabilityClass::Network,
550                    io_hint: HostcallIoHint::IoHeavy,
551                    queue_depth: 0,
552                    force_compat_lane: false,
553                },
554                expected_lane: HostcallDispatchLane::Fast,
555                expected_reason: IoUringFallbackReason::IoUringDisabled,
556            },
557            Case {
558                name: "unavailable ring",
559                config: unavailable,
560                input: IoUringLaneDecisionInput {
561                    capability: HostcallCapabilityClass::Network,
562                    io_hint: HostcallIoHint::IoHeavy,
563                    queue_depth: 0,
564                    force_compat_lane: false,
565                },
566                expected_lane: HostcallDispatchLane::Fast,
567                expected_reason: IoUringFallbackReason::IoUringUnavailable,
568            },
569            Case {
570                name: "missing io hint",
571                config: enabled_config(),
572                input: IoUringLaneDecisionInput {
573                    capability: HostcallCapabilityClass::Network,
574                    io_hint: HostcallIoHint::CpuBound,
575                    queue_depth: 0,
576                    force_compat_lane: false,
577                },
578                expected_lane: HostcallDispatchLane::Fast,
579                expected_reason: IoUringFallbackReason::MissingIoHint,
580            },
581            Case {
582                name: "unsupported capability",
583                config: unsupported_capability,
584                input: IoUringLaneDecisionInput {
585                    capability: HostcallCapabilityClass::Network,
586                    io_hint: HostcallIoHint::IoHeavy,
587                    queue_depth: 0,
588                    force_compat_lane: false,
589                },
590                expected_lane: HostcallDispatchLane::Fast,
591                expected_reason: IoUringFallbackReason::UnsupportedCapability,
592            },
593            Case {
594                name: "queue budget exceeded",
595                config: enabled_config(),
596                input: IoUringLaneDecisionInput {
597                    capability: HostcallCapabilityClass::Filesystem,
598                    io_hint: HostcallIoHint::IoHeavy,
599                    queue_depth: 8,
600                    force_compat_lane: false,
601                },
602                expected_lane: HostcallDispatchLane::Fast,
603                expected_reason: IoUringFallbackReason::QueueDepthBudgetExceeded,
604            },
605        ];
606
607        for case in cases {
608            let decision = decide_io_uring_lane(case.config, case.input);
609            assert_eq!(decision.lane, case.expected_lane, "{}", case.name);
610            assert_eq!(
611                decision.fallback_reason,
612                Some(case.expected_reason),
613                "{}",
614                case.name
615            );
616            assert_eq!(
617                decision.fallback_code(),
618                Some(case.expected_reason.as_code()),
619                "{}",
620                case.name
621            );
622        }
623    }
624
625    #[test]
626    fn telemetry_budget_remaining_saturates_when_queue_depth_exceeds_budget() {
627        let config = IoUringLanePolicyConfig {
628            enabled: true,
629            ring_available: true,
630            max_queue_depth: 4,
631            allow_filesystem: true,
632            allow_network: true,
633        };
634        let input = IoUringLaneDecisionInput {
635            capability: HostcallCapabilityClass::Filesystem,
636            io_hint: HostcallIoHint::IoHeavy,
637            queue_depth: 11,
638            force_compat_lane: false,
639        };
640
641        let decision = decide_io_uring_lane(config, input);
642        let telemetry = build_io_uring_lane_telemetry(config, input, decision);
643
644        assert_eq!(decision.lane, HostcallDispatchLane::Fast);
645        assert_eq!(
646            decision.fallback_reason,
647            Some(IoUringFallbackReason::QueueDepthBudgetExceeded)
648        );
649        assert_eq!(
650            telemetry.fallback_code.as_deref(),
651            Some("io_uring_queue_depth_budget_exceeded")
652        );
653        assert!(!telemetry.queue_depth_within_budget);
654        assert_eq!(telemetry.queue_depth_budget_remaining, 0);
655    }
656
657    // ── Additional public API coverage ──
658
659    #[test]
660    fn dispatch_lane_as_str_all_variants() {
661        assert_eq!(HostcallDispatchLane::Fast.as_str(), "fast");
662        assert_eq!(HostcallDispatchLane::IoUring.as_str(), "io_uring");
663        assert_eq!(HostcallDispatchLane::Compat.as_str(), "compat");
664    }
665
666    #[test]
667    fn io_hint_is_io_heavy_only_for_io_heavy_variant() {
668        assert!(HostcallIoHint::IoHeavy.is_io_heavy());
669        assert!(!HostcallIoHint::Unknown.is_io_heavy());
670        assert!(!HostcallIoHint::CpuBound.is_io_heavy());
671    }
672
673    #[test]
674    fn conservative_config_defaults() {
675        let config = IoUringLanePolicyConfig::conservative();
676        assert!(!config.enabled);
677        assert!(!config.ring_available);
678        assert_eq!(config.max_queue_depth, 256);
679        assert!(config.allow_filesystem);
680        assert!(config.allow_network);
681        // Default impl delegates to conservative
682        assert_eq!(IoUringLanePolicyConfig::default(), config);
683    }
684
685    #[test]
686    fn allow_for_capability_only_filesystem_and_network() {
687        let config = IoUringLanePolicyConfig {
688            enabled: true,
689            ring_available: true,
690            max_queue_depth: 8,
691            allow_filesystem: true,
692            allow_network: false,
693        };
694        assert!(config.allow_for_capability(HostcallCapabilityClass::Filesystem));
695        assert!(!config.allow_for_capability(HostcallCapabilityClass::Network));
696        // All other classes always false
697        assert!(!config.allow_for_capability(HostcallCapabilityClass::Execution));
698        assert!(!config.allow_for_capability(HostcallCapabilityClass::Session));
699        assert!(!config.allow_for_capability(HostcallCapabilityClass::Events));
700        assert!(!config.allow_for_capability(HostcallCapabilityClass::Environment));
701        assert!(!config.allow_for_capability(HostcallCapabilityClass::Tool));
702        assert!(!config.allow_for_capability(HostcallCapabilityClass::Ui));
703        assert!(!config.allow_for_capability(HostcallCapabilityClass::Telemetry));
704        assert!(!config.allow_for_capability(HostcallCapabilityClass::Unknown));
705    }
706
707    #[test]
708    fn decision_constructors_produce_expected_lanes() {
709        let uring = IoUringLaneDecision::io_uring();
710        assert_eq!(uring.lane, HostcallDispatchLane::IoUring);
711        assert!(uring.fallback_reason.is_none());
712        assert!(uring.fallback_code().is_none());
713
714        let compat = IoUringLaneDecision::compat(IoUringFallbackReason::CompatKillSwitch);
715        assert_eq!(compat.lane, HostcallDispatchLane::Compat);
716        assert_eq!(
717            compat.fallback_reason,
718            Some(IoUringFallbackReason::CompatKillSwitch)
719        );
720        assert_eq!(compat.fallback_code(), Some("forced_compat_kill_switch"));
721
722        let fast = IoUringLaneDecision::fast(IoUringFallbackReason::IoUringDisabled);
723        assert_eq!(fast.lane, HostcallDispatchLane::Fast);
724        assert_eq!(
725            fast.fallback_reason,
726            Some(IoUringFallbackReason::IoUringDisabled)
727        );
728        assert_eq!(fast.fallback_code(), Some("io_uring_disabled"));
729    }
730
731    #[test]
732    fn capability_class_from_all_aliases() {
733        // Filesystem aliases
734        assert_eq!(
735            HostcallCapabilityClass::from_capability("write"),
736            HostcallCapabilityClass::Filesystem
737        );
738        assert_eq!(
739            HostcallCapabilityClass::from_capability("filesystem"),
740            HostcallCapabilityClass::Filesystem
741        );
742        assert_eq!(
743            HostcallCapabilityClass::from_capability("fs"),
744            HostcallCapabilityClass::Filesystem
745        );
746        // Network aliases
747        assert_eq!(
748            HostcallCapabilityClass::from_capability("network"),
749            HostcallCapabilityClass::Network
750        );
751        // Execution
752        assert_eq!(
753            HostcallCapabilityClass::from_capability("exec"),
754            HostcallCapabilityClass::Execution
755        );
756        assert_eq!(
757            HostcallCapabilityClass::from_capability("execution"),
758            HostcallCapabilityClass::Execution
759        );
760        // Environment
761        assert_eq!(
762            HostcallCapabilityClass::from_capability("env"),
763            HostcallCapabilityClass::Environment
764        );
765        assert_eq!(
766            HostcallCapabilityClass::from_capability("environment"),
767            HostcallCapabilityClass::Environment
768        );
769        // Events
770        assert_eq!(
771            HostcallCapabilityClass::from_capability("events"),
772            HostcallCapabilityClass::Events
773        );
774        // Tool
775        assert_eq!(
776            HostcallCapabilityClass::from_capability("tool"),
777            HostcallCapabilityClass::Tool
778        );
779        // UI
780        assert_eq!(
781            HostcallCapabilityClass::from_capability("ui"),
782            HostcallCapabilityClass::Ui
783        );
784        // Telemetry
785        assert_eq!(
786            HostcallCapabilityClass::from_capability("log"),
787            HostcallCapabilityClass::Telemetry
788        );
789        assert_eq!(
790            HostcallCapabilityClass::from_capability("telemetry"),
791            HostcallCapabilityClass::Telemetry
792        );
793        // Case insensitivity
794        assert_eq!(
795            HostcallCapabilityClass::from_capability("READ"),
796            HostcallCapabilityClass::Filesystem
797        );
798        assert_eq!(
799            HostcallCapabilityClass::from_capability("  HTTP  "),
800            HostcallCapabilityClass::Network
801        );
802    }
803
804    #[test]
805    fn fallback_reason_as_code_all_variants() {
806        assert_eq!(
807            IoUringFallbackReason::CompatKillSwitch.as_code(),
808            "forced_compat_kill_switch"
809        );
810        assert_eq!(
811            IoUringFallbackReason::IoUringDisabled.as_code(),
812            "io_uring_disabled"
813        );
814        assert_eq!(
815            IoUringFallbackReason::IoUringUnavailable.as_code(),
816            "io_uring_unavailable"
817        );
818        assert_eq!(
819            IoUringFallbackReason::MissingIoHint.as_code(),
820            "io_hint_missing"
821        );
822        assert_eq!(
823            IoUringFallbackReason::UnsupportedCapability.as_code(),
824            "io_uring_capability_not_supported"
825        );
826        assert_eq!(
827            IoUringFallbackReason::QueueDepthBudgetExceeded.as_code(),
828            "io_uring_queue_depth_budget_exceeded"
829        );
830    }
831
832    #[test]
833    fn serde_roundtrip_decision_and_lane() {
834        let decision = IoUringLaneDecision::io_uring();
835        let json = serde_json::to_string(&decision).expect("serialize");
836        let back: IoUringLaneDecision = serde_json::from_str(&json).expect("deserialize");
837        assert_eq!(back, decision);
838
839        let compat = IoUringLaneDecision::compat(IoUringFallbackReason::CompatKillSwitch);
840        let json2 = serde_json::to_string(&compat).expect("serialize");
841        let back2: IoUringLaneDecision = serde_json::from_str(&json2).expect("deserialize");
842        assert_eq!(back2, compat);
843    }
844
845    #[test]
846    fn decide_with_telemetry_matches_core_decision() {
847        let config = enabled_config();
848        let input = IoUringLaneDecisionInput {
849            capability: HostcallCapabilityClass::Network,
850            io_hint: HostcallIoHint::CpuBound,
851            queue_depth: 1,
852            force_compat_lane: false,
853        };
854        let expected = decide_io_uring_lane(config, input);
855        let (actual, telemetry) = decide_io_uring_lane_with_telemetry(config, input);
856        assert_eq!(actual, expected);
857        assert_eq!(telemetry.lane, expected.lane);
858        assert_eq!(telemetry.fallback_reason, expected.fallback_reason);
859        assert_eq!(telemetry.fallback_code.as_deref(), expected.fallback_code());
860    }
861
862    // ── Property tests ──
863
864    mod proptest_io_uring_lane {
865        use super::*;
866        use proptest::prelude::*;
867
868        fn arb_capability() -> impl Strategy<Value = HostcallCapabilityClass> {
869            prop::sample::select(vec![
870                HostcallCapabilityClass::Filesystem,
871                HostcallCapabilityClass::Network,
872                HostcallCapabilityClass::Execution,
873                HostcallCapabilityClass::Session,
874                HostcallCapabilityClass::Events,
875                HostcallCapabilityClass::Environment,
876                HostcallCapabilityClass::Tool,
877                HostcallCapabilityClass::Ui,
878                HostcallCapabilityClass::Telemetry,
879                HostcallCapabilityClass::Unknown,
880            ])
881        }
882
883        fn arb_io_hint() -> impl Strategy<Value = HostcallIoHint> {
884            prop::sample::select(vec![
885                HostcallIoHint::Unknown,
886                HostcallIoHint::IoHeavy,
887                HostcallIoHint::CpuBound,
888            ])
889        }
890
891        fn arb_config() -> impl Strategy<Value = IoUringLanePolicyConfig> {
892            (
893                any::<bool>(),
894                any::<bool>(),
895                1..512usize,
896                any::<bool>(),
897                any::<bool>(),
898            )
899                .prop_map(
900                    |(enabled, ring_available, max_queue_depth, allow_fs, allow_net)| {
901                        IoUringLanePolicyConfig {
902                            enabled,
903                            ring_available,
904                            max_queue_depth,
905                            allow_filesystem: allow_fs,
906                            allow_network: allow_net,
907                        }
908                    },
909                )
910        }
911
912        fn arb_input() -> impl Strategy<Value = IoUringLaneDecisionInput> {
913            (arb_capability(), arb_io_hint(), 0..1024usize, any::<bool>()).prop_map(
914                |(capability, io_hint, queue_depth, force_compat_lane)| IoUringLaneDecisionInput {
915                    capability,
916                    io_hint,
917                    queue_depth,
918                    force_compat_lane,
919                },
920            )
921        }
922
923        proptest! {
924            #[test]
925            fn force_compat_always_returns_compat_lane(
926                cfg in arb_config(),
927                capability in arb_capability(),
928                io_hint in arb_io_hint(),
929                queue_depth in 0..1024usize,
930            ) {
931                let input = IoUringLaneDecisionInput {
932                    capability,
933                    io_hint,
934                    queue_depth,
935                    force_compat_lane: true,
936                };
937                let decision = decide_io_uring_lane(cfg, input);
938                assert_eq!(decision.lane, HostcallDispatchLane::Compat);
939                assert_eq!(
940                    decision.fallback_reason,
941                    Some(IoUringFallbackReason::CompatKillSwitch)
942                );
943            }
944
945            #[test]
946            fn disabled_never_returns_io_uring(
947                cfg_base in arb_config(),
948                input in arb_input(),
949            ) {
950                let cfg = IoUringLanePolicyConfig {
951                    enabled: false,
952                    ..cfg_base
953                };
954                let input = IoUringLaneDecisionInput {
955                    force_compat_lane: false,
956                    ..input
957                };
958                let decision = decide_io_uring_lane(cfg, input);
959                assert_ne!(
960                    decision.lane,
961                    HostcallDispatchLane::IoUring,
962                    "disabled config must never select io_uring"
963                );
964            }
965
966            #[test]
967            fn io_uring_only_when_all_preconditions_met(
968                cfg in arb_config(),
969                input in arb_input(),
970            ) {
971                let decision = decide_io_uring_lane(cfg, input);
972                if decision.lane == HostcallDispatchLane::IoUring {
973                    assert!(!input.force_compat_lane, "compat kill-switch must be off");
974                    assert!(cfg.enabled, "policy must be enabled");
975                    assert!(cfg.ring_available, "ring must be available");
976                    assert!(input.io_hint.is_io_heavy(), "must be IO-heavy");
977                    assert!(
978                        cfg.allow_for_capability(input.capability),
979                        "capability must be allowed"
980                    );
981                    assert!(
982                        input.queue_depth < cfg.max_queue_depth,
983                        "queue depth must be within budget"
984                    );
985                }
986            }
987
988            #[test]
989            fn decision_always_has_fallback_reason_unless_io_uring(
990                cfg in arb_config(),
991                input in arb_input(),
992            ) {
993                let decision = decide_io_uring_lane(cfg, input);
994                if decision.lane == HostcallDispatchLane::IoUring {
995                    assert!(
996                        decision.fallback_reason.is_none(),
997                        "io_uring lane must have no fallback reason"
998                    );
999                } else {
1000                    assert!(
1001                        decision.fallback_reason.is_some(),
1002                        "non-io_uring lane must have a fallback reason"
1003                    );
1004                }
1005            }
1006
1007            #[test]
1008            fn telemetry_consistent_with_decision(
1009                cfg in arb_config(),
1010                input in arb_input(),
1011            ) {
1012                let (decision, telemetry) = decide_io_uring_lane_with_telemetry(cfg, input);
1013                assert_eq!(telemetry.lane, decision.lane);
1014                assert_eq!(telemetry.fallback_reason, decision.fallback_reason);
1015                assert_eq!(telemetry.policy_enabled, cfg.enabled);
1016                assert_eq!(telemetry.ring_available, cfg.ring_available);
1017                assert_eq!(telemetry.force_compat_lane, input.force_compat_lane);
1018                assert_eq!(telemetry.queue_depth, input.queue_depth);
1019                assert_eq!(telemetry.queue_depth_budget, cfg.max_queue_depth);
1020            }
1021
1022            #[test]
1023            fn decide_is_deterministic(
1024                cfg in arb_config(),
1025                input in arb_input(),
1026            ) {
1027                let d1 = decide_io_uring_lane(cfg, input);
1028                let d2 = decide_io_uring_lane(cfg, input);
1029                assert_eq!(d1, d2, "same inputs must produce same decision");
1030            }
1031        }
1032    }
1033}