Skip to main content

infotheory/aixi/
vm_nyx.rs

1//! High-performance VM-backed AIXI environment using nyx-lite (Firecracker).
2//!
3//! This module provides a VM environment implementation built on top of nyx-lite,
4//! enabling high-frequency snapshot-based resets for fast experimentation (hardware and
5//! guest behavior dependent).
6//!
7//! ## Architecture
8//!
9//! The environment uses Firecracker's KVM-based microVM with nyx-lite's incremental
10//! snapshot and reset capabilities. Communication with the guest occurs via:
11//!
12//! 1. **Shared Memory**: Zero-copy data transfer between host and guest
13//! 2. **Hypercalls**: Control plane communication (snapshot, done, etc.)
14//! 3. **Serial PTY**: Optional console I/O for simpler protocols
15//!
16//! ## Design Principles
17//!
18//! - **Universal**: Not biased towards any specific use case (fuzzing, etc.)
19//! - **High Performance**: Leverages incremental snapshots and dirty page tracking
20//! - **Configurable**: Pluggable reward policies, action sources, observation modes
21//! - **Information-Theoretic**: Built-in support for entropy-based metrics
22
23use crate::aixi::common::{Action, ActionAlphabet, PerceptVal, RandomGenerator, Reward};
24use crate::aixi::environment::Environment;
25use crate::api::{
26    CompiledRateBackend, RateBackend, empirical_entropy_bytes, try_cross_entropy_rate_backend,
27    try_entropy_rate_backend,
28};
29#[cfg(feature = "backend-ctw")]
30use crate::backends::ctw::{ContextTree, FacContextTree, ctw_symbol_bit_msb};
31#[cfg(feature = "backend-rosa")]
32use crate::backends::rosaplus::RosaPlus;
33#[cfg(feature = "backend-zpaq")]
34use crate::backends::zpaq_rate::ZpaqRateModel;
35#[cfg(feature = "backend-rwkv")]
36use crate::coders::softmax_pdf_inplace;
37use crate::error::{InfotheoryError, InfotheoryResult};
38#[cfg(feature = "backend-mamba")]
39use crate::mambazip;
40#[cfg(feature = "backend-mamba")]
41use crate::mambazip::Compressor as MambaCompressor;
42use crate::mixture::OnlineBytePredictor;
43#[cfg(feature = "backend-rwkv")]
44use crate::rwkvzip::Compressor;
45use crate::spec::{
46    AssetBinding, AssetRef, EnvironmentSpec, ResolvedAssetBinding, SharedMemoryPolicySpec,
47    SpecEnvironment, VmActionFilterSpec, VmEnvironmentSpec, VmFuzzMutatorSpec,
48    VmObservationPolicySpec, VmObservationStreamModeSpec, VmPayloadEncodingSpec,
49    VmRewardPolicySpec, VmRewardShapingSpec, VmRuntimeActionSourceSpec, VmTraceSpec,
50};
51use serde_json::Value;
52use std::borrow::Cow;
53use std::fs::OpenOptions;
54use std::io::Write;
55use std::path::Path;
56use std::sync::Arc;
57use std::time::{Duration, Instant};
58
59// Re-export nyx-lite types for external use
60pub use nyx_lite::mem::SharedMemoryRegion;
61pub use nyx_lite::snapshot::NyxSnapshot;
62pub use nyx_lite::{ExitReason, NyxVM, SharedMemoryPolicy};
63
64// ============================================================================
65// Encoding Types
66// ============================================================================
67
68/// Payload encoding for wire protocol.
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70#[non_exhaustive]
71pub enum PayloadEncoding {
72    /// Treat payloads as UTF-8/text bytes.
73    Utf8,
74    /// Treat payloads as hexadecimal text.
75    Hex,
76}
77
78impl PayloadEncoding {
79    /// Decode a wire payload string into raw bytes using this encoding.
80    pub fn decode(self, s: &str) -> anyhow::Result<Vec<u8>> {
81        match self {
82            Self::Utf8 => Ok(s.as_bytes().to_vec()),
83            Self::Hex => hex_decode(s),
84        }
85    }
86
87    /// Encode raw bytes for transport over the configured wire protocol.
88    pub fn encode(self, bytes: &[u8]) -> String {
89        match self {
90            Self::Utf8 => String::from_utf8_lossy(bytes).to_string(),
91            Self::Hex => hex_encode(bytes),
92        }
93    }
94}
95
96impl std::str::FromStr for PayloadEncoding {
97    type Err = &'static str;
98
99    fn from_str(s: &str) -> Result<Self, Self::Err> {
100        match s {
101            "utf8" => Ok(Self::Utf8),
102            "hex" => Ok(Self::Hex),
103            _ => Err("unknown payload encoding"),
104        }
105    }
106}
107
108fn hex_decode(s: &str) -> anyhow::Result<Vec<u8>> {
109    let mut out = Vec::with_capacity(s.len() / 2);
110    let mut buf = 0u8;
111    let mut high = true;
112    for c in s.bytes() {
113        let v = match c {
114            b'0'..=b'9' => c - b'0',
115            b'a'..=b'f' => c - b'a' + 10,
116            b'A'..=b'F' => c - b'A' + 10,
117            b' ' | b'\n' | b'\r' | b'\t' => continue,
118            _ => return Err(anyhow::anyhow!("invalid hex byte: {}", c as char)),
119        };
120        if high {
121            buf = v << 4;
122            high = false;
123        } else {
124            buf |= v;
125            out.push(buf);
126            high = true;
127        }
128    }
129    if !high {
130        return Err(anyhow::anyhow!("hex string has odd length"));
131    }
132    Ok(out)
133}
134
135fn resolve_relative_path(base: &Path, path: &str) -> String {
136    let p = Path::new(path);
137    if p.is_absolute() {
138        path.to_string()
139    } else {
140        base.join(p).to_string_lossy().to_string()
141    }
142}
143
144fn rewrite_firecracker_config_paths(config_path: &str, raw_json: &str) -> anyhow::Result<String> {
145    let base_dir = Path::new(config_path)
146        .parent()
147        .unwrap_or_else(|| Path::new("."));
148    let mut v: Value = serde_json::from_str(raw_json)?;
149
150    if let Some(boot) = v.get_mut("boot-source") {
151        if let Some(path_val) = boot.get_mut("kernel_image_path")
152            && let Some(path_str) = path_val.as_str()
153        {
154            let resolved = resolve_relative_path(base_dir, path_str);
155            *path_val = Value::String(resolved);
156        }
157        if let Some(path_val) = boot.get_mut("initrd_path")
158            && let Some(path_str) = path_val.as_str()
159        {
160            let resolved = resolve_relative_path(base_dir, path_str);
161            *path_val = Value::String(resolved);
162        }
163    }
164
165    if let Some(drives) = v.get_mut("drives").and_then(|d| d.as_array_mut()) {
166        for drive in drives {
167            if let Some(path_val) = drive.get_mut("path_on_host")
168                && let Some(path_str) = path_val.as_str()
169            {
170                let resolved = resolve_relative_path(base_dir, path_str);
171                *path_val = Value::String(resolved);
172            }
173        }
174    }
175
176    Ok(serde_json::to_string(&v)?)
177}
178
179fn hex_encode(bytes: &[u8]) -> String {
180    let mut s = String::with_capacity(bytes.len() * 2);
181    for b in bytes {
182        s.push(hex_digit(b >> 4));
183        s.push(hex_digit(b & 0x0F));
184    }
185    s
186}
187
188fn hex_digit(v: u8) -> char {
189    match v {
190        0..=9 => (b'0' + v) as char,
191        _ => (b'a' + (v - 10)) as char,
192    }
193}
194
195// ============================================================================
196// Guest Communication Protocol
197// ============================================================================
198
199/// Hypercall identifiers (must match guest implementation).
200/// These are exported for use by custom guest programs.
201#[allow(dead_code)]
202pub const HYPERCALL_EXECDONE: u64 = 0x656e6f6463657865; // "execdone"
203/// Guest requested host-side snapshot operation.
204#[allow(dead_code)]
205pub const HYPERCALL_SNAPSHOT: u64 = 0x746f687370616e73; // "snapshot"
206/// Guest announced nyx-lite protocol/version handshake.
207#[allow(dead_code)]
208pub const HYPERCALL_NYX_LITE: u64 = 0x6574696c2d78796e; // "nyx-lite"
209/// Guest requested shared memory initialization/refresh.
210#[allow(dead_code)]
211pub const HYPERCALL_SHAREMEM: u64 = 0x6d656d6572616873; // "sharemem"
212/// Guest emitted a debug-print hypercall payload.
213#[allow(dead_code)]
214pub const HYPERCALL_DBGPRINT: u64 = 0x746e697270676264; // "dbgprint"
215
216const SHARED_ACTION_LEN_OFFSET: u64 = 0;
217const SHARED_RESP_LEN_OFFSET: u64 = 8;
218const SHARED_PAYLOAD_OFFSET: u64 = 16;
219
220/// Protocol configuration for structured communication.
221#[derive(Clone, Debug)]
222#[non_exhaustive]
223pub struct NyxProtocolConfig {
224    /// Prefix for action messages.
225    pub action_prefix: String,
226    /// Suffix for action messages.
227    pub action_suffix: String,
228    /// Prefix for observation responses.
229    pub obs_prefix: String,
230    /// Prefix for reward responses.
231    pub rew_prefix: String,
232    /// Prefix for done indicator.
233    pub done_prefix: String,
234    /// Prefix for data payloads.
235    pub data_prefix: String,
236    /// Wire encoding for payloads.
237    pub wire_encoding: PayloadEncoding,
238}
239
240impl Default for NyxProtocolConfig {
241    fn default() -> Self {
242        Self {
243            action_prefix: "ACT ".to_string(),
244            action_suffix: "\n".to_string(),
245            obs_prefix: "OBS ".to_string(),
246            rew_prefix: "REW ".to_string(),
247            done_prefix: "DONE ".to_string(),
248            data_prefix: "DATA ".to_string(),
249            wire_encoding: PayloadEncoding::Hex,
250        }
251    }
252}
253
254// ============================================================================
255// Action Configuration
256// ============================================================================
257
258/// A single action specification.
259#[derive(Clone, Debug)]
260#[non_exhaustive]
261pub struct NyxActionSpec {
262    /// Optional human-readable name.
263    pub name: Option<String>,
264    /// Raw payload bytes to send.
265    pub payload: Vec<u8>,
266}
267
268impl NyxActionSpec {
269    /// Create an action specification with no explicit name.
270    pub fn new(payload: Vec<u8>) -> Self {
271        Self {
272            name: None,
273            payload,
274        }
275    }
276
277    /// Create an action specification with a human-readable name.
278    pub fn named(name: impl Into<String>, payload: Vec<u8>) -> Self {
279        Self {
280            name: Some(name.into()),
281            payload,
282        }
283    }
284}
285
286impl Default for NyxActionSpec {
287    fn default() -> Self {
288        Self::new(Vec::new())
289    }
290}
291
292/// Fuzzing mutator types.
293#[derive(Clone, Debug)]
294#[non_exhaustive]
295pub enum FuzzMutator {
296    /// Flip one random bit.
297    FlipBit,
298    /// Flip one full byte.
299    FlipByte,
300    /// Insert a random byte at a random position.
301    InsertByte,
302    /// Delete one random byte.
303    DeleteByte,
304    /// Splice bytes from an existing seed input.
305    SpliceSeed,
306    /// Replace the working input with a seed input.
307    ResetSeed,
308    /// Apply a short sequence of random mutations.
309    Havoc,
310}
311
312/// Fuzzing configuration for action generation.
313#[derive(Clone, Debug)]
314#[non_exhaustive]
315pub struct NyxFuzzConfig {
316    /// Corpus used for seed/reset/splice operations.
317    pub seeds: Vec<Vec<u8>>,
318    /// Mutator set available for action generation.
319    pub mutators: Vec<FuzzMutator>,
320    /// Minimum generated action length.
321    pub min_len: usize,
322    /// Maximum generated action length.
323    pub max_len: usize,
324    /// Optional dictionary tokens for insertion/splicing.
325    pub dictionary: Vec<Vec<u8>>,
326    /// Deterministic RNG seed for mutation sampling.
327    pub rng_seed: u64,
328}
329
330impl NyxFuzzConfig {
331    /// Create fuzzing configuration with sensible defaults.
332    pub fn new(seeds: Vec<Vec<u8>>) -> Self {
333        Self {
334            seeds,
335            mutators: vec![FuzzMutator::Havoc],
336            min_len: 1,
337            max_len: 4096,
338            dictionary: Vec::new(),
339            rng_seed: 0,
340        }
341    }
342}
343
344impl Default for NyxFuzzConfig {
345    fn default() -> Self {
346        Self::new(Vec::new())
347    }
348}
349
350/// Source of actions for the environment.
351#[derive(Clone, Debug)]
352#[non_exhaustive]
353pub enum NyxActionSource {
354    /// Fixed set of action payloads.
355    Literal(Vec<NyxActionSpec>),
356    /// Mutation-based action generation.
357    Fuzz(NyxFuzzConfig),
358}
359
360// ============================================================================
361// Observation Configuration
362// ============================================================================
363
364/// How observations are derived from guest output.
365#[derive(Clone, Copy, Debug)]
366#[non_exhaustive]
367pub enum NyxObservationPolicy {
368    /// Parse structured OBS/REW/DONE messages from guest.
369    FromGuest,
370    /// Hash raw output to derive observation.
371    OutputHash,
372    /// Use raw output bytes as observation stream.
373    RawOutput,
374    /// Use shared memory contents as observation.
375    SharedMemory,
376}
377
378/// Stream normalization mode.
379#[derive(Clone, Copy, Debug)]
380#[non_exhaustive]
381pub enum NyxObservationStreamMode {
382    /// Pad short streams, truncate long ones.
383    PadTruncate,
384    /// Only pad short streams.
385    Pad,
386    /// Only truncate long streams.
387    Truncate,
388}
389
390// ============================================================================
391// Reward Configuration
392// ============================================================================
393
394/// How rewards are computed.
395#[derive(Clone)]
396#[non_exhaustive]
397pub enum NyxRewardPolicy {
398    /// Parse reward from guest response.
399    FromGuest,
400    /// Pattern matching on output.
401    Pattern {
402        /// Substring/pattern tested against guest output.
403        pattern: String,
404        /// Reward returned when the pattern does not match.
405        base_reward: i64,
406        /// Additional reward added when the pattern matches.
407        bonus_reward: i64,
408    },
409    /// Custom reward function (callback-based).
410    Custom(Arc<dyn Fn(&NyxStepResult) -> Reward + Send + Sync>),
411}
412
413/// Optional reward shaping (additive to base reward).
414///
415/// Algorithmic configuration for the entropy estimator (such as ROSA's
416/// `max_order`) lives inside the active `stats_backend`'s
417/// [`crate::api::RateBackend`] variant.
418#[derive(Clone, Debug)]
419#[non_exhaustive]
420pub enum NyxRewardShaping {
421    /// Entropy reduction vs baseline.
422    EntropyReduction {
423        /// Reference bytes used as baseline data distribution.
424        baseline_bytes: Vec<u8>,
425        /// Scaling factor applied to the shaping term.
426        scale: f64,
427        /// Optional additive bonus when guest crashes.
428        crash_bonus: Option<i64>,
429        /// Optional additive bonus when guest times out.
430        timeout_bonus: Option<i64>,
431    },
432    /// Entropy of trace data (online learning).
433    TraceEntropy {
434        /// Scaling factor applied to the shaping term.
435        scale: f64,
436        /// If true, normalize by trace length.
437        normalize: bool,
438    },
439}
440
441impl std::fmt::Debug for NyxRewardPolicy {
442    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443        match self {
444            Self::FromGuest => write!(f, "FromGuest"),
445            Self::Pattern {
446                pattern,
447                base_reward,
448                bonus_reward,
449            } => f
450                .debug_struct("Pattern")
451                .field("pattern", pattern)
452                .field("base_reward", base_reward)
453                .field("bonus_reward", bonus_reward)
454                .finish(),
455            Self::Custom(_) => write!(f, "Custom(<fn>)"),
456        }
457    }
458}
459
460// ============================================================================
461// Action Filtering
462// ============================================================================
463
464/// Information-theoretic action filtering.
465///
466/// Algorithmic configuration for the entropy estimator (such as ROSA's
467/// `max_order`) lives inside the active `stats_backend`'s
468/// [`crate::api::RateBackend`] variant.
469#[derive(Clone, Debug)]
470#[non_exhaustive]
471pub struct NyxActionFilter {
472    /// Minimum entropy threshold.
473    pub min_entropy: Option<f64>,
474    /// Maximum entropy threshold.
475    pub max_entropy: Option<f64>,
476    /// Minimum intrinsic dependence.
477    pub min_intrinsic_dependence: Option<f64>,
478    /// Minimum novelty (cross-entropy vs prior).
479    pub min_novelty: Option<f64>,
480    /// Prior corpus for novelty computation.
481    pub novelty_prior: Option<Vec<u8>>,
482    /// Reward to assign when action is rejected.
483    pub reject_reward: Option<i64>,
484}
485
486impl NyxActionFilter {
487    /// Create an action filter with no active constraints.
488    pub fn new() -> Self {
489        Self::default()
490    }
491}
492
493impl Default for NyxActionFilter {
494    fn default() -> Self {
495        Self {
496            min_entropy: None,
497            max_entropy: None,
498            min_intrinsic_dependence: None,
499            min_novelty: None,
500            novelty_prior: None,
501            reject_reward: None,
502        }
503    }
504}
505
506// ============================================================================
507// Trace Configuration
508// ============================================================================
509
510/// Configuration for trace collection and analysis.
511#[derive(Clone, Debug)]
512#[non_exhaustive]
513pub struct NyxTraceConfig {
514    /// Shared memory region name for trace data.
515    pub shared_region_name: Option<String>,
516    /// Maximum bytes to collect per step.
517    pub max_bytes: usize,
518    /// Reset trace model on episode boundary.
519    pub reset_on_episode: bool,
520}
521
522impl NyxTraceConfig {
523    /// Create trace configuration with defaults used by the CLI parser.
524    pub fn new() -> Self {
525        Self::default()
526    }
527}
528
529impl Default for NyxTraceConfig {
530    fn default() -> Self {
531        Self {
532            shared_region_name: Some("trace".to_string()),
533            max_bytes: 1_000_000,
534            reset_on_episode: false,
535        }
536    }
537}
538
539// ============================================================================
540// Main Configuration
541// ============================================================================
542
543/// Complete configuration for the nyx-lite VM environment.
544#[derive(Clone)]
545#[non_exhaustive]
546pub struct NyxVmConfig {
547    /// Path to Firecracker JSON config.
548    pub firecracker_config: String,
549    /// Instance ID for the VM.
550    pub instance_id: String,
551
552    // Shared memory configuration
553    /// Name of the shared memory region for communication.
554    pub shared_region_name: String,
555    /// Size of the shared memory region.
556    pub shared_region_size: usize,
557    /// Shared memory policy (snapshot vs preserve).
558    pub shared_memory_policy: SharedMemoryPolicy,
559
560    // Timing configuration
561    /// Timeout for each step.
562    pub step_timeout: Duration,
563    /// Timeout for initial boot.
564    pub boot_timeout: Duration,
565
566    // Episode configuration
567    /// Number of steps per episode.
568    pub episode_steps: usize,
569    /// Cost subtracted from reward each step.
570    pub step_cost: i64,
571
572    // Observation configuration
573    /// Observation derivation policy.
574    pub observation_policy: NyxObservationPolicy,
575    /// Bits per observation symbol.
576    pub observation_bits: usize,
577    /// Number of observation symbols per action.
578    pub observation_stream_len: usize,
579    /// Stream normalization mode.
580    pub observation_stream_mode: NyxObservationStreamMode,
581    /// Padding byte for short streams.
582    pub observation_pad_byte: u8,
583
584    // Reward configuration
585    /// Bits for reward encoding.
586    pub reward_bits: usize,
587    /// Reward computation policy.
588    pub reward_policy: NyxRewardPolicy,
589    /// Optional reward shaping (additive; non-canonical).
590    pub reward_shaping: Option<NyxRewardShaping>,
591
592    // Action configuration
593    /// Source of actions.
594    pub action_source: NyxActionSource,
595    /// Optional action filter.
596    pub action_filter: Option<NyxActionFilter>,
597
598    // Protocol configuration
599    /// Wire protocol for structured communication.
600    pub protocol: NyxProtocolConfig,
601
602    // Statistics backend
603    /// Backend for entropy estimation.
604    pub stats_backend: RateBackend,
605
606    // Trace configuration
607    /// Optional trace collection.
608    pub trace: Option<NyxTraceConfig>,
609
610    // Debug mode
611    /// Enable verbose VM/protocol diagnostics.
612    pub debug_mode: bool,
613
614    // Crash logging
615    /// Path to log crashes/interesting behaviors (JSONL format).
616    pub crash_log: Option<String>,
617}
618
619fn default_vm_stats_backend() -> RateBackend {
620    // Keep the VM default explicit so `vm` can be combined with a narrow
621    // backend slice instead of inheriting the crate-wide implicit default.
622    RateBackend::Ctw { depth: 20 }
623}
624
625impl Default for NyxVmConfig {
626    fn default() -> Self {
627        Self {
628            firecracker_config: String::new(),
629            instance_id: "aixi-nyx".to_string(),
630            shared_region_name: "shared".to_string(),
631            shared_region_size: 4096,
632            shared_memory_policy: SharedMemoryPolicy::Snapshot,
633            step_timeout: Duration::from_millis(100),
634            boot_timeout: Duration::from_secs(30),
635            episode_steps: 100,
636            step_cost: 0,
637            observation_policy: NyxObservationPolicy::SharedMemory,
638            observation_bits: 8,
639            observation_stream_len: 64,
640            observation_stream_mode: NyxObservationStreamMode::PadTruncate,
641            observation_pad_byte: 0,
642            reward_bits: 8,
643            reward_policy: NyxRewardPolicy::FromGuest,
644            reward_shaping: None,
645            action_source: NyxActionSource::Literal(vec![]),
646            action_filter: None,
647            protocol: NyxProtocolConfig::default(),
648            stats_backend: default_vm_stats_backend(),
649            trace: None,
650            debug_mode: false,
651            crash_log: None,
652        }
653    }
654}
655
656impl NyxVmConfig {
657    fn validate_runtime_invariants(&self) -> InfotheoryResult<()> {
658        if self.firecracker_config.trim().is_empty() {
659            return Err(InfotheoryError::invalid_backend_config(
660                "firecracker_config path must be set",
661            ));
662        }
663        if self.episode_steps == 0 {
664            return Err(InfotheoryError::invalid_backend_config(
665                "episode_steps must be > 0",
666            ));
667        }
668        if matches!(self.observation_policy, NyxObservationPolicy::RawOutput)
669            && self.observation_stream_len == 0
670        {
671            return Err(InfotheoryError::invalid_backend_config(
672                "observation_stream_len must be > 0 for RawOutput policy",
673            ));
674        }
675        if matches!(
676            self.reward_shaping,
677            Some(NyxRewardShaping::TraceEntropy { .. })
678        ) && self.trace.is_none()
679        {
680            return Err(InfotheoryError::invalid_backend_config(
681                "vm_trace must be configured for vm_reward_shaping.mode=trace-entropy",
682            ));
683        }
684
685        Ok(())
686    }
687
688    /// Validate this VM configuration for direct runtime construction.
689    pub fn validate(&self) -> InfotheoryResult<()> {
690        self.validate_runtime_invariants()
691    }
692
693    /// Validate that this VM configuration is representable by canonical spec documents.
694    pub fn validate_canonical_spec_compatibility(&self) -> InfotheoryResult<()> {
695        self.validate_runtime_invariants()?;
696
697        let encoding = self.protocol.wire_encoding;
698        let firecracker_asset = "firecracker_config".to_string();
699        let mut assets = vec![AssetBinding {
700            id: firecracker_asset.clone(),
701            path: self.firecracker_config.clone(),
702        }];
703        let reward_shaping = match &self.reward_shaping {
704            Some(NyxRewardShaping::EntropyReduction {
705                baseline_bytes: _,
706                scale,
707                crash_bonus,
708                timeout_bonus,
709            }) => {
710                let asset_id = "reward_shaping_baseline".to_string();
711                assets.push(AssetBinding {
712                    id: asset_id.clone(),
713                    path: "inline://reward_shaping_baseline".to_string(),
714                });
715                Some(VmRewardShapingSpec::EntropyReduction {
716                    baseline_asset: asset_id,
717                    scale: *scale,
718                    crash_bonus: *crash_bonus,
719                    timeout_bonus: *timeout_bonus,
720                })
721            }
722            Some(NyxRewardShaping::TraceEntropy { scale, normalize }) => {
723                Some(VmRewardShapingSpec::TraceEntropy {
724                    scale: *scale,
725                    normalize: *normalize,
726                })
727            }
728            None => None,
729        };
730        let action_filter = self.action_filter.as_ref().map(|filter| {
731            let novelty_prior_asset = filter.novelty_prior.as_ref().map(|_| {
732                let asset_id = "action_filter_novelty_prior".to_string();
733                assets.push(AssetBinding {
734                    id: asset_id.clone(),
735                    path: "inline://action_filter_novelty_prior".to_string(),
736                });
737                asset_id
738            });
739            VmActionFilterSpec {
740                min_entropy: filter.min_entropy,
741                max_entropy: filter.max_entropy,
742                min_intrinsic_dependence: filter.min_intrinsic_dependence,
743                min_novelty: filter.min_novelty,
744                novelty_prior_asset,
745                reject_reward: filter.reject_reward,
746            }
747        });
748        let action_source = match &self.action_source {
749            NyxActionSource::Literal(actions) => VmRuntimeActionSourceSpec::Literal {
750                names: actions.iter().map(|action| action.name.clone()).collect(),
751                payloads: actions
752                    .iter()
753                    .map(|action| encoding.encode(&action.payload))
754                    .collect(),
755                encoding: match encoding {
756                    PayloadEncoding::Utf8 => VmPayloadEncodingSpec::Utf8,
757                    PayloadEncoding::Hex => VmPayloadEncodingSpec::Hex,
758                },
759            },
760            NyxActionSource::Fuzz(fuzz) => VmRuntimeActionSourceSpec::Fuzz {
761                seeds: fuzz
762                    .seeds
763                    .iter()
764                    .map(|seed| encoding.encode(seed))
765                    .collect(),
766                encoding: match encoding {
767                    PayloadEncoding::Utf8 => VmPayloadEncodingSpec::Utf8,
768                    PayloadEncoding::Hex => VmPayloadEncodingSpec::Hex,
769                },
770                mutators: fuzz
771                    .mutators
772                    .iter()
773                    .map(|mutator| match mutator {
774                        FuzzMutator::FlipBit => VmFuzzMutatorSpec::FlipBit,
775                        FuzzMutator::FlipByte => VmFuzzMutatorSpec::FlipByte,
776                        FuzzMutator::InsertByte => VmFuzzMutatorSpec::InsertByte,
777                        FuzzMutator::DeleteByte => VmFuzzMutatorSpec::DeleteByte,
778                        FuzzMutator::SpliceSeed => VmFuzzMutatorSpec::SpliceSeed,
779                        FuzzMutator::ResetSeed => VmFuzzMutatorSpec::ResetSeed,
780                        FuzzMutator::Havoc => VmFuzzMutatorSpec::Havoc,
781                    })
782                    .collect(),
783                min_len: fuzz.min_len,
784                max_len: fuzz.max_len,
785                dictionary: fuzz
786                    .dictionary
787                    .iter()
788                    .map(|entry| encoding.encode(entry))
789                    .collect(),
790                rng_seed: fuzz.rng_seed,
791            },
792        };
793        let reward_policy = match &self.reward_policy {
794            NyxRewardPolicy::FromGuest => VmRewardPolicySpec::FromGuest,
795            NyxRewardPolicy::Pattern {
796                pattern,
797                base_reward,
798                bonus_reward,
799            } => VmRewardPolicySpec::Pattern {
800                pattern: pattern.clone(),
801                base_reward: *base_reward,
802                bonus_reward: *bonus_reward,
803            },
804            NyxRewardPolicy::Custom(_) => {
805                return Err(InfotheoryError::invalid_backend_config(
806                    "custom Nyx reward callbacks are not representable in canonical specs",
807                ));
808            }
809        };
810        let environment = EnvironmentSpec::NyxVm(VmEnvironmentSpec {
811            firecracker_config_asset: firecracker_asset,
812            instance_id: self.instance_id.clone(),
813            shared_region_name: self.shared_region_name.clone(),
814            shared_region_size: self.shared_region_size,
815            shared_memory_policy: match self.shared_memory_policy {
816                SharedMemoryPolicy::Preserve => SharedMemoryPolicySpec::Preserve,
817                SharedMemoryPolicy::Snapshot => SharedMemoryPolicySpec::Snapshot,
818            },
819            step_timeout_ms: self.step_timeout.as_millis() as u64,
820            boot_timeout_ms: self.boot_timeout.as_millis() as u64,
821            episode_steps: self.episode_steps,
822            step_cost: self.step_cost,
823            observation_policy: match self.observation_policy {
824                NyxObservationPolicy::FromGuest => VmObservationPolicySpec::FromGuest,
825                NyxObservationPolicy::OutputHash => VmObservationPolicySpec::OutputHash,
826                NyxObservationPolicy::RawOutput => VmObservationPolicySpec::RawOutput,
827                NyxObservationPolicy::SharedMemory => VmObservationPolicySpec::SharedMemory,
828            },
829            observation_bits: self.observation_bits,
830            observation_stream_len: self.observation_stream_len,
831            observation_stream_mode: match self.observation_stream_mode {
832                NyxObservationStreamMode::PadTruncate => VmObservationStreamModeSpec::PadTruncate,
833                NyxObservationStreamMode::Pad => VmObservationStreamModeSpec::Pad,
834                NyxObservationStreamMode::Truncate => VmObservationStreamModeSpec::Truncate,
835            },
836            observation_pad_byte: self.observation_pad_byte,
837            reward_bits: self.reward_bits,
838            reward_policy,
839            reward_shaping,
840            action_source,
841            action_filter,
842            action_prefix: self.protocol.action_prefix.clone(),
843            action_suffix: self.protocol.action_suffix.clone(),
844            obs_prefix: self.protocol.obs_prefix.clone(),
845            rew_prefix: self.protocol.rew_prefix.clone(),
846            done_prefix: self.protocol.done_prefix.clone(),
847            data_prefix: self.protocol.data_prefix.clone(),
848            wire_encoding: match self.protocol.wire_encoding {
849                PayloadEncoding::Utf8 => VmPayloadEncodingSpec::Utf8,
850                PayloadEncoding::Hex => VmPayloadEncodingSpec::Hex,
851            },
852            stats_backend: self.stats_backend.clone(),
853            trace: self.trace.as_ref().map(|trace| VmTraceSpec {
854                shared_region_name: trace.shared_region_name.clone(),
855                max_bytes: trace.max_bytes,
856                reset_on_episode: trace.reset_on_episode,
857            }),
858            debug_mode: self.debug_mode,
859            crash_log: self.crash_log.clone(),
860        });
861        environment
862            .validate_in(&assets, &SpecEnvironment::default())
863            .map(|_| ())
864            .map_err(|err| InfotheoryError::invalid_backend_config(err.to_string()))
865    }
866
867    /// Build a runtime VM configuration from a canonical planner environment spec.
868    pub fn from_environment_spec(
869        spec: &VmEnvironmentSpec,
870        resolved_assets: &[ResolvedAssetBinding],
871    ) -> InfotheoryResult<Self> {
872        let wire_encoding = match spec.wire_encoding {
873            VmPayloadEncodingSpec::Utf8 => PayloadEncoding::Utf8,
874            VmPayloadEncodingSpec::Hex => PayloadEncoding::Hex,
875        };
876        let reward_policy = match &spec.reward_policy {
877            VmRewardPolicySpec::FromGuest => NyxRewardPolicy::FromGuest,
878            VmRewardPolicySpec::Pattern {
879                pattern,
880                base_reward,
881                bonus_reward,
882            } => NyxRewardPolicy::Pattern {
883                pattern: pattern.clone(),
884                base_reward: *base_reward,
885                bonus_reward: *bonus_reward,
886            },
887        };
888        let reward_shaping = match &spec.reward_shaping {
889            Some(VmRewardShapingSpec::EntropyReduction {
890                baseline_asset,
891                scale,
892                crash_bonus,
893                timeout_bonus,
894            }) => Some(NyxRewardShaping::EntropyReduction {
895                baseline_bytes: read_resolved_asset_bytes(resolved_assets, baseline_asset)?,
896                scale: *scale,
897                crash_bonus: *crash_bonus,
898                timeout_bonus: *timeout_bonus,
899            }),
900            Some(VmRewardShapingSpec::TraceEntropy { scale, normalize }) => {
901                Some(NyxRewardShaping::TraceEntropy {
902                    scale: *scale,
903                    normalize: *normalize,
904                })
905            }
906            None => None,
907        };
908        let action_source = match &spec.action_source {
909            VmRuntimeActionSourceSpec::Literal {
910                names,
911                payloads,
912                encoding,
913            } => {
914                let encoding = match encoding {
915                    VmPayloadEncodingSpec::Utf8 => PayloadEncoding::Utf8,
916                    VmPayloadEncodingSpec::Hex => PayloadEncoding::Hex,
917                };
918                let mut actions = Vec::with_capacity(payloads.len());
919                for (index, payload) in payloads.iter().enumerate() {
920                    actions.push(NyxActionSpec {
921                        name: names.get(index).cloned().flatten(),
922                        payload: encoding.decode(payload).map_err(|err| {
923                            InfotheoryError::invalid_backend_config(format!(
924                                "invalid literal action payload: {err}"
925                            ))
926                        })?,
927                    });
928                }
929                NyxActionSource::Literal(actions)
930            }
931            VmRuntimeActionSourceSpec::Fuzz {
932                seeds,
933                encoding,
934                mutators,
935                min_len,
936                max_len,
937                dictionary,
938                rng_seed,
939            } => {
940                let encoding = match encoding {
941                    VmPayloadEncodingSpec::Utf8 => PayloadEncoding::Utf8,
942                    VmPayloadEncodingSpec::Hex => PayloadEncoding::Hex,
943                };
944                NyxActionSource::Fuzz(NyxFuzzConfig {
945                    seeds: seeds
946                        .iter()
947                        .map(|seed| {
948                            encoding.decode(seed).map_err(|err| {
949                                InfotheoryError::invalid_backend_config(format!(
950                                    "invalid VM fuzz seed: {err}"
951                                ))
952                            })
953                        })
954                        .collect::<Result<Vec<_>, _>>()?,
955                    mutators: mutators
956                        .iter()
957                        .map(|mutator| match mutator {
958                            VmFuzzMutatorSpec::FlipBit => Ok(FuzzMutator::FlipBit),
959                            VmFuzzMutatorSpec::FlipByte => Ok(FuzzMutator::FlipByte),
960                            VmFuzzMutatorSpec::InsertByte => Ok(FuzzMutator::InsertByte),
961                            VmFuzzMutatorSpec::DeleteByte => Ok(FuzzMutator::DeleteByte),
962                            VmFuzzMutatorSpec::SpliceSeed => Ok(FuzzMutator::SpliceSeed),
963                            VmFuzzMutatorSpec::ResetSeed => Ok(FuzzMutator::ResetSeed),
964                            VmFuzzMutatorSpec::Havoc => Ok(FuzzMutator::Havoc),
965                        })
966                        .collect::<Result<Vec<_>, InfotheoryError>>()?,
967                    min_len: *min_len,
968                    max_len: *max_len,
969                    dictionary: dictionary
970                        .iter()
971                        .map(|entry| {
972                            encoding.decode(entry).map_err(|err| {
973                                InfotheoryError::invalid_backend_config(format!(
974                                    "invalid VM fuzz dictionary entry: {err}"
975                                ))
976                            })
977                        })
978                        .collect::<Result<Vec<_>, _>>()?,
979                    rng_seed: *rng_seed,
980                })
981            }
982        };
983        let action_filter = spec
984            .action_filter
985            .as_ref()
986            .map(|filter| -> InfotheoryResult<NyxActionFilter> {
987                Ok(NyxActionFilter {
988                    min_entropy: filter.min_entropy,
989                    max_entropy: filter.max_entropy,
990                    min_intrinsic_dependence: filter.min_intrinsic_dependence,
991                    min_novelty: filter.min_novelty,
992                    novelty_prior: filter
993                        .novelty_prior_asset
994                        .as_ref()
995                        .map(|id| read_resolved_asset_bytes(resolved_assets, id))
996                        .transpose()?,
997                    reject_reward: filter.reject_reward,
998                })
999            })
1000            .transpose()?;
1001
1002        let config = Self {
1003            firecracker_config: resolved_asset_path(
1004                resolved_assets,
1005                &spec.firecracker_config_asset,
1006            )?
1007            .to_string_lossy()
1008            .into_owned(),
1009            instance_id: spec.instance_id.clone(),
1010            shared_region_name: spec.shared_region_name.clone(),
1011            shared_region_size: spec.shared_region_size,
1012            shared_memory_policy: match spec.shared_memory_policy {
1013                SharedMemoryPolicySpec::Preserve => SharedMemoryPolicy::Preserve,
1014                SharedMemoryPolicySpec::Snapshot => SharedMemoryPolicy::Snapshot,
1015            },
1016            step_timeout: Duration::from_millis(spec.step_timeout_ms),
1017            boot_timeout: Duration::from_millis(spec.boot_timeout_ms),
1018            episode_steps: spec.episode_steps,
1019            step_cost: spec.step_cost,
1020            observation_policy: match spec.observation_policy {
1021                VmObservationPolicySpec::FromGuest => NyxObservationPolicy::FromGuest,
1022                VmObservationPolicySpec::OutputHash => NyxObservationPolicy::OutputHash,
1023                VmObservationPolicySpec::RawOutput => NyxObservationPolicy::RawOutput,
1024                VmObservationPolicySpec::SharedMemory => NyxObservationPolicy::SharedMemory,
1025            },
1026            observation_bits: spec.observation_bits,
1027            observation_stream_len: spec.observation_stream_len,
1028            observation_stream_mode: match spec.observation_stream_mode {
1029                VmObservationStreamModeSpec::PadTruncate => NyxObservationStreamMode::PadTruncate,
1030                VmObservationStreamModeSpec::Pad => NyxObservationStreamMode::Pad,
1031                VmObservationStreamModeSpec::Truncate => NyxObservationStreamMode::Truncate,
1032            },
1033            observation_pad_byte: spec.observation_pad_byte,
1034            reward_bits: spec.reward_bits,
1035            reward_policy,
1036            reward_shaping,
1037            action_source,
1038            action_filter,
1039            protocol: NyxProtocolConfig {
1040                action_prefix: spec.action_prefix.clone(),
1041                action_suffix: spec.action_suffix.clone(),
1042                obs_prefix: spec.obs_prefix.clone(),
1043                rew_prefix: spec.rew_prefix.clone(),
1044                done_prefix: spec.done_prefix.clone(),
1045                data_prefix: spec.data_prefix.clone(),
1046                wire_encoding,
1047            },
1048            stats_backend: spec.stats_backend.clone(),
1049            trace: spec.trace.as_ref().map(|trace| NyxTraceConfig {
1050                shared_region_name: trace.shared_region_name.clone(),
1051                max_bytes: trace.max_bytes,
1052                reset_on_episode: trace.reset_on_episode,
1053            }),
1054            debug_mode: spec.debug_mode,
1055            crash_log: spec.crash_log.clone(),
1056        };
1057        config.validate()?;
1058        Ok(config)
1059    }
1060}
1061
1062fn resolved_asset_path<'a>(
1063    resolved_assets: &'a [ResolvedAssetBinding],
1064    id: &str,
1065) -> InfotheoryResult<&'a Path> {
1066    let binding = resolved_assets
1067        .iter()
1068        .find(|binding| binding.id == id)
1069        .ok_or_else(|| {
1070            InfotheoryError::invalid_backend_config(format!(
1071                "planner_run references unknown asset id '{id}'"
1072            ))
1073        })?;
1074    match &binding.asset {
1075        AssetRef::Filesystem(path) => Ok(path.as_path()),
1076    }
1077}
1078
1079fn read_resolved_asset_bytes(
1080    resolved_assets: &[ResolvedAssetBinding],
1081    id: &str,
1082) -> InfotheoryResult<Vec<u8>> {
1083    let path = resolved_asset_path(resolved_assets, id)?;
1084    std::fs::read(path).map_err(|err| {
1085        InfotheoryError::invalid_backend_config(format!(
1086            "failed to read asset '{}': {err}",
1087            path.display()
1088        ))
1089    })
1090}
1091
1092// ============================================================================
1093// Step Result
1094// ============================================================================
1095
1096/// Result of a single environment step.
1097#[derive(Clone, Debug)]
1098pub struct NyxStepResult {
1099    /// Exit reason from the VM.
1100    pub exit_reason: NyxExitKind,
1101    /// Raw output data from guest.
1102    pub output: Vec<u8>,
1103    /// Parsed observation (if any).
1104    pub parsed_obs: Option<u64>,
1105    /// Parsed reward (if any).
1106    pub parsed_rew: Option<i64>,
1107    /// Done flag.
1108    pub done: bool,
1109    /// Trace data (if collected).
1110    pub trace_data: Vec<u8>,
1111    /// Shared memory contents snapshot.
1112    pub shared_memory: Vec<u8>,
1113}
1114
1115/// Simplified exit reason categories.
1116#[derive(Clone, Debug)]
1117pub enum NyxExitKind {
1118    /// Guest terminated normally with an application-defined code.
1119    ExecDone(u64),
1120    /// Step timed out before a terminal signal/response.
1121    Timeout,
1122    /// VM reported a shutdown event.
1123    Shutdown,
1124    /// Raw hypercall event with integer arguments.
1125    Hypercall {
1126        /// Hypercall identifier/magic value.
1127        code: u64,
1128        /// Hypercall argument 1.
1129        arg1: u64,
1130        /// Hypercall argument 2.
1131        arg2: u64,
1132        /// Hypercall argument 3.
1133        arg3: u64,
1134        /// Hypercall argument 4.
1135        arg4: u64,
1136    },
1137    /// Debug string emitted by guest/host bridge.
1138    DebugPrint(String),
1139    /// Breakpoint/trap-like stop event.
1140    Breakpoint,
1141    /// Uncategorized exit event represented as text.
1142    Other(String),
1143}
1144
1145impl From<ExitReason> for NyxExitKind {
1146    fn from(reason: ExitReason) -> Self {
1147        match reason {
1148            ExitReason::ExecDone(code) => Self::ExecDone(code),
1149            ExitReason::Timeout => Self::Timeout,
1150            ExitReason::Shutdown => Self::Shutdown,
1151            ExitReason::Hypercall(r8, r9, r10, r11, r12) => Self::Hypercall {
1152                code: r8,
1153                arg1: r9,
1154                arg2: r10,
1155                arg3: r11,
1156                arg4: r12,
1157            },
1158            ExitReason::DebugPrint(s) => Self::DebugPrint(s),
1159            ExitReason::Breakpoint => Self::Breakpoint,
1160            ExitReason::RequestSnapshot => Self::Other("RequestSnapshot".to_string()),
1161            ExitReason::SharedMem(name, _, _) => Self::Other(format!("SharedMem({})", name)),
1162            ExitReason::SingleStep => Self::Other("SingleStep".to_string()),
1163            ExitReason::Interrupted => Self::Other("Interrupted".to_string()),
1164            ExitReason::HWBreakpoint(n) => Self::Other(format!("HWBreakpoint({})", n)),
1165            ExitReason::BadMemoryAccess(_) => Self::Other("BadMemoryAccess".to_string()),
1166        }
1167    }
1168}
1169
1170// ============================================================================
1171// Trace Model
1172// ============================================================================
1173
1174/// Predictive model for trace-based reward computation.
1175enum TraceModel {
1176    #[cfg(feature = "backend-rosa")]
1177    Rosa { model: RosaPlus, max_order: i64 },
1178    // `max_order` is preserved here so that `reset()` can rebuild a fresh
1179    // `RosaPlus` with the same `max_order` configured by the active backend
1180    // variant; it is read once at construction from `RateBackendPlan::RosaPlus`.
1181    #[cfg(feature = "backend-ctw")]
1182    Ctw { tree: ContextTree },
1183    #[cfg(feature = "backend-ctw")]
1184    FacCtw {
1185        tree: FacContextTree,
1186        bits_per_symbol: usize,
1187        msb_first: bool,
1188    },
1189    #[cfg(feature = "backend-mamba")]
1190    Mamba {
1191        compressor: MambaCompressor,
1192        primed: bool,
1193    },
1194    #[cfg(feature = "backend-rwkv")]
1195    Rwkv7 {
1196        compressor: Compressor,
1197        primed: bool,
1198    },
1199    #[cfg(feature = "backend-zpaq")]
1200    Zpaq { model: ZpaqRateModel },
1201    Mixture {
1202        backend: CompiledRateBackend,
1203        model: crate::mixture::RateBackendPredictor,
1204    },
1205}
1206
1207impl TraceModel {
1208    fn predictor_backed(backend: CompiledRateBackend) -> anyhow::Result<Self> {
1209        let mut model = crate::runtime::build_rate_backend_predictor(&backend, 2f64.powi(-24))
1210            .map_err(|e| anyhow::anyhow!("predictor-backed init failed: {e}"))?;
1211        model
1212            .begin_stream(None)
1213            .map_err(|e| anyhow::anyhow!("predictor-backed stream init failed: {e}"))?;
1214        Ok(TraceModel::Mixture { backend, model })
1215    }
1216
1217    fn new(backend: &CompiledRateBackend) -> anyhow::Result<Self> {
1218        #[allow(unreachable_patterns)]
1219        match crate::runtime::rate_backend_trace_model_strategy(backend) {
1220            #[cfg(feature = "backend-rosa")]
1221            crate::runtime::TraceModelStrategy::Rosa => {
1222                let crate::spec::core::RateBackendPlan::RosaPlus { max_order } = backend.plan()
1223                else {
1224                    unreachable!("rosa trace strategy used with non-rosa backend")
1225                };
1226                let mut model = RosaPlus::new(*max_order, false, 0, 42);
1227                model.build_lm_full_bytes_no_finalize_endpos();
1228                Ok(TraceModel::Rosa {
1229                    model,
1230                    max_order: *max_order,
1231                })
1232            }
1233            crate::runtime::TraceModelStrategy::PredictorBacked => {
1234                TraceModel::predictor_backed(backend.clone())
1235            }
1236            #[cfg(feature = "backend-ctw")]
1237            crate::runtime::TraceModelStrategy::Ctw => {
1238                let crate::spec::core::RateBackendPlan::Ctw { depth } = backend.plan() else {
1239                    unreachable!("trace-model strategy mismatch for ctw");
1240                };
1241                Ok(TraceModel::Ctw {
1242                    tree: ContextTree::new(*depth),
1243                })
1244            }
1245            #[cfg(feature = "backend-ctw")]
1246            crate::runtime::TraceModelStrategy::FacCtw => {
1247                let crate::spec::core::RateBackendPlan::FacCtw {
1248                    base_depth,
1249                    num_percept_bits: _,
1250                    encoding_bits,
1251                    msb_first,
1252                } = backend.plan()
1253                else {
1254                    unreachable!("trace-model strategy mismatch for fac-ctw");
1255                };
1256                let bits_per_symbol = *encoding_bits;
1257                Ok(TraceModel::FacCtw {
1258                    tree: FacContextTree::new(*base_depth, bits_per_symbol),
1259                    bits_per_symbol,
1260                    msb_first: *msb_first,
1261                })
1262            }
1263            #[cfg(feature = "backend-zpaq")]
1264            crate::runtime::TraceModelStrategy::Zpaq => {
1265                let crate::spec::core::RateBackendPlan::Zpaq { method } = backend.plan() else {
1266                    unreachable!("trace-model strategy mismatch for zpaq");
1267                };
1268                Ok(TraceModel::Zpaq {
1269                    model: ZpaqRateModel::new(method.clone(), 2f64.powi(-24)),
1270                })
1271            }
1272            #[cfg(feature = "backend-mamba")]
1273            crate::runtime::TraceModelStrategy::Mamba => {
1274                let crate::spec::core::RateBackendPlan::Mamba { parsed_method, .. } =
1275                    backend.plan()
1276                else {
1277                    unreachable!("trace-model strategy mismatch for mamba");
1278                };
1279                let compressor = MambaCompressor::new_from_method_spec(parsed_method)
1280                    .map_err(|e| anyhow::anyhow!("invalid mamba method for vm trace model: {e}"))?;
1281                Ok(TraceModel::Mamba {
1282                    compressor,
1283                    primed: false,
1284                })
1285            }
1286            #[cfg(feature = "backend-rwkv")]
1287            crate::runtime::TraceModelStrategy::Rwkv7 => {
1288                let crate::spec::core::RateBackendPlan::Rwkv7 { parsed_method, .. } =
1289                    backend.plan()
1290                else {
1291                    unreachable!("trace-model strategy mismatch for rwkv7");
1292                };
1293                let compressor = Compressor::new_from_method_spec(parsed_method)
1294                    .map_err(|e| anyhow::anyhow!("invalid rwkv7 method for vm trace model: {e}"))?;
1295                Ok(TraceModel::Rwkv7 {
1296                    compressor,
1297                    primed: false,
1298                })
1299            }
1300            _ => unreachable!("trace-model strategy requires an unavailable backend feature"),
1301        }
1302    }
1303
1304    fn reset(&mut self) -> anyhow::Result<()> {
1305        match self {
1306            #[cfg(feature = "backend-rosa")]
1307            TraceModel::Rosa { model, max_order } => {
1308                let mut fresh = RosaPlus::new(*max_order, false, 0, 42);
1309                fresh.build_lm_full_bytes_no_finalize_endpos();
1310                *model = fresh;
1311            }
1312            #[cfg(feature = "backend-ctw")]
1313            TraceModel::Ctw { tree } => tree.clear(),
1314            #[cfg(feature = "backend-ctw")]
1315            TraceModel::FacCtw { tree, .. } => tree.clear(),
1316            #[cfg(feature = "backend-mamba")]
1317            TraceModel::Mamba { compressor, primed } => {
1318                compressor.state.reset();
1319                *primed = false;
1320            }
1321            #[cfg(feature = "backend-rwkv")]
1322            TraceModel::Rwkv7 { compressor, primed } => {
1323                compressor.state.reset();
1324                *primed = false;
1325            }
1326            #[cfg(feature = "backend-zpaq")]
1327            TraceModel::Zpaq { model } => {
1328                model.reset();
1329            }
1330            TraceModel::Mixture { backend, model } => {
1331                *model = crate::runtime::build_rate_backend_predictor(backend, 2f64.powi(-24))
1332                    .map_err(|e| anyhow::anyhow!("mixture model reset failed: {e}"))?;
1333                model
1334                    .begin_stream(None)
1335                    .map_err(|e| anyhow::anyhow!("mixture stream init failed: {e}"))?;
1336            }
1337        }
1338        Ok(())
1339    }
1340
1341    /// Update the model with new data and return the surprise (bits).
1342    fn update_and_score(&mut self, data: &[u8]) -> f64 {
1343        if data.is_empty() {
1344            return 0.0;
1345        }
1346        match self {
1347            #[cfg(feature = "backend-rosa")]
1348            TraceModel::Rosa { model, .. } => {
1349                let mut bits = 0.0;
1350                for &b in data {
1351                    let p = model.prob_for_last(b as u32).max(1e-12);
1352                    bits -= p.log2();
1353                    model.train_byte(b);
1354                }
1355                bits
1356            }
1357            #[cfg(feature = "backend-ctw")]
1358            TraceModel::Ctw { tree } => {
1359                let log_before = tree.get_log_block_probability();
1360                for &b in data {
1361                    for i in (0..8).rev() {
1362                        tree.update(((b >> i) & 1) == 1);
1363                    }
1364                }
1365                let log_after = tree.get_log_block_probability();
1366                let log_delta = log_after - log_before;
1367                -log_delta / std::f64::consts::LN_2
1368            }
1369            #[cfg(feature = "backend-ctw")]
1370            TraceModel::FacCtw {
1371                tree,
1372                bits_per_symbol,
1373                msb_first,
1374            } => {
1375                let log_before = tree.get_log_block_probability();
1376                for &b in data {
1377                    for i in 0..*bits_per_symbol {
1378                        let bit = if *msb_first {
1379                            ctw_symbol_bit_msb(b, *bits_per_symbol, i)
1380                        } else {
1381                            ((b >> i) & 1) == 1
1382                        };
1383                        tree.update(bit, i);
1384                    }
1385                }
1386                let log_after = tree.get_log_block_probability();
1387                let log_delta = log_after - log_before;
1388                -log_delta / std::f64::consts::LN_2
1389            }
1390            #[cfg(feature = "backend-mamba")]
1391            TraceModel::Mamba { compressor, primed } => {
1392                if !*primed {
1393                    let bias = compressor.online_bias_snapshot();
1394                    let logits =
1395                        compressor
1396                            .model
1397                            .forward(&mut compressor.scratch, 0, &mut compressor.state);
1398                    mambazip::Compressor::logits_to_pdf(
1399                        logits,
1400                        bias.as_deref(),
1401                        &mut compressor.pdf_buffer,
1402                    );
1403                    *primed = true;
1404                }
1405                let mut bits = 0.0;
1406                for &b in data {
1407                    let p = compressor.pdf_buffer[b as usize].max(1e-12);
1408                    bits -= p.log2();
1409                    let bias = compressor.online_bias_snapshot();
1410                    let logits = compressor.model.forward(
1411                        &mut compressor.scratch,
1412                        b as u32,
1413                        &mut compressor.state,
1414                    );
1415                    mambazip::Compressor::logits_to_pdf(
1416                        logits,
1417                        bias.as_deref(),
1418                        &mut compressor.pdf_buffer,
1419                    );
1420                }
1421                bits
1422            }
1423            #[cfg(feature = "backend-rwkv")]
1424            TraceModel::Rwkv7 { compressor, primed } => {
1425                if !*primed {
1426                    let vocab_size = compressor.vocab_size();
1427                    let logits =
1428                        compressor
1429                            .model
1430                            .forward(&mut compressor.scratch, 0, &mut compressor.state);
1431                    softmax_pdf_inplace(logits, vocab_size, &mut compressor.pdf_buffer);
1432                    *primed = true;
1433                }
1434                let mut bits = 0.0;
1435                let vocab_size = compressor.vocab_size();
1436                for &b in data {
1437                    let p = compressor.pdf_buffer[b as usize].max(1e-12);
1438                    bits -= p.log2();
1439                    let logits = compressor.model.forward(
1440                        &mut compressor.scratch,
1441                        b as u32,
1442                        &mut compressor.state,
1443                    );
1444                    softmax_pdf_inplace(logits, vocab_size, &mut compressor.pdf_buffer);
1445                }
1446                bits
1447            }
1448            #[cfg(feature = "backend-zpaq")]
1449            TraceModel::Zpaq { model } => model.update_and_score(data),
1450            TraceModel::Mixture { model, .. } => {
1451                let mut bits = 0.0;
1452                for &b in data {
1453                    let logp = model.log_prob(b);
1454                    bits -= logp / std::f64::consts::LN_2;
1455                    model.update(b);
1456                }
1457                bits
1458            }
1459        }
1460    }
1461}
1462
1463// ============================================================================
1464// Fuzz State
1465// ============================================================================
1466
1467struct FuzzState {
1468    current: Vec<u8>,
1469    rng: RandomGenerator,
1470}
1471
1472// ============================================================================
1473// NyxVmEnvironment
1474// ============================================================================
1475
1476/// High-performance VM environment using nyx-lite.
1477pub struct NyxVmEnvironment {
1478    /// Configuration.
1479    config: NyxVmConfig,
1480    /// Compiled entropy/scoring backend used by VM reward logic.
1481    compiled_stats_backend: CompiledRateBackend,
1482    /// The nyx-lite VM instance.
1483    vm: NyxVM,
1484    /// Base snapshot for episode resets.
1485    base_snapshot: Option<Arc<NyxSnapshot>>,
1486    /// Shared memory virtual address in guest.
1487    shared_vaddr: Option<u64>,
1488    /// CR3 used when shared memory was registered.
1489    shared_cr3: Option<u64>,
1490    /// Trace model for entropy-based rewards.
1491    trace_model: Option<TraceModel>,
1492    /// Baseline entropy for entropy reduction rewards.
1493    baseline_entropy: Option<f64>,
1494    /// Effective reward shaping policy (additive).
1495    reward_shaping: Option<NyxRewardShaping>,
1496    /// Fuzzing state.
1497    fuzz_state: Option<FuzzState>,
1498
1499    // Current step state
1500    /// Current observation.
1501    obs: PerceptVal,
1502    /// Current reward.
1503    rew: Reward,
1504    /// Current observation stream.
1505    obs_stream: Vec<PerceptVal>,
1506    /// Step within current episode.
1507    step_in_episode: usize,
1508    /// Whether the environment needs reset.
1509    needs_reset: bool,
1510    /// Whether the VM has been initialized.
1511    initialized: bool,
1512}
1513
1514impl NyxVmEnvironment {
1515    /// Creates a new NyxVmEnvironment with the given configuration.
1516    pub fn new(config: NyxVmConfig) -> anyhow::Result<Self> {
1517        config.validate().map_err(anyhow::Error::msg)?;
1518
1519        // Load Firecracker config and resolve relative paths
1520        let fc_config_raw = std::fs::read_to_string(&config.firecracker_config)
1521            .map_err(|e| anyhow::anyhow!("Failed to read firecracker config: {}", e))?;
1522        let fc_config =
1523            rewrite_firecracker_config_paths(&config.firecracker_config, &fc_config_raw)
1524                .map_err(|e| anyhow::anyhow!("Failed to parse firecracker config: {}", e))?;
1525
1526        // Create the VM
1527        let vm = NyxVM::new(config.instance_id.clone(), &fc_config);
1528
1529        // Initialize reward shaping
1530        let reward_shaping = config.reward_shaping.clone();
1531
1532        let compiled_stats_backend = config
1533            .stats_backend
1534            .compile()
1535            .map_err(|err| anyhow::anyhow!("invalid vm stats_backend: {err}"))?;
1536
1537        // Initialize trace model if needed
1538        let trace_model = match &reward_shaping {
1539            Some(NyxRewardShaping::TraceEntropy { .. }) => Some(
1540                TraceModel::new(&compiled_stats_backend)
1541                    .map_err(|err| anyhow::anyhow!("failed to initialize trace model: {err}"))?,
1542            ),
1543            _ => None,
1544        };
1545
1546        // Compute baseline entropy if needed
1547        let baseline_entropy = match &reward_shaping {
1548            Some(NyxRewardShaping::EntropyReduction { baseline_bytes, .. }) => {
1549                let h = try_entropy_rate_backend(baseline_bytes, &compiled_stats_backend).map_err(
1550                    |err| {
1551                        anyhow::anyhow!(
1552                            "validated vm stats_backend failed to score baseline entropy: {err}"
1553                        )
1554                    },
1555                )?;
1556                Some(h)
1557            }
1558            _ => None,
1559        };
1560
1561        // Initialize fuzz state if needed
1562        let fuzz_state = match &config.action_source {
1563            NyxActionSource::Fuzz(fuzz) => {
1564                if fuzz.seeds.is_empty() {
1565                    return Err(anyhow::anyhow!("Fuzz mode requires at least one seed"));
1566                }
1567                if fuzz.mutators.is_empty() {
1568                    return Err(anyhow::anyhow!("Fuzz mode requires at least one mutator"));
1569                }
1570                let seed = fuzz.seeds[0].clone();
1571                Some(FuzzState {
1572                    current: seed,
1573                    rng: RandomGenerator::from_seed(fuzz.rng_seed),
1574                })
1575            }
1576            NyxActionSource::Literal(actions) => {
1577                if actions.is_empty() {
1578                    return Err(anyhow::anyhow!("Literal mode requires at least one action"));
1579                }
1580                None
1581            }
1582        };
1583
1584        let mut env = Self {
1585            config,
1586            compiled_stats_backend,
1587            vm,
1588            base_snapshot: None,
1589            shared_vaddr: None,
1590            shared_cr3: None,
1591            trace_model,
1592            baseline_entropy,
1593            reward_shaping,
1594            fuzz_state,
1595            obs: 0,
1596            rew: 0,
1597            obs_stream: Vec::new(),
1598            step_in_episode: 0,
1599            needs_reset: true,
1600            initialized: false,
1601        };
1602
1603        // Boot and initialize
1604        env.initialize()?;
1605
1606        Ok(env)
1607    }
1608
1609    /// Initializes the VM by booting to the snapshot point.
1610    fn initialize(&mut self) -> anyhow::Result<()> {
1611        if self.initialized {
1612            return Ok(());
1613        }
1614
1615        if self.config.debug_mode {
1616            eprintln!("[NyxVm] Booting VM...");
1617        }
1618
1619        // Run until we get the shared memory registration
1620        let start = Instant::now();
1621        loop {
1622            if start.elapsed() > self.config.boot_timeout {
1623                return Err(anyhow::anyhow!("Boot timeout waiting for shared memory"));
1624            }
1625
1626            let exit = self.vm.run(Duration::from_secs(1));
1627            match exit {
1628                ExitReason::SharedMem(name, vaddr, size) => {
1629                    if self.config.debug_mode {
1630                        eprintln!(
1631                            "[NyxVm] Shared memory registered: {} @ {:#x} ({} bytes)",
1632                            name, vaddr, size
1633                        );
1634                    }
1635                    if name.trim_end_matches('\0') == self.config.shared_region_name {
1636                        self.shared_vaddr = Some(vaddr);
1637                        self.shared_cr3 = Some(self.vm.sregs().cr3);
1638                        // Register the shared region with the configured policy
1639                        let _ = self.vm.register_shared_region_current(
1640                            vaddr,
1641                            size,
1642                            self.config.shared_memory_policy,
1643                        );
1644                        break;
1645                    }
1646                }
1647                ExitReason::DebugPrint(msg) => {
1648                    if self.config.debug_mode {
1649                        eprintln!("[NyxVm] Guest: {}", msg);
1650                    }
1651                }
1652                ExitReason::Shutdown => {
1653                    return Err(anyhow::anyhow!("VM shut down during boot"));
1654                }
1655                _ => {
1656                    if self.config.debug_mode {
1657                        eprintln!("[NyxVm] Boot exit: {:?}", exit);
1658                    }
1659                    // Continue waiting
1660                }
1661            }
1662        }
1663
1664        // Continue running until snapshot request
1665        loop {
1666            if start.elapsed() > self.config.boot_timeout {
1667                return Err(anyhow::anyhow!("Boot timeout waiting for snapshot request"));
1668            }
1669
1670            let exit = self.vm.run(Duration::from_secs(1));
1671            match exit {
1672                ExitReason::RequestSnapshot => {
1673                    if self.config.debug_mode {
1674                        eprintln!("[NyxVm] Taking base snapshot...");
1675                    }
1676                    self.base_snapshot = Some(self.vm.take_base_snapshot());
1677                    break;
1678                }
1679                ExitReason::DebugPrint(msg) => {
1680                    if self.config.debug_mode {
1681                        eprintln!("[NyxVm] Guest: {}", msg);
1682                    }
1683                }
1684                ExitReason::Shutdown => {
1685                    return Err(anyhow::anyhow!("VM shut down before snapshot"));
1686                }
1687                _ => {
1688                    if self.config.debug_mode {
1689                        eprintln!("[NyxVm] Snapshot wait exit: {:?}", exit);
1690                    }
1691                    // Continue waiting
1692                }
1693            }
1694        }
1695
1696        if self.config.debug_mode {
1697            eprintln!("[NyxVm] Initialization complete");
1698        }
1699
1700        self.initialized = true;
1701        self.needs_reset = false;
1702        Ok(())
1703    }
1704
1705    /// Resets to the base snapshot.
1706    pub fn reset(&mut self) -> anyhow::Result<()> {
1707        let snapshot = self
1708            .base_snapshot
1709            .as_ref()
1710            .ok_or_else(|| anyhow::anyhow!("No base snapshot available"))?
1711            .clone();
1712
1713        self.vm.apply_snapshot(&snapshot);
1714
1715        // Reset trace model if configured
1716        if let Some(trace_cfg) = &self.config.trace
1717            && trace_cfg.reset_on_episode
1718            && let Some(model) = &mut self.trace_model
1719        {
1720            model
1721                .reset()
1722                .map_err(|err| anyhow::anyhow!("failed to reset trace model: {err}"))?;
1723        }
1724
1725        self.step_in_episode = 0;
1726        self.needs_reset = false;
1727
1728        Ok(())
1729    }
1730
1731    /// Writes action data to shared memory.
1732    fn write_action_to_shared_memory(&mut self, payload: &[u8]) -> anyhow::Result<()> {
1733        let vaddr = self
1734            .shared_vaddr
1735            .ok_or_else(|| anyhow::anyhow!("Shared memory not initialized"))?;
1736        let cr3 = self
1737            .shared_cr3
1738            .ok_or_else(|| anyhow::anyhow!("Shared memory CR3 not initialized"))?;
1739        let process = self.vm.process_memory(cr3);
1740
1741        // Ensure guest has cleared the previous message length to avoid races.
1742        let wait_start = Instant::now();
1743        loop {
1744            let cur_len = process
1745                .read_u64(vaddr + SHARED_ACTION_LEN_OFFSET)
1746                .unwrap_or(0);
1747            if cur_len == 0 {
1748                break;
1749            }
1750            if wait_start.elapsed() > self.config.step_timeout {
1751                return Err(anyhow::anyhow!("shared buffer busy (len={cur_len})"));
1752            }
1753            std::thread::yield_now();
1754        }
1755
1756        // Write length as first 8 bytes (u64 LE)
1757        let len = payload.len() as u64;
1758        process
1759            .write_u64(vaddr + SHARED_ACTION_LEN_OFFSET, len)
1760            .map_err(|e| anyhow::anyhow!("write len failed: {e}"))?;
1761        let _ = process.write_u64(vaddr + SHARED_RESP_LEN_OFFSET, 0);
1762
1763        // Write payload starting at offset 8
1764        let max_len = self
1765            .config
1766            .shared_region_size
1767            .saturating_sub(SHARED_PAYLOAD_OFFSET as usize);
1768        let write_len = payload.len().min(max_len);
1769        if write_len > 0 {
1770            let _ = process
1771                .write_bytes(vaddr + SHARED_PAYLOAD_OFFSET, &payload[..write_len])
1772                .map_err(|e| anyhow::anyhow!("write payload failed: {e}"))?;
1773        }
1774
1775        if self.config.debug_mode {
1776            let verify = process
1777                .read_u64(vaddr + SHARED_ACTION_LEN_OFFSET)
1778                .unwrap_or(0) as usize;
1779            eprintln!(
1780                "[NyxVm] Wrote action len={}, verified len={}",
1781                write_len, verify
1782            );
1783        }
1784
1785        Ok(())
1786    }
1787
1788    /// Reads response from shared memory.
1789    fn read_shared_memory(&self) -> Vec<u8> {
1790        let Some(vaddr) = self.shared_vaddr else {
1791            return Vec::new();
1792        };
1793        let Some(cr3) = self.shared_cr3 else {
1794            return Vec::new();
1795        };
1796        let process = self.vm.process_memory(cr3);
1797
1798        // Read length from first 8 bytes
1799        let len = process
1800            .read_u64(vaddr + SHARED_RESP_LEN_OFFSET)
1801            .unwrap_or(0) as usize;
1802        let max_len = self
1803            .config
1804            .shared_region_size
1805            .saturating_sub(SHARED_PAYLOAD_OFFSET as usize);
1806        let read_len = len.min(max_len);
1807
1808        if read_len == 0 {
1809            return Vec::new();
1810        }
1811
1812        let mut buf = vec![0u8; read_len];
1813        let _ = process.read_bytes(vaddr + SHARED_PAYLOAD_OFFSET, &mut buf);
1814        buf
1815    }
1816
1817    fn clear_shared_length(&self) {
1818        let (Some(vaddr), Some(cr3)) = (self.shared_vaddr, self.shared_cr3) else {
1819            return;
1820        };
1821        let process = self.vm.process_memory(cr3);
1822        let _ = process.write_u64(vaddr + SHARED_ACTION_LEN_OFFSET, 0);
1823        let _ = process.write_u64(vaddr + SHARED_RESP_LEN_OFFSET, 0);
1824    }
1825
1826    /// Runs a single step, returning detailed results.
1827    pub fn run_step(&mut self, payload: &[u8]) -> anyhow::Result<NyxStepResult> {
1828        // Write action to shared memory
1829        self.write_action_to_shared_memory(payload)?;
1830
1831        // Run the VM until we get a meaningful exit
1832        let start = Instant::now();
1833        let mut output = Vec::new();
1834        let mut trace_data = Vec::new();
1835        let mut parsed_obs = None;
1836        let mut parsed_rew = None;
1837        let mut done = false;
1838        let exit_kind;
1839        let collect_output =
1840            matches!(
1841                self.config.observation_policy,
1842                NyxObservationPolicy::OutputHash | NyxObservationPolicy::RawOutput
1843            ) || matches!(self.config.reward_policy, NyxRewardPolicy::Pattern { .. })
1844                || matches!(
1845                    self.reward_shaping,
1846                    Some(NyxRewardShaping::EntropyReduction { .. })
1847                );
1848
1849        loop {
1850            let remaining = self
1851                .config
1852                .step_timeout
1853                .checked_sub(start.elapsed())
1854                .unwrap_or(Duration::ZERO);
1855
1856            if remaining.is_zero() {
1857                exit_kind = NyxExitKind::Timeout;
1858                break;
1859            }
1860
1861            let exit = self.vm.run(remaining);
1862            match exit {
1863                ExitReason::ExecDone(code) => {
1864                    exit_kind = NyxExitKind::ExecDone(code);
1865                    done = true;
1866                    break;
1867                }
1868                ExitReason::Timeout => {
1869                    if self.config.debug_mode {
1870                        eprintln!("[NyxVm] Step timeout");
1871                    }
1872                    exit_kind = NyxExitKind::Timeout;
1873                    break;
1874                }
1875                ExitReason::Shutdown => {
1876                    if self.config.debug_mode {
1877                        eprintln!("[NyxVm] VM shutdown during step");
1878                    }
1879                    exit_kind = NyxExitKind::Shutdown;
1880                    done = true;
1881                    break;
1882                }
1883                ExitReason::DebugPrint(msg) => {
1884                    if self.config.debug_mode {
1885                        eprintln!("[NyxVm] Guest: {}", msg);
1886                    }
1887                    // Accumulate debug output
1888                    if collect_output {
1889                        output.extend_from_slice(msg.as_bytes());
1890                    }
1891                    // Continue running
1892                }
1893                ExitReason::Hypercall(r8, r9, r10, r11, r12) => {
1894                    exit_kind = NyxExitKind::Hypercall {
1895                        code: r8,
1896                        arg1: r9,
1897                        arg2: r10,
1898                        arg3: r11,
1899                        arg4: r12,
1900                    };
1901                    // Attempt to parse structured response
1902                    if let Some(obs) = Self::try_parse_u64(r9) {
1903                        parsed_obs = Some(obs);
1904                    }
1905                    if let Some(rew) = Self::try_parse_i64(r10) {
1906                        parsed_rew = Some(rew);
1907                    }
1908                    break;
1909                }
1910                ExitReason::Breakpoint => {
1911                    if self.config.debug_mode {
1912                        eprintln!("[NyxVm] Breakpoint exit during step");
1913                    }
1914                    exit_kind = NyxExitKind::Breakpoint;
1915                    break;
1916                }
1917                _ => {
1918                    // Continue for other exits
1919                }
1920            }
1921        }
1922
1923        // Read shared memory contents (only if needed)
1924        let need_shared_memory = matches!(
1925            self.config.observation_policy,
1926            NyxObservationPolicy::SharedMemory
1927        ) || matches!(
1928            self.config.reward_policy,
1929            NyxRewardPolicy::Pattern { .. }
1930        ) || matches!(
1931            self.reward_shaping,
1932            Some(NyxRewardShaping::EntropyReduction { .. })
1933        ) || self.config.trace.is_some();
1934        let shared_memory = if need_shared_memory {
1935            self.read_shared_memory()
1936        } else {
1937            Vec::new()
1938        };
1939
1940        // Clear shared length to avoid host/guest races on the next step.
1941        self.clear_shared_length();
1942
1943        // Collect trace data if configured
1944        if let Some(trace_cfg) = &self.config.trace
1945            && trace_cfg.shared_region_name.is_some()
1946        {
1947            // Read from trace shared memory region (implementation-specific)
1948            // For now, use main shared memory as fallback
1949            trace_data = shared_memory.clone();
1950            if trace_data.len() > trace_cfg.max_bytes {
1951                trace_data.truncate(trace_cfg.max_bytes);
1952            }
1953        }
1954
1955        Ok(NyxStepResult {
1956            exit_reason: exit_kind,
1957            output,
1958            parsed_obs,
1959            parsed_rew,
1960            done,
1961            trace_data,
1962            shared_memory,
1963        })
1964    }
1965
1966    fn try_parse_u64(val: u64) -> Option<u64> {
1967        // Hypercall args are already u64
1968        Some(val)
1969    }
1970
1971    fn try_parse_i64(val: u64) -> Option<i64> {
1972        Some(val as i64)
1973    }
1974
1975    /// Gets the action payload for the given action index.
1976    fn get_action_payload(&mut self, action: Action) -> anyhow::Result<Cow<'_, [u8]>> {
1977        match &self.config.action_source {
1978            NyxActionSource::Literal(actions) => {
1979                let idx = action as usize;
1980                if idx >= actions.len() {
1981                    return Err(anyhow::anyhow!("Action index out of range"));
1982                }
1983                Ok(Cow::Borrowed(actions[idx].payload.as_slice()))
1984            }
1985            NyxActionSource::Fuzz(fuzz) => {
1986                let state = self
1987                    .fuzz_state
1988                    .as_mut()
1989                    .ok_or_else(|| anyhow::anyhow!("Fuzz state missing"))?;
1990                let idx = action as usize % fuzz.mutators.len();
1991                let mut input = state.current.clone();
1992                let mutator = &fuzz.mutators[idx];
1993                apply_mutator(mutator, &mut input, fuzz, &mut state.rng);
1994                if input.len() < fuzz.min_len {
1995                    input.resize(fuzz.min_len, 0);
1996                }
1997                if input.len() > fuzz.max_len {
1998                    input.truncate(fuzz.max_len);
1999                }
2000                state.current = input.clone();
2001                Ok(Cow::Owned(input))
2002            }
2003        }
2004    }
2005
2006    /// Applies action filtering, returning reject reward if filtered.
2007    fn filter_action(&self, payload: &[u8]) -> anyhow::Result<Option<i64>> {
2008        let Some(filter) = self.config.action_filter.as_ref() else {
2009            return Ok(None);
2010        };
2011        if payload.is_empty() {
2012            return Ok(filter.reject_reward);
2013        }
2014
2015        let (entropy, intrinsic, novelty) = self.compute_filter_metrics(payload, filter)?;
2016
2017        if let Some(min_entropy) = filter.min_entropy
2018            && entropy < min_entropy
2019        {
2020            return Ok(filter.reject_reward);
2021        }
2022        if let Some(max_entropy) = filter.max_entropy
2023            && entropy > max_entropy
2024        {
2025            return Ok(filter.reject_reward);
2026        }
2027        if let Some(min_intrinsic) = filter.min_intrinsic_dependence
2028            && intrinsic < min_intrinsic
2029        {
2030            return Ok(filter.reject_reward);
2031        }
2032        if let Some(min_novelty) = filter.min_novelty
2033            && filter.novelty_prior.is_some()
2034            && novelty < min_novelty
2035        {
2036            return Ok(filter.reject_reward);
2037        }
2038        Ok(None)
2039    }
2040
2041    fn wrap_action_payload(&self, payload: &[u8]) -> Vec<u8> {
2042        let p = &self.config.protocol;
2043        let mut wrapped = p.action_prefix.clone().into_bytes();
2044        wrapped.extend_from_slice(p.wire_encoding.encode(payload).as_bytes());
2045        wrapped.extend_from_slice(p.action_suffix.as_bytes());
2046        wrapped
2047    }
2048
2049    fn compute_filter_metrics(
2050        &self,
2051        payload: &[u8],
2052        filter: &NyxActionFilter,
2053    ) -> anyhow::Result<(f64, f64, f64)> {
2054        let h_marg = empirical_entropy_bytes(payload);
2055        let h_rate =
2056            try_entropy_rate_backend(payload, &self.compiled_stats_backend).map_err(|err| {
2057                anyhow::anyhow!("vm stats backend failed to score payload entropy: {err}")
2058            })?;
2059
2060        let intrinsic = if h_marg < 1e-9 {
2061            0.0
2062        } else {
2063            ((h_marg - h_rate) / h_marg).clamp(0.0, 1.0)
2064        };
2065
2066        let novelty = if let Some(ref prior) = filter.novelty_prior {
2067            try_cross_entropy_rate_backend(payload, prior, &self.compiled_stats_backend)
2068                .map_err(|err| anyhow::anyhow!("vm stats backend failed to score novelty: {err}"))?
2069        } else {
2070            0.0
2071        };
2072
2073        Ok((h_rate, intrinsic, novelty))
2074    }
2075
2076    /// Computes reward from step result.
2077    fn compute_reward(&mut self, result: &NyxStepResult) -> anyhow::Result<Reward> {
2078        let base_reward = match &self.config.reward_policy {
2079            NyxRewardPolicy::FromGuest => result.parsed_rew.unwrap_or(0),
2080            NyxRewardPolicy::Pattern {
2081                pattern,
2082                base_reward,
2083                bonus_reward,
2084            } => {
2085                let text = String::from_utf8_lossy(&result.output);
2086                let shared_text = String::from_utf8_lossy(&result.shared_memory);
2087                if text.contains(pattern) || shared_text.contains(pattern) {
2088                    base_reward + bonus_reward
2089                } else {
2090                    *base_reward
2091                }
2092            }
2093            NyxRewardPolicy::Custom(f) => f(result),
2094        };
2095
2096        let shaping_reward = if let Some(shaping) = self.reward_shaping.clone() {
2097            self.compute_reward_shaping(&shaping, result)?
2098        } else {
2099            0
2100        };
2101
2102        let mut reward = base_reward.saturating_add(shaping_reward);
2103
2104        reward = reward.saturating_sub(self.config.step_cost);
2105        let min_reward = self.min_reward();
2106        let max_reward = self.max_reward();
2107        Ok(reward.clamp(min_reward, max_reward))
2108    }
2109
2110    fn compute_reward_shaping(
2111        &mut self,
2112        shaping: &NyxRewardShaping,
2113        result: &NyxStepResult,
2114    ) -> anyhow::Result<Reward> {
2115        Ok(match shaping {
2116            NyxRewardShaping::EntropyReduction {
2117                scale,
2118                crash_bonus,
2119                timeout_bonus,
2120                ..
2121            } => {
2122                let mut base_reward = {
2123                    let data = if result.shared_memory.is_empty() {
2124                        &result.output
2125                    } else {
2126                        &result.shared_memory
2127                    };
2128                    let h_obs = try_entropy_rate_backend(data, &self.compiled_stats_backend)
2129                        .map_err(|err| {
2130                            anyhow::anyhow!(
2131                                "vm stats backend failed to score observation entropy: {err}"
2132                            )
2133                        })?;
2134                    let h_base = self.baseline_entropy.unwrap_or(0.0);
2135                    let er = (h_base - h_obs) * scale;
2136                    er.round() as i64
2137                };
2138
2139                // Add bonuses for interesting behaviors (bugs/crashes)
2140                match &result.exit_reason {
2141                    NyxExitKind::Shutdown | NyxExitKind::Breakpoint => {
2142                        if let Some(bonus) = crash_bonus {
2143                            base_reward = base_reward.saturating_add(*bonus);
2144                        }
2145                    }
2146                    NyxExitKind::Timeout => {
2147                        if let Some(bonus) = timeout_bonus {
2148                            base_reward = base_reward.saturating_add(*bonus);
2149                        }
2150                    }
2151                    _ => {}
2152                }
2153
2154                base_reward
2155            }
2156            NyxRewardShaping::TraceEntropy {
2157                scale, normalize, ..
2158            } => {
2159                let data = &result.trace_data;
2160                let bits = match self.trace_model.as_mut() {
2161                    Some(model) => model.update_and_score(data),
2162                    None => 0.0,
2163                };
2164                let bits = if *normalize && !data.is_empty() {
2165                    bits / data.len() as f64
2166                } else {
2167                    bits
2168                };
2169                (bits * scale).round() as i64
2170            }
2171        })
2172    }
2173
2174    fn mask_observation(&self, value: u64) -> u64 {
2175        let bits = self.config.observation_bits;
2176        if bits >= 64 {
2177            value
2178        } else if bits == 0 {
2179            0
2180        } else {
2181            value & ((1u64 << bits) - 1)
2182        }
2183    }
2184
2185    fn build_observation_stream(&self, result: &NyxStepResult) -> Vec<PerceptVal> {
2186        let mut observations = match self.config.observation_policy {
2187            NyxObservationPolicy::FromGuest => {
2188                if let Some(obs) = result.parsed_obs {
2189                    vec![self.mask_observation(obs)]
2190                } else {
2191                    vec![self.hash_observation(&result.shared_memory)]
2192                }
2193            }
2194            NyxObservationPolicy::OutputHash => {
2195                vec![self.hash_observation(&result.output)]
2196            }
2197            NyxObservationPolicy::RawOutput => {
2198                result.output.iter().map(|b| *b as PerceptVal).collect()
2199            }
2200            NyxObservationPolicy::SharedMemory => result
2201                .shared_memory
2202                .iter()
2203                .map(|b| *b as PerceptVal)
2204                .collect(),
2205        };
2206
2207        if observations.is_empty() {
2208            observations.push(0);
2209        }
2210
2211        self.normalize_observation_stream(&mut observations);
2212        observations
2213    }
2214
2215    fn hash_observation(&self, data: &[u8]) -> PerceptVal {
2216        let h = robust_hash_bytes(data);
2217        self.mask_observation(h)
2218    }
2219
2220    fn normalize_observation_stream(&self, observations: &mut Vec<PerceptVal>) {
2221        let mask = if self.config.observation_bits >= 64 {
2222            u64::MAX
2223        } else if self.config.observation_bits == 0 {
2224            0
2225        } else {
2226            (1u64 << self.config.observation_bits) - 1
2227        };
2228
2229        for obs in observations.iter_mut() {
2230            *obs &= mask;
2231        }
2232
2233        let target = self.config.observation_stream_len;
2234        if target == 0 {
2235            return;
2236        }
2237
2238        if observations.len() > target {
2239            match self.config.observation_stream_mode {
2240                NyxObservationStreamMode::Truncate | NyxObservationStreamMode::PadTruncate => {
2241                    observations.truncate(target);
2242                }
2243                NyxObservationStreamMode::Pad => {}
2244            }
2245        } else if observations.len() < target {
2246            match self.config.observation_stream_mode {
2247                NyxObservationStreamMode::Pad | NyxObservationStreamMode::PadTruncate => {
2248                    let pad = self.config.observation_pad_byte as PerceptVal;
2249                    observations.resize(target, pad);
2250                }
2251                NyxObservationStreamMode::Truncate => {}
2252            }
2253        }
2254    }
2255
2256    fn action_count(&self) -> usize {
2257        match &self.config.action_source {
2258            NyxActionSource::Literal(actions) => actions.len(),
2259            NyxActionSource::Fuzz(fuzz) => fuzz.mutators.len(),
2260        }
2261    }
2262
2263    /// Direct access to the underlying NyxVM for advanced use cases.
2264    pub fn vm(&self) -> &NyxVM {
2265        &self.vm
2266    }
2267
2268    /// Mutable access to the underlying NyxVM.
2269    pub fn vm_mut(&mut self) -> &mut NyxVM {
2270        &mut self.vm
2271    }
2272
2273    /// Takes a new snapshot at the current state.
2274    pub fn take_snapshot(&mut self) -> Arc<NyxSnapshot> {
2275        self.vm.take_snapshot()
2276    }
2277
2278    /// Applies a specific snapshot.
2279    pub fn apply_snapshot(&mut self, snapshot: &Arc<NyxSnapshot>) {
2280        self.vm.apply_snapshot(snapshot);
2281    }
2282
2283    /// Resets trace model.
2284    pub fn reset_trace_model(&mut self) -> anyhow::Result<()> {
2285        if let Some(model) = &mut self.trace_model {
2286            model
2287                .reset()
2288                .map_err(|err| anyhow::anyhow!("failed to reset trace model: {err}"))?;
2289        }
2290        Ok(())
2291    }
2292
2293    /// Logs crashes and interesting behaviors to file.
2294    fn log_crash(&self, action_payload: &[u8], result: &NyxStepResult, reward: i64) {
2295        let Some(log_path) = &self.config.crash_log else {
2296            return;
2297        };
2298
2299        // Only log interesting exits
2300        let is_interesting = matches!(
2301            result.exit_reason,
2302            NyxExitKind::Shutdown | NyxExitKind::Breakpoint | NyxExitKind::Timeout
2303        );
2304
2305        if !is_interesting {
2306            return;
2307        }
2308
2309        let log_entry = serde_json::json!({
2310            "timestamp": std::time::SystemTime::now()
2311                .duration_since(std::time::UNIX_EPOCH)
2312                .unwrap_or_default()
2313                .as_secs(),
2314            "exit_reason": format!("{:?}", result.exit_reason),
2315            "action_payload": hex_encode(action_payload),
2316            "action_payload_str": String::from_utf8_lossy(action_payload),
2317            "output": String::from_utf8_lossy(&result.output),
2318            "shared_memory": hex_encode(&result.shared_memory),
2319            "reward": reward,
2320            "parsed_obs": result.parsed_obs,
2321            "parsed_rew": result.parsed_rew,
2322        });
2323
2324        // Append to JSONL file
2325        if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path)
2326            && let Ok(json_str) = serde_json::to_string(&log_entry)
2327        {
2328            let _ = writeln!(file, "{}", json_str);
2329        }
2330    }
2331}
2332
2333// ============================================================================
2334// Environment Trait Implementation
2335// ============================================================================
2336
2337impl Environment for NyxVmEnvironment {
2338    fn perform_action(&mut self, action: Action) {
2339        if self.needs_reset
2340            && let Err(e) = self.reset()
2341            && self.config.debug_mode
2342        {
2343            eprintln!("[NyxVm] Reset failed: {}", e);
2344        }
2345
2346        let payload = match self.get_action_payload(action) {
2347            Ok(payload) => payload.into_owned(),
2348            Err(e) => {
2349                if self.config.debug_mode {
2350                    eprintln!("[NyxVm] Invalid action: {}", e);
2351                }
2352                self.obs = 0;
2353                self.rew = self.min_reward();
2354                self.obs_stream.clear();
2355                self.obs_stream.push(0);
2356                self.step_in_episode = (self.step_in_episode + 1) % self.config.episode_steps;
2357                if self.step_in_episode == 0 {
2358                    self.needs_reset = true;
2359                }
2360                return;
2361            }
2362        };
2363
2364        // Check action filter
2365        match self.filter_action(&payload) {
2366            Ok(Some(reject_reward)) => {
2367                self.obs = 0;
2368                self.rew = reject_reward.clamp(self.min_reward(), self.max_reward());
2369                self.obs_stream.clear();
2370                self.obs_stream.push(0);
2371                self.step_in_episode = (self.step_in_episode + 1) % self.config.episode_steps;
2372                if self.step_in_episode == 0 {
2373                    self.needs_reset = true;
2374                }
2375                return;
2376            }
2377            Ok(None) => {}
2378            Err(e) => {
2379                if self.config.debug_mode {
2380                    eprintln!("[NyxVm] Action filter scoring failed: {}", e);
2381                }
2382                self.obs = 0;
2383                self.rew = self.min_reward();
2384                self.obs_stream.clear();
2385                self.obs_stream.push(0);
2386                self.step_in_episode = (self.step_in_episode + 1) % self.config.episode_steps;
2387                if self.step_in_episode == 0 {
2388                    self.needs_reset = true;
2389                }
2390                return;
2391            }
2392        }
2393
2394        // Run the step
2395        let wrapped_payload = self.wrap_action_payload(&payload);
2396        let result = match self.run_step(&wrapped_payload) {
2397            Ok(result) => result,
2398            Err(e) => {
2399                if self.config.debug_mode {
2400                    eprintln!("[NyxVm] Step failed: {}", e);
2401                }
2402                self.obs = 0;
2403                self.rew = self.min_reward();
2404                self.obs_stream.clear();
2405                self.obs_stream.push(0);
2406                self.step_in_episode = (self.step_in_episode + 1) % self.config.episode_steps;
2407                if self.step_in_episode == 0 {
2408                    self.needs_reset = true;
2409                }
2410                return;
2411            }
2412        };
2413
2414        // Process results
2415        self.obs_stream = self.build_observation_stream(&result);
2416        self.obs = self.obs_stream.first().copied().unwrap_or(0);
2417        self.rew = match self.compute_reward(&result) {
2418            Ok(reward) => reward,
2419            Err(e) => {
2420                if self.config.debug_mode {
2421                    eprintln!("[NyxVm] Reward computation failed: {}", e);
2422                }
2423                self.min_reward()
2424            }
2425        };
2426
2427        // Log crashes and interesting behaviors
2428        self.log_crash(&payload, &result, self.rew);
2429
2430        if self.config.debug_mode {
2431            eprintln!(
2432                "[NyxVm] Action={} Obs={} Rew={} Done={:?} Exit={:?}",
2433                action, self.obs, self.rew, result.done, result.exit_reason
2434            );
2435        }
2436
2437        self.step_in_episode = (self.step_in_episode + 1) % self.config.episode_steps;
2438        if self.step_in_episode == 0 || result.done {
2439            self.needs_reset = true;
2440        }
2441    }
2442
2443    fn get_observation(&self) -> PerceptVal {
2444        self.obs
2445    }
2446
2447    fn drain_observations(&mut self) -> Vec<PerceptVal> {
2448        if self.obs_stream.is_empty() {
2449            vec![self.obs]
2450        } else {
2451            std::mem::take(&mut self.obs_stream)
2452        }
2453    }
2454
2455    fn get_reward(&self) -> Reward {
2456        self.rew
2457    }
2458
2459    fn is_finished(&self) -> bool {
2460        false
2461    }
2462
2463    fn get_observation_bits(&self) -> usize {
2464        self.config.observation_bits
2465    }
2466
2467    fn get_reward_bits(&self) -> usize {
2468        self.config.reward_bits
2469    }
2470
2471    fn get_action_bits(&self) -> usize {
2472        let n = self.action_count();
2473        if n <= 1 {
2474            return 1;
2475        }
2476        (n as f64).log2().ceil() as usize
2477    }
2478
2479    fn get_num_actions(&self) -> ActionAlphabet {
2480        ActionAlphabet::try_from_usize(self.action_count())
2481            .expect("vm environment must expose a non-empty action alphabet")
2482    }
2483
2484    fn max_reward(&self) -> Reward {
2485        let bits = self.config.reward_bits;
2486        if bits >= 64 {
2487            i64::MAX
2488        } else if bits == 0 {
2489            0
2490        } else {
2491            (1i64 << (bits - 1)) - 1
2492        }
2493    }
2494
2495    fn min_reward(&self) -> Reward {
2496        let bits = self.config.reward_bits;
2497        if bits >= 64 {
2498            i64::MIN
2499        } else if bits == 0 {
2500            0
2501        } else {
2502            -(1i64 << (bits - 1))
2503        }
2504    }
2505}
2506
2507// ============================================================================
2508// Helper Functions
2509// ============================================================================
2510
2511fn robust_hash_bytes(data: &[u8]) -> u64 {
2512    let mut h = 0u64;
2513    for &b in data {
2514        h = h.rotate_left(7) ^ (b as u64);
2515    }
2516    h
2517}
2518
2519fn apply_mutator(
2520    mutator: &FuzzMutator,
2521    input: &mut Vec<u8>,
2522    fuzz: &NyxFuzzConfig,
2523    rng: &mut RandomGenerator,
2524) {
2525    match mutator {
2526        FuzzMutator::FlipBit => {
2527            if input.is_empty() {
2528                input.push(0);
2529            }
2530            let idx = rng.gen_range(input.len());
2531            let bit = rng.gen_range(8);
2532            input[idx] ^= 1u8 << bit;
2533        }
2534        FuzzMutator::FlipByte => {
2535            if input.is_empty() {
2536                input.push(0);
2537            }
2538            let idx = rng.gen_range(input.len());
2539            input[idx] ^= rng.next_u64() as u8;
2540        }
2541        FuzzMutator::InsertByte => {
2542            let idx = if input.is_empty() {
2543                0
2544            } else {
2545                rng.gen_range(input.len() + 1)
2546            };
2547            let byte = if !fuzz.dictionary.is_empty() {
2548                let d = rng.gen_range(fuzz.dictionary.len());
2549                let entry = &fuzz.dictionary[d];
2550                if entry.is_empty() {
2551                    0
2552                } else {
2553                    entry[rng.gen_range(entry.len())]
2554                }
2555            } else {
2556                rng.next_u64() as u8
2557            };
2558            input.insert(idx, byte);
2559        }
2560        FuzzMutator::DeleteByte => {
2561            if input.len() > 1 {
2562                let idx = rng.gen_range(input.len());
2563                input.remove(idx);
2564            }
2565        }
2566        FuzzMutator::SpliceSeed => {
2567            if fuzz.seeds.is_empty() {
2568                return;
2569            }
2570            let seed = &fuzz.seeds[rng.gen_range(fuzz.seeds.len())];
2571            if input.is_empty() {
2572                input.extend_from_slice(seed);
2573            } else if !seed.is_empty() {
2574                let cut = rng.gen_range(input.len());
2575                let seed_cut = rng.gen_range(seed.len());
2576                let mut out = Vec::new();
2577                out.extend_from_slice(&input[..cut]);
2578                out.extend_from_slice(&seed[seed_cut..]);
2579                *input = out;
2580            }
2581        }
2582        FuzzMutator::ResetSeed => {
2583            if fuzz.seeds.is_empty() {
2584                return;
2585            }
2586            *input = fuzz.seeds[rng.gen_range(fuzz.seeds.len())].clone();
2587        }
2588        FuzzMutator::Havoc => {
2589            let flips = 1 + rng.gen_range(8);
2590            for _ in 0..flips {
2591                if input.is_empty() {
2592                    input.push(0);
2593                }
2594                let idx = rng.gen_range(input.len());
2595                input[idx] ^= rng.next_u64() as u8;
2596            }
2597        }
2598    }
2599}
2600
2601// ============================================================================
2602// Tests
2603// ============================================================================
2604
2605#[cfg(test)]
2606mod tests {
2607    use super::*;
2608
2609    #[test]
2610    fn test_hex_encoding() {
2611        let data = b"hello";
2612        let encoded = hex_encode(data);
2613        assert_eq!(encoded, "68656c6c6f");
2614        let decoded = hex_decode(&encoded).unwrap();
2615        assert_eq!(decoded, data);
2616    }
2617
2618    #[test]
2619    fn test_robust_hash() {
2620        let data1 = b"test data";
2621        let data2 = b"test data";
2622        let data3 = b"different";
2623
2624        assert_eq!(robust_hash_bytes(data1), robust_hash_bytes(data2));
2625        assert_ne!(robust_hash_bytes(data1), robust_hash_bytes(data3));
2626    }
2627
2628    #[test]
2629    fn test_payload_encoding() {
2630        let utf8 = PayloadEncoding::Utf8;
2631        let hex = PayloadEncoding::Hex;
2632
2633        let data = b"test";
2634        assert_eq!(utf8.encode(data), "test");
2635        assert_eq!(hex.encode(data), "74657374");
2636
2637        assert_eq!(utf8.decode("test").unwrap(), data);
2638        assert_eq!(hex.decode("74657374").unwrap(), data);
2639    }
2640
2641    fn fuzz_cfg_with_seed(rng_seed: u64) -> NyxFuzzConfig {
2642        NyxFuzzConfig {
2643            seeds: vec![b"seed-alpha".to_vec(), b"seed-beta".to_vec()],
2644            mutators: vec![
2645                FuzzMutator::FlipBit,
2646                FuzzMutator::FlipByte,
2647                FuzzMutator::InsertByte,
2648                FuzzMutator::DeleteByte,
2649                FuzzMutator::SpliceSeed,
2650                FuzzMutator::ResetSeed,
2651                FuzzMutator::Havoc,
2652            ],
2653            min_len: 1,
2654            max_len: 32,
2655            dictionary: vec![b"DICT".to_vec(), b"TOK".to_vec()],
2656            rng_seed,
2657        }
2658    }
2659
2660    fn fuzz_payload_sequence(config: &NyxFuzzConfig, steps: usize) -> Vec<Vec<u8>> {
2661        let mut current = config.seeds[0].clone();
2662        let mut rng = RandomGenerator::from_seed(config.rng_seed);
2663        let mut out = Vec::with_capacity(steps);
2664        for _ in 0..steps {
2665            let mut input = current.clone();
2666            let idx = rng.gen_range(config.mutators.len());
2667            let mutator = &config.mutators[idx];
2668            apply_mutator(mutator, &mut input, config, &mut rng);
2669            current = input.clone();
2670            out.push(input);
2671        }
2672        out
2673    }
2674
2675    #[test]
2676    fn fuzz_mutation_sequence_is_reproducible_for_identical_rng_seed() {
2677        let a = fuzz_payload_sequence(&fuzz_cfg_with_seed(77), 64);
2678        let b = fuzz_payload_sequence(&fuzz_cfg_with_seed(77), 64);
2679        assert_eq!(a, b);
2680    }
2681
2682    #[test]
2683    fn fuzz_mutation_sequence_changes_for_different_rng_seed() {
2684        let a = fuzz_payload_sequence(&fuzz_cfg_with_seed(77), 64);
2685        let b = fuzz_payload_sequence(&fuzz_cfg_with_seed(78), 64);
2686        assert_ne!(a, b);
2687    }
2688
2689    #[cfg(feature = "vm")]
2690    #[test]
2691    fn validate_allows_custom_reward_callbacks_for_runtime_configs() {
2692        let mut config = NyxVmConfig::default();
2693        config.firecracker_config = "dummy-firecracker.json".to_string();
2694        config.reward_policy = NyxRewardPolicy::Custom(Arc::new(|_| 0));
2695        config
2696            .validate()
2697            .expect("custom reward callbacks should remain valid for direct runtime configs");
2698    }
2699
2700    #[cfg(feature = "vm")]
2701    #[test]
2702    fn validate_canonical_spec_compatibility_rejects_custom_reward_callbacks() {
2703        let mut config = NyxVmConfig::default();
2704        config.firecracker_config = "dummy-firecracker.json".to_string();
2705        config.reward_policy = NyxRewardPolicy::Custom(Arc::new(|_| 0));
2706        let err = config
2707            .validate_canonical_spec_compatibility()
2708            .expect_err("custom reward callbacks are not canonical");
2709        assert!(matches!(
2710            err,
2711            InfotheoryError::InvalidBackendConfig(message)
2712                if message.contains("not representable in canonical specs")
2713        ));
2714    }
2715
2716    #[cfg(feature = "vm")]
2717    #[test]
2718    fn from_environment_spec_builds_runtime_vm_config_without_legacy_json() {
2719        use std::time::{SystemTime, UNIX_EPOCH};
2720
2721        let nanos = SystemTime::now()
2722            .duration_since(UNIX_EPOCH)
2723            .map(|duration| duration.as_nanos())
2724            .unwrap_or(0);
2725        let root = std::env::temp_dir().join(format!(
2726            "infotheory-vm-spec-runtime-{}-{nanos}",
2727            std::process::id()
2728        ));
2729        std::fs::create_dir_all(&root).expect("temp dir");
2730
2731        let firecracker_path = root.join("firecracker.json");
2732        let baseline_path = root.join("baseline.bin");
2733        let novelty_path = root.join("novelty.bin");
2734        std::fs::write(&firecracker_path, b"{\"boot-source\":{}}").expect("firecracker config");
2735        std::fs::write(&baseline_path, b"baseline-bytes").expect("baseline asset");
2736        std::fs::write(&novelty_path, b"novelty-bytes").expect("novelty asset");
2737
2738        let spec = VmEnvironmentSpec {
2739            firecracker_config_asset: "firecracker".to_string(),
2740            instance_id: "vm-test".to_string(),
2741            shared_region_name: "shared".to_string(),
2742            shared_region_size: 4096,
2743            shared_memory_policy: SharedMemoryPolicySpec::Snapshot,
2744            step_timeout_ms: 125,
2745            boot_timeout_ms: 1_250,
2746            episode_steps: 8,
2747            step_cost: -1,
2748            observation_policy: VmObservationPolicySpec::OutputHash,
2749            observation_bits: 8,
2750            observation_stream_len: 16,
2751            observation_stream_mode: VmObservationStreamModeSpec::PadTruncate,
2752            observation_pad_byte: 0x7f,
2753            reward_bits: 8,
2754            reward_policy: VmRewardPolicySpec::Pattern {
2755                pattern: "win".to_string(),
2756                base_reward: 1,
2757                bonus_reward: 4,
2758            },
2759            reward_shaping: Some(VmRewardShapingSpec::EntropyReduction {
2760                baseline_asset: "baseline".to_string(),
2761                scale: 0.25,
2762                crash_bonus: Some(5),
2763                timeout_bonus: Some(6),
2764            }),
2765            action_source: VmRuntimeActionSourceSpec::Literal {
2766                names: vec![Some("hi".to_string())],
2767                payloads: vec!["6869".to_string()],
2768                encoding: VmPayloadEncodingSpec::Hex,
2769            },
2770            action_filter: Some(VmActionFilterSpec {
2771                min_entropy: Some(0.1),
2772                max_entropy: Some(2.0),
2773                min_intrinsic_dependence: Some(0.05),
2774                min_novelty: Some(0.2),
2775                novelty_prior_asset: Some("novelty".to_string()),
2776                reject_reward: Some(-3),
2777            }),
2778            action_prefix: "ACT ".to_string(),
2779            action_suffix: "\n".to_string(),
2780            obs_prefix: "OBS ".to_string(),
2781            rew_prefix: "REW ".to_string(),
2782            done_prefix: "DONE ".to_string(),
2783            data_prefix: "DATA ".to_string(),
2784            wire_encoding: VmPayloadEncodingSpec::Utf8,
2785            stats_backend: RateBackend::Ctw { depth: 8 },
2786            trace: Some(VmTraceSpec {
2787                shared_region_name: Some("trace".to_string()),
2788                max_bytes: 256,
2789                reset_on_episode: true,
2790            }),
2791            debug_mode: true,
2792            crash_log: Some("/tmp/vm-crash.jsonl".to_string()),
2793        };
2794        let assets = vec![
2795            ResolvedAssetBinding {
2796                id: "firecracker".to_string(),
2797                asset: AssetRef::Filesystem(firecracker_path.clone()),
2798            },
2799            ResolvedAssetBinding {
2800                id: "baseline".to_string(),
2801                asset: AssetRef::Filesystem(baseline_path.clone()),
2802            },
2803            ResolvedAssetBinding {
2804                id: "novelty".to_string(),
2805                asset: AssetRef::Filesystem(novelty_path.clone()),
2806            },
2807        ];
2808
2809        let config = NyxVmConfig::from_environment_spec(&spec, &assets)
2810            .expect("canonical VM spec should build runtime config");
2811
2812        assert_eq!(
2813            config.firecracker_config,
2814            firecracker_path.display().to_string()
2815        );
2816        assert_eq!(config.crash_log.as_deref(), Some("/tmp/vm-crash.jsonl"));
2817        assert!(matches!(
2818            config.observation_policy,
2819            NyxObservationPolicy::OutputHash
2820        ));
2821        assert!(matches!(
2822            config.observation_stream_mode,
2823            NyxObservationStreamMode::PadTruncate
2824        ));
2825        assert!(matches!(
2826            config.reward_policy,
2827            NyxRewardPolicy::Pattern {
2828                ref pattern,
2829                base_reward: 1,
2830                bonus_reward: 4,
2831            } if pattern == "win"
2832        ));
2833        assert!(matches!(
2834            config.protocol.wire_encoding,
2835            PayloadEncoding::Utf8
2836        ));
2837        assert!(matches!(
2838            config.stats_backend,
2839            RateBackend::Ctw { depth: 8 }
2840        ));
2841        match &config.action_source {
2842            NyxActionSource::Literal(actions) => {
2843                assert_eq!(actions.len(), 1);
2844                assert_eq!(actions[0].name.as_deref(), Some("hi"));
2845                assert_eq!(actions[0].payload, b"hi");
2846            }
2847            other => panic!("expected literal actions, got {other:?}"),
2848        }
2849        match &config.reward_shaping {
2850            Some(NyxRewardShaping::EntropyReduction { baseline_bytes, .. }) => {
2851                assert_eq!(baseline_bytes, b"baseline-bytes");
2852            }
2853            other => panic!("expected entropy-reduction shaping, got {other:?}"),
2854        }
2855        match &config.action_filter {
2856            Some(filter) => {
2857                assert_eq!(filter.novelty_prior.as_deref(), Some(&b"novelty-bytes"[..]));
2858            }
2859            None => panic!("expected action filter"),
2860        }
2861
2862        let _ = std::fs::remove_file(firecracker_path);
2863        let _ = std::fs::remove_file(baseline_path);
2864        let _ = std::fs::remove_file(novelty_path);
2865        let _ = std::fs::remove_dir(root);
2866    }
2867
2868    #[cfg(feature = "vm")]
2869    #[test]
2870    fn from_environment_spec_accepts_vm_alias_names() {
2871        use std::time::{SystemTime, UNIX_EPOCH};
2872
2873        let nanos = SystemTime::now()
2874            .duration_since(UNIX_EPOCH)
2875            .map(|duration| duration.as_nanos())
2876            .unwrap_or(0);
2877        let root = std::env::temp_dir().join(format!(
2878            "infotheory-vm-spec-aliases-{}-{nanos}",
2879            std::process::id()
2880        ));
2881        std::fs::create_dir_all(&root).expect("temp dir");
2882
2883        let firecracker_path = root.join("firecracker.json");
2884        std::fs::write(&firecracker_path, b"{\"boot-source\":{}}").expect("firecracker config");
2885
2886        let spec = VmEnvironmentSpec {
2887            firecracker_config_asset: "firecracker".to_string(),
2888            instance_id: "vm-test".to_string(),
2889            shared_region_name: "shared".to_string(),
2890            shared_region_size: 4096,
2891            shared_memory_policy: SharedMemoryPolicySpec::Snapshot,
2892            step_timeout_ms: 125,
2893            boot_timeout_ms: 1_250,
2894            episode_steps: 8,
2895            step_cost: -1,
2896            observation_policy: VmObservationPolicySpec::OutputHash,
2897            observation_bits: 8,
2898            observation_stream_len: 16,
2899            observation_stream_mode: VmObservationStreamModeSpec::PadTruncate,
2900            observation_pad_byte: 0x00,
2901            reward_bits: 8,
2902            reward_policy: VmRewardPolicySpec::FromGuest,
2903            reward_shaping: None,
2904            action_source: VmRuntimeActionSourceSpec::Fuzz {
2905                seeds: vec!["seed".to_string()],
2906                encoding: VmPayloadEncodingSpec::Utf8,
2907                mutators: vec![VmFuzzMutatorSpec::FlipBit, VmFuzzMutatorSpec::SpliceSeed],
2908                min_len: 1,
2909                max_len: 8,
2910                dictionary: vec!["dict".to_string()],
2911                rng_seed: 7,
2912            },
2913            action_filter: None,
2914            action_prefix: "ACT ".to_string(),
2915            action_suffix: "\n".to_string(),
2916            obs_prefix: "OBS ".to_string(),
2917            rew_prefix: "REW ".to_string(),
2918            done_prefix: "DONE ".to_string(),
2919            data_prefix: "DATA ".to_string(),
2920            wire_encoding: VmPayloadEncodingSpec::Utf8,
2921            stats_backend: RateBackend::Ctw { depth: 8 },
2922            trace: None,
2923            debug_mode: false,
2924            crash_log: None,
2925        };
2926        let assets = vec![ResolvedAssetBinding {
2927            id: "firecracker".to_string(),
2928            asset: AssetRef::Filesystem(firecracker_path.clone()),
2929        }];
2930
2931        let config =
2932            NyxVmConfig::from_environment_spec(&spec, &assets).expect("aliases should parse");
2933        assert!(matches!(
2934            config.observation_policy,
2935            NyxObservationPolicy::OutputHash
2936        ));
2937        assert!(matches!(
2938            config.observation_stream_mode,
2939            NyxObservationStreamMode::PadTruncate
2940        ));
2941        assert!(matches!(
2942            config.protocol.wire_encoding,
2943            PayloadEncoding::Utf8
2944        ));
2945        match &config.action_source {
2946            NyxActionSource::Fuzz(fuzz) => {
2947                assert_eq!(fuzz.seeds, vec![b"seed".to_vec()]);
2948                assert!(matches!(fuzz.mutators[0], FuzzMutator::FlipBit));
2949                assert!(matches!(fuzz.mutators[1], FuzzMutator::SpliceSeed));
2950            }
2951            other => panic!("expected fuzz action source, got {other:?}"),
2952        }
2953
2954        let _ = std::fs::remove_file(firecracker_path);
2955        let _ = std::fs::remove_dir(root);
2956    }
2957
2958    #[cfg(feature = "all-backends")]
2959    #[test]
2960    fn trace_model_supports_predictor_backed_backends() {
2961        use crate::api::{
2962            CalibratedSpec, CalibrationContextKind, MixtureExpertSpec, MixtureKind, MixtureSpec,
2963            ParticleSpec,
2964        };
2965
2966        let backends = vec![
2967            RateBackend::Match {
2968                hash_bits: 20,
2969                min_len: 4,
2970                max_len: 255,
2971                base_mix: 0.02,
2972                confidence_scale: 1.0,
2973            },
2974            RateBackend::SparseMatch {
2975                hash_bits: 19,
2976                min_len: 3,
2977                max_len: 64,
2978                gap_min: 1,
2979                gap_max: 2,
2980                base_mix: 0.05,
2981                confidence_scale: 1.0,
2982            },
2983            RateBackend::Ppmd {
2984                order: 8,
2985                memory_mb: 8,
2986            },
2987            RateBackend::Calibrated {
2988                spec: Arc::new(CalibratedSpec {
2989                    base: RateBackend::Ctw { depth: 8 },
2990                    context: CalibrationContextKind::Text,
2991                    bins: 33,
2992                    learning_rate: 0.02,
2993                    bias_clip: 4.0,
2994                }),
2995            },
2996            RateBackend::Particle {
2997                spec: Arc::new(ParticleSpec {
2998                    num_particles: 4,
2999                    num_cells: 4,
3000                    cell_dim: 8,
3001                    ..ParticleSpec::default()
3002                }),
3003            },
3004            RateBackend::Mixture {
3005                spec: Arc::new(MixtureSpec::new(
3006                    MixtureKind::Bayes,
3007                    vec![MixtureExpertSpec {
3008                        name: Some("ctw".to_string()),
3009                        log_prior: 0.0,
3010                        backend: RateBackend::Ctw { depth: 8 },
3011                    }],
3012                )),
3013            },
3014        ];
3015
3016        for backend in backends {
3017            let compiled = backend.compile().expect("compiled trace backend");
3018            let mut model = TraceModel::new(&compiled).expect("trace model should initialize");
3019            let bits = model.update_and_score(b"trace payload");
3020            assert!(bits.is_finite() && bits >= 0.0, "bits={bits}");
3021            model.reset().expect("trace model should reset");
3022            let bits_after_reset = model.update_and_score(b"trace payload");
3023            assert!(
3024                bits_after_reset.is_finite() && bits_after_reset >= 0.0,
3025                "bits_after_reset={bits_after_reset}"
3026            );
3027        }
3028    }
3029
3030    /// Scores `data` against an existing [`FacContextTree`] using the same
3031    /// bit-extraction logic as `TraceModel::FacCtw::update_and_score`, then
3032    /// returns the surprise in bits (negative log-prob delta / ln 2).
3033    ///
3034    /// The tree is mutated (updated) exactly as `update_and_score` would do,
3035    /// so callers can chain multiple calls on the same tree to simulate
3036    /// the VM's incremental scoring pattern.
3037    #[cfg(feature = "backend-ctw")]
3038    fn fac_ctw_oracle_score_on_tree(
3039        tree: &mut crate::backends::ctw::FacContextTree,
3040        bits_per_symbol: usize,
3041        msb_first: bool,
3042        data: &[u8],
3043    ) -> f64 {
3044        use crate::backends::ctw::ctw_symbol_bit_msb;
3045        let log_before = tree.get_log_block_probability();
3046        for &b in data {
3047            for i in 0..bits_per_symbol {
3048                let bit = if msb_first {
3049                    ctw_symbol_bit_msb(b, bits_per_symbol, i)
3050                } else {
3051                    ((b >> i) & 1) == 1
3052                };
3053                tree.update(bit, i);
3054            }
3055        }
3056        let log_after = tree.get_log_block_probability();
3057        -(log_after - log_before) / std::f64::consts::LN_2
3058    }
3059
3060    /// Computes the expected `update_and_score` result by driving a *fresh*
3061    /// [`FacContextTree`] directly with the same bit-extraction logic used
3062    /// inside `TraceModel::FacCtw::update_and_score`.
3063    ///
3064    /// This is the single-shot reference oracle used by parity tests.
3065    /// For incremental (multi-chunk) scenarios use [`fac_ctw_oracle_score_on_tree`]
3066    /// with a persistent tree.
3067    #[cfg(feature = "backend-ctw")]
3068    fn fac_ctw_oracle_score(
3069        base_depth: usize,
3070        bits_per_symbol: usize,
3071        msb_first: bool,
3072        data: &[u8],
3073    ) -> f64 {
3074        use crate::backends::ctw::FacContextTree;
3075        let mut tree = FacContextTree::new(base_depth, bits_per_symbol);
3076        fac_ctw_oracle_score_on_tree(&mut tree, bits_per_symbol, msb_first, data)
3077    }
3078
3079    /// Asserts that `TraceModel::FacCtw` with the given parameters scores
3080    /// `data` identically (bit-exact `f64`) to the reference oracle, and that
3081    /// `reset()` restores the model so a second pass yields the same score.
3082    #[cfg(feature = "backend-ctw")]
3083    fn assert_fac_ctw_trace_parity(
3084        base_depth: usize,
3085        encoding_bits: usize,
3086        msb_first: Option<bool>,
3087        data: &[u8],
3088    ) {
3089        // The plan resolves msb_first via `unwrap_or(encoding_bits == 8)`.
3090        let resolved_msb_first = msb_first.unwrap_or(encoding_bits == 8);
3091
3092        let backend = RateBackend::FacCtw {
3093            base_depth,
3094            num_percept_bits: encoding_bits,
3095            encoding_bits,
3096            msb_first,
3097        };
3098        let compiled = backend
3099            .compile()
3100            .expect("FacCtw backend should compile cleanly");
3101
3102        let mut model =
3103            TraceModel::new(&compiled).expect("TraceModel::FacCtw should initialize without error");
3104
3105        // ── First pass: trace model vs. oracle ─────────────────────────────
3106        let trace_bits = model.update_and_score(data);
3107        let oracle_bits = fac_ctw_oracle_score(base_depth, encoding_bits, resolved_msb_first, data);
3108
3109        assert!(
3110            trace_bits.is_finite() && trace_bits >= 0.0,
3111            "trace model bits must be finite and non-negative; got {trace_bits} \
3112             (base_depth={base_depth}, encoding_bits={encoding_bits}, msb_first={msb_first:?})"
3113        );
3114        assert_eq!(
3115            trace_bits.to_bits(),
3116            oracle_bits.to_bits(),
3117            "TraceModel::FacCtw score must match FacContextTree oracle exactly \
3118             (base_depth={base_depth}, encoding_bits={encoding_bits}, msb_first={msb_first:?}); \
3119             trace={trace_bits}, oracle={oracle_bits}"
3120        );
3121
3122        // ── Reset then second pass: scores must be identical to first pass ──
3123        // This catches msb_first / bits_per_symbol state not being properly
3124        // preserved across reset(), or the tree not being fully cleared.
3125        model
3126            .reset()
3127            .expect("TraceModel::FacCtw reset should succeed");
3128        let trace_bits_after_reset = model.update_and_score(data);
3129
3130        assert_eq!(
3131            trace_bits_after_reset.to_bits(),
3132            oracle_bits.to_bits(),
3133            "TraceModel::FacCtw score after reset must equal the fresh-model score \
3134             (base_depth={base_depth}, encoding_bits={encoding_bits}, msb_first={msb_first:?}); \
3135             after_reset={trace_bits_after_reset}, expected={oracle_bits}"
3136        );
3137    }
3138
3139    /// Regression test: `TraceModel::FacCtw` with 8-bit symbols and MSB-first
3140    /// ordering must use `ctw_symbol_bit_msb` to decompose each byte, not
3141    /// the legacy LSB path.  This is the primary regression target for the
3142    /// branch that wired `msb_first=true` and `raw encoding_bits` into the
3143    /// trace model.
3144    #[cfg(feature = "backend-ctw")]
3145    #[test]
3146    fn trace_model_fac_ctw_msb_first_8bit_parity() {
3147        // Use a non-trivial payload with varied bit patterns to exercise the
3148        // full 8-bit MSB decomposition path.
3149        let data = b"trace-model regression: fac-ctw msb path";
3150        assert_fac_ctw_trace_parity(
3151            /*base_depth=*/ 6,
3152            /*encoding_bits=*/ 8,
3153            /*msb_first=*/ Some(true),
3154            data,
3155        );
3156    }
3157
3158    /// Regression test: `TraceModel::FacCtw` with 8-bit symbols and explicit
3159    /// LSB-first ordering must use `(b >> i) & 1`.  Verifies the `msb_first`
3160    /// flag is correctly threaded through from the compiled plan into the
3161    /// update loop and is distinct from the MSB path above (the scores for the
3162    /// same data must differ, proving the two paths are not identical).
3163    #[cfg(feature = "backend-ctw")]
3164    #[test]
3165    fn trace_model_fac_ctw_lsb_first_8bit_parity() {
3166        let data = b"trace-model regression: fac-ctw lsb path";
3167        assert_fac_ctw_trace_parity(
3168            /*base_depth=*/ 6,
3169            /*encoding_bits=*/ 8,
3170            /*msb_first=*/ Some(false),
3171            data,
3172        );
3173
3174        // Sanity: the two orderings must produce distinct scores for non-palindromic
3175        // bit patterns, confirming the flag actually controls bit extraction.
3176        let oracle_msb = fac_ctw_oracle_score(6, 8, true, data);
3177        let oracle_lsb = fac_ctw_oracle_score(6, 8, false, data);
3178        assert_ne!(
3179            oracle_msb.to_bits(),
3180            oracle_lsb.to_bits(),
3181            "MSB-first and LSB-first FacCtw must differ on non-palindromic data"
3182        );
3183    }
3184
3185    /// Regression test: `TraceModel::FacCtw` with a sub-byte `encoding_bits`
3186    /// (4 bits per symbol) and MSB-first ordering, verifying that
3187    /// `ctw_symbol_bit_msb` correctly addresses the low-4 bits of each byte.
3188    ///
3189    /// `num_percept_bits` is kept equal to `encoding_bits` here; see
3190    /// [`trace_model_fac_ctw_encoding_bits_drives_width_not_num_percept_bits`]
3191    /// for the dedicated guard that `TraceModel` uses `encoding_bits` (not
3192    /// `num_percept_bits`) as the per-symbol bit width.
3193    #[cfg(feature = "backend-ctw")]
3194    #[test]
3195    fn trace_model_fac_ctw_sub_byte_4bit_msb_parity() {
3196        // Bytes whose lower nibble and upper nibble differ, so that LSB vs MSB
3197        // ordering produces different bit sequences.
3198        let data = &[0xA3u8, 0x5C, 0xF1, 0x7E, 0x29, 0xB4];
3199        assert_fac_ctw_trace_parity(
3200            /*base_depth=*/ 4,
3201            /*encoding_bits=*/ 4,
3202            /*msb_first=*/ Some(true),
3203            data,
3204        );
3205    }
3206
3207    /// Regression test: default `msb_first=None` with 8-bit symbols must
3208    /// resolve to MSB-first.  The rule `unwrap_or(encoding_bits == 8)` evaluates
3209    /// to `true` for 8-bit symbols; this verifies that the `Option<bool>` →
3210    /// `bool` resolution in `compile_rate_plan_fac_ctw` propagates end-to-end
3211    /// through `TraceModel::new` into the update loop.
3212    ///
3213    /// The `assert_fac_ctw_trace_parity` call already exercises the full
3214    /// compile → `TraceModel::new` → `update_and_score` path against the oracle
3215    /// with the resolved `bool`.  The additional assertion below confirms that
3216    /// `None` and `Some(true)` produce bit-identical `TraceModel` scores on the
3217    /// same data, ruling out any partial or inverted propagation.
3218    #[cfg(feature = "backend-ctw")]
3219    #[test]
3220    fn trace_model_fac_ctw_default_msb_resolution_8bit() {
3221        let data = b"default-msb resolution smoke test";
3222
3223        // None + encoding_bits=8 → resolved msb_first = true.
3224        assert_fac_ctw_trace_parity(
3225            /*base_depth=*/ 5, /*encoding_bits=*/ 8, /*msb_first=*/ None, data,
3226        );
3227
3228        // Confirm: two TraceModels — one with None, one with Some(true) — must
3229        // score the same data identically.  This catches inversions or partial
3230        // propagation that assert_fac_ctw_trace_parity (oracle-based) would miss
3231        // if the oracle itself used the wrong convention.
3232        let backend_none = RateBackend::FacCtw {
3233            base_depth: 5,
3234            num_percept_bits: 8,
3235            encoding_bits: 8,
3236            msb_first: None,
3237        };
3238        let backend_explicit = RateBackend::FacCtw {
3239            base_depth: 5,
3240            num_percept_bits: 8,
3241            encoding_bits: 8,
3242            msb_first: Some(true),
3243        };
3244        let mut model_none =
3245            TraceModel::new(&backend_none.compile().expect("fac-ctw None compile"))
3246                .expect("TraceModel::new (None)");
3247        let mut model_explicit = TraceModel::new(
3248            &backend_explicit
3249                .compile()
3250                .expect("fac-ctw Some(true) compile"),
3251        )
3252        .expect("TraceModel::new (Some(true))");
3253        assert_eq!(
3254            model_none.update_and_score(data).to_bits(),
3255            model_explicit.update_and_score(data).to_bits(),
3256            "msb_first=None with encoding_bits=8 must produce the same score as Some(true)"
3257        );
3258    }
3259
3260    /// Regression test: default `msb_first=None` with a sub-byte `encoding_bits`
3261    /// (4 bits) must resolve to LSB-first.  The rule `unwrap_or(encoding_bits == 8)`
3262    /// evaluates to `false` for any width other than 8; this verifies end-to-end
3263    /// propagation for the sub-byte default case.
3264    ///
3265    /// An additional assertion confirms that `None` and `Some(false)` produce
3266    /// bit-identical `TraceModel` scores, ruling out any inversion.
3267    #[cfg(feature = "backend-ctw")]
3268    #[test]
3269    fn trace_model_fac_ctw_default_lsb_resolution_4bit() {
3270        let data = &[0xA3u8, 0x5C, 0xF1, 0x7E, 0x29, 0xB4];
3271
3272        // None + encoding_bits=4 → resolved msb_first = false (LSB).
3273        assert_fac_ctw_trace_parity(
3274            /*base_depth=*/ 4, /*encoding_bits=*/ 4, /*msb_first=*/ None, data,
3275        );
3276
3277        // Confirm: None score == Some(false) score via two TraceModel instances.
3278        let backend_none = RateBackend::FacCtw {
3279            base_depth: 4,
3280            num_percept_bits: 4,
3281            encoding_bits: 4,
3282            msb_first: None,
3283        };
3284        let backend_explicit = RateBackend::FacCtw {
3285            base_depth: 4,
3286            num_percept_bits: 4,
3287            encoding_bits: 4,
3288            msb_first: Some(false),
3289        };
3290        let mut model_none =
3291            TraceModel::new(&backend_none.compile().expect("fac-ctw None/4-bit compile"))
3292                .expect("TraceModel::new (None/4-bit)");
3293        let mut model_explicit = TraceModel::new(
3294            &backend_explicit
3295                .compile()
3296                .expect("fac-ctw Some(false)/4-bit compile"),
3297        )
3298        .expect("TraceModel::new (Some(false)/4-bit)");
3299        assert_eq!(
3300            model_none.update_and_score(data).to_bits(),
3301            model_explicit.update_and_score(data).to_bits(),
3302            "msb_first=None with encoding_bits=4 must produce the same score as Some(false)"
3303        );
3304    }
3305
3306    /// Regression guard: `TraceModel::FacCtw` must use `encoding_bits` as the
3307    /// per-symbol bit width, not `num_percept_bits`.
3308    ///
3309    /// `TraceModel::new` explicitly patterns `num_percept_bits: _` and assigns
3310    /// `bits_per_symbol = *encoding_bits`.  A regression back to `num_percept_bits`
3311    /// would cause 4-bit vs 8-bit symbol decomposition, producing a different bit
3312    /// count and failing the oracle assertion (oracle is wired to `encoding_bits`).
3313    #[cfg(feature = "backend-ctw")]
3314    #[test]
3315    fn trace_model_fac_ctw_encoding_bits_drives_width_not_num_percept_bits() {
3316        // num_percept_bits=8 (AIXI percept cardinality) diverges from
3317        // encoding_bits=4 (VM trace / rate-byte symbol width).
3318        let data = &[0xA3u8, 0x5C, 0xF1, 0x7E, 0x29, 0xB4];
3319        let base_depth: usize = 4;
3320        let encoding_bits: usize = 4;
3321
3322        let backend = RateBackend::FacCtw {
3323            base_depth,
3324            num_percept_bits: 8, // intentionally differs from encoding_bits
3325            encoding_bits,
3326            msb_first: Some(true),
3327        };
3328        let compiled = backend.compile().expect("fac-ctw compile");
3329        let mut model = TraceModel::new(&compiled).expect("TraceModel::new");
3330
3331        let trace_bits = model.update_and_score(data);
3332
3333        // Oracle uses encoding_bits=4 (as the trace model must).
3334        let oracle_4bit = fac_ctw_oracle_score(base_depth, encoding_bits, true, data);
3335        assert_eq!(
3336            trace_bits.to_bits(),
3337            oracle_4bit.to_bits(),
3338            "TraceModel must use encoding_bits={encoding_bits} as symbol width, not num_percept_bits=8; \
3339             trace={trace_bits}, oracle_4bit={oracle_4bit}"
3340        );
3341
3342        // Confirm the test is meaningful: an oracle with 8-bit width produces a
3343        // *different* score, so the assert above would catch a num_percept_bits regression.
3344        let oracle_8bit = fac_ctw_oracle_score(base_depth, 8, true, data);
3345        assert_ne!(
3346            oracle_4bit.to_bits(),
3347            oracle_8bit.to_bits(),
3348            "4-bit and 8-bit FacCtw oracles must differ on this data (test is non-trivial)"
3349        );
3350    }
3351
3352    /// Regression test: `TraceModel::FacCtw` must produce the correct incremental
3353    /// surprise when `update_and_score` is called multiple times on the same
3354    /// persistent model — the normal VM usage pattern for trace-entropy shaping.
3355    ///
3356    /// Each call must score only the *new* bytes against the model already updated
3357    /// by all prior calls; the oracle maintains a matching persistent
3358    /// [`FacContextTree`] using [`fac_ctw_oracle_score_on_tree`].
3359    #[cfg(feature = "backend-ctw")]
3360    #[test]
3361    fn trace_model_fac_ctw_incremental_scoring_parity() {
3362        use crate::backends::ctw::FacContextTree;
3363
3364        let base_depth: usize = 5;
3365        let encoding_bits: usize = 8;
3366        let msb_first = true;
3367
3368        let backend = RateBackend::FacCtw {
3369            base_depth,
3370            num_percept_bits: encoding_bits,
3371            encoding_bits,
3372            msb_first: Some(msb_first),
3373        };
3374        let compiled = backend.compile().expect("fac-ctw compile");
3375        let mut model = TraceModel::new(&compiled).expect("TraceModel::new");
3376
3377        // Two distinct chunks sharing context (realistic VM trace pattern).
3378        let chunk_a: &[u8] = b"incremental trace chunk A";
3379        let chunk_b: &[u8] = b"incremental trace chunk B -- different continuation";
3380
3381        // ── Trace model: two sequential updates ────────────────────────────
3382        let trace_bits_a = model.update_and_score(chunk_a);
3383        let trace_bits_b = model.update_and_score(chunk_b);
3384
3385        // ── Oracle: persistent tree updated through A then B ───────────────
3386        let mut oracle_tree = FacContextTree::new(base_depth, encoding_bits);
3387        let oracle_bits_a =
3388            fac_ctw_oracle_score_on_tree(&mut oracle_tree, encoding_bits, msb_first, chunk_a);
3389        let oracle_bits_b =
3390            fac_ctw_oracle_score_on_tree(&mut oracle_tree, encoding_bits, msb_first, chunk_b);
3391
3392        assert_eq!(
3393            trace_bits_a.to_bits(),
3394            oracle_bits_a.to_bits(),
3395            "incremental: first chunk score must match oracle; \
3396             trace={trace_bits_a}, oracle={oracle_bits_a}"
3397        );
3398        assert_eq!(
3399            trace_bits_b.to_bits(),
3400            oracle_bits_b.to_bits(),
3401            "incremental: second chunk score must match oracle after first chunk is consumed; \
3402             trace={trace_bits_b}, oracle={oracle_bits_b}"
3403        );
3404    }
3405
3406    /// Edge case: `update_and_score` on empty data must return exactly `0.0`
3407    /// without mutating the model.  The production guard is the top-level
3408    /// `if data.is_empty() { return 0.0; }` in `update_and_score`.
3409    #[cfg(feature = "backend-ctw")]
3410    #[test]
3411    fn trace_model_fac_ctw_empty_input_returns_zero() {
3412        let backend = RateBackend::FacCtw {
3413            base_depth: 4,
3414            num_percept_bits: 8,
3415            encoding_bits: 8,
3416            msb_first: Some(true),
3417        };
3418        let compiled = backend.compile().expect("fac-ctw compile");
3419        let mut model = TraceModel::new(&compiled).expect("TraceModel::new");
3420
3421        let bits_empty = model.update_and_score(b"");
3422        assert_eq!(
3423            bits_empty.to_bits(),
3424            0.0f64.to_bits(),
3425            "empty input must return exactly 0.0"
3426        );
3427
3428        // Confirm the model is unmodified: scoring non-empty data after an empty
3429        // call must match a fresh oracle (no phantom state from the empty update).
3430        let data = b"post-empty data";
3431        let bits_after = model.update_and_score(data);
3432        let oracle_bits = fac_ctw_oracle_score(4, 8, true, data);
3433        assert_eq!(
3434            bits_after.to_bits(),
3435            oracle_bits.to_bits(),
3436            "model must be unmodified after empty update; \
3437             bits_after={bits_after}, oracle={oracle_bits}"
3438        );
3439    }
3440}