1use 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
59pub use nyx_lite::mem::SharedMemoryRegion;
61pub use nyx_lite::snapshot::NyxSnapshot;
62pub use nyx_lite::{ExitReason, NyxVM, SharedMemoryPolicy};
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70#[non_exhaustive]
71pub enum PayloadEncoding {
72 Utf8,
74 Hex,
76}
77
78impl PayloadEncoding {
79 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 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#[allow(dead_code)]
202pub const HYPERCALL_EXECDONE: u64 = 0x656e6f6463657865; #[allow(dead_code)]
205pub const HYPERCALL_SNAPSHOT: u64 = 0x746f687370616e73; #[allow(dead_code)]
208pub const HYPERCALL_NYX_LITE: u64 = 0x6574696c2d78796e; #[allow(dead_code)]
211pub const HYPERCALL_SHAREMEM: u64 = 0x6d656d6572616873; #[allow(dead_code)]
214pub const HYPERCALL_DBGPRINT: u64 = 0x746e697270676264; const SHARED_ACTION_LEN_OFFSET: u64 = 0;
217const SHARED_RESP_LEN_OFFSET: u64 = 8;
218const SHARED_PAYLOAD_OFFSET: u64 = 16;
219
220#[derive(Clone, Debug)]
222#[non_exhaustive]
223pub struct NyxProtocolConfig {
224 pub action_prefix: String,
226 pub action_suffix: String,
228 pub obs_prefix: String,
230 pub rew_prefix: String,
232 pub done_prefix: String,
234 pub data_prefix: String,
236 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#[derive(Clone, Debug)]
260#[non_exhaustive]
261pub struct NyxActionSpec {
262 pub name: Option<String>,
264 pub payload: Vec<u8>,
266}
267
268impl NyxActionSpec {
269 pub fn new(payload: Vec<u8>) -> Self {
271 Self {
272 name: None,
273 payload,
274 }
275 }
276
277 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#[derive(Clone, Debug)]
294#[non_exhaustive]
295pub enum FuzzMutator {
296 FlipBit,
298 FlipByte,
300 InsertByte,
302 DeleteByte,
304 SpliceSeed,
306 ResetSeed,
308 Havoc,
310}
311
312#[derive(Clone, Debug)]
314#[non_exhaustive]
315pub struct NyxFuzzConfig {
316 pub seeds: Vec<Vec<u8>>,
318 pub mutators: Vec<FuzzMutator>,
320 pub min_len: usize,
322 pub max_len: usize,
324 pub dictionary: Vec<Vec<u8>>,
326 pub rng_seed: u64,
328}
329
330impl NyxFuzzConfig {
331 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#[derive(Clone, Debug)]
352#[non_exhaustive]
353pub enum NyxActionSource {
354 Literal(Vec<NyxActionSpec>),
356 Fuzz(NyxFuzzConfig),
358}
359
360#[derive(Clone, Copy, Debug)]
366#[non_exhaustive]
367pub enum NyxObservationPolicy {
368 FromGuest,
370 OutputHash,
372 RawOutput,
374 SharedMemory,
376}
377
378#[derive(Clone, Copy, Debug)]
380#[non_exhaustive]
381pub enum NyxObservationStreamMode {
382 PadTruncate,
384 Pad,
386 Truncate,
388}
389
390#[derive(Clone)]
396#[non_exhaustive]
397pub enum NyxRewardPolicy {
398 FromGuest,
400 Pattern {
402 pattern: String,
404 base_reward: i64,
406 bonus_reward: i64,
408 },
409 Custom(Arc<dyn Fn(&NyxStepResult) -> Reward + Send + Sync>),
411}
412
413#[derive(Clone, Debug)]
419#[non_exhaustive]
420pub enum NyxRewardShaping {
421 EntropyReduction {
423 baseline_bytes: Vec<u8>,
425 scale: f64,
427 crash_bonus: Option<i64>,
429 timeout_bonus: Option<i64>,
431 },
432 TraceEntropy {
434 scale: f64,
436 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#[derive(Clone, Debug)]
470#[non_exhaustive]
471pub struct NyxActionFilter {
472 pub min_entropy: Option<f64>,
474 pub max_entropy: Option<f64>,
476 pub min_intrinsic_dependence: Option<f64>,
478 pub min_novelty: Option<f64>,
480 pub novelty_prior: Option<Vec<u8>>,
482 pub reject_reward: Option<i64>,
484}
485
486impl NyxActionFilter {
487 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#[derive(Clone, Debug)]
512#[non_exhaustive]
513pub struct NyxTraceConfig {
514 pub shared_region_name: Option<String>,
516 pub max_bytes: usize,
518 pub reset_on_episode: bool,
520}
521
522impl NyxTraceConfig {
523 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#[derive(Clone)]
545#[non_exhaustive]
546pub struct NyxVmConfig {
547 pub firecracker_config: String,
549 pub instance_id: String,
551
552 pub shared_region_name: String,
555 pub shared_region_size: usize,
557 pub shared_memory_policy: SharedMemoryPolicy,
559
560 pub step_timeout: Duration,
563 pub boot_timeout: Duration,
565
566 pub episode_steps: usize,
569 pub step_cost: i64,
571
572 pub observation_policy: NyxObservationPolicy,
575 pub observation_bits: usize,
577 pub observation_stream_len: usize,
579 pub observation_stream_mode: NyxObservationStreamMode,
581 pub observation_pad_byte: u8,
583
584 pub reward_bits: usize,
587 pub reward_policy: NyxRewardPolicy,
589 pub reward_shaping: Option<NyxRewardShaping>,
591
592 pub action_source: NyxActionSource,
595 pub action_filter: Option<NyxActionFilter>,
597
598 pub protocol: NyxProtocolConfig,
601
602 pub stats_backend: RateBackend,
605
606 pub trace: Option<NyxTraceConfig>,
609
610 pub debug_mode: bool,
613
614 pub crash_log: Option<String>,
617}
618
619fn default_vm_stats_backend() -> RateBackend {
620 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 pub fn validate(&self) -> InfotheoryResult<()> {
690 self.validate_runtime_invariants()
691 }
692
693 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 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#[derive(Clone, Debug)]
1098pub struct NyxStepResult {
1099 pub exit_reason: NyxExitKind,
1101 pub output: Vec<u8>,
1103 pub parsed_obs: Option<u64>,
1105 pub parsed_rew: Option<i64>,
1107 pub done: bool,
1109 pub trace_data: Vec<u8>,
1111 pub shared_memory: Vec<u8>,
1113}
1114
1115#[derive(Clone, Debug)]
1117pub enum NyxExitKind {
1118 ExecDone(u64),
1120 Timeout,
1122 Shutdown,
1124 Hypercall {
1126 code: u64,
1128 arg1: u64,
1130 arg2: u64,
1132 arg3: u64,
1134 arg4: u64,
1136 },
1137 DebugPrint(String),
1139 Breakpoint,
1141 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
1170enum TraceModel {
1176 #[cfg(feature = "backend-rosa")]
1177 Rosa { model: RosaPlus, max_order: i64 },
1178 #[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 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
1463struct FuzzState {
1468 current: Vec<u8>,
1469 rng: RandomGenerator,
1470}
1471
1472pub struct NyxVmEnvironment {
1478 config: NyxVmConfig,
1480 compiled_stats_backend: CompiledRateBackend,
1482 vm: NyxVM,
1484 base_snapshot: Option<Arc<NyxSnapshot>>,
1486 shared_vaddr: Option<u64>,
1488 shared_cr3: Option<u64>,
1490 trace_model: Option<TraceModel>,
1492 baseline_entropy: Option<f64>,
1494 reward_shaping: Option<NyxRewardShaping>,
1496 fuzz_state: Option<FuzzState>,
1498
1499 obs: PerceptVal,
1502 rew: Reward,
1504 obs_stream: Vec<PerceptVal>,
1506 step_in_episode: usize,
1508 needs_reset: bool,
1510 initialized: bool,
1512}
1513
1514impl NyxVmEnvironment {
1515 pub fn new(config: NyxVmConfig) -> anyhow::Result<Self> {
1517 config.validate().map_err(anyhow::Error::msg)?;
1518
1519 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 let vm = NyxVM::new(config.instance_id.clone(), &fc_config);
1528
1529 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 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 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 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 env.initialize()?;
1605
1606 Ok(env)
1607 }
1608
1609 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 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 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 }
1661 }
1662 }
1663
1664 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 }
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 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 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 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 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 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 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 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 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 pub fn run_step(&mut self, payload: &[u8]) -> anyhow::Result<NyxStepResult> {
1828 self.write_action_to_shared_memory(payload)?;
1830
1831 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 if collect_output {
1889 output.extend_from_slice(msg.as_bytes());
1890 }
1891 }
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 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 }
1920 }
1921 }
1922
1923 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 self.clear_shared_length();
1942
1943 if let Some(trace_cfg) = &self.config.trace
1945 && trace_cfg.shared_region_name.is_some()
1946 {
1947 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 Some(val)
1969 }
1970
1971 fn try_parse_i64(val: u64) -> Option<i64> {
1972 Some(val as i64)
1973 }
1974
1975 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 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 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 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 pub fn vm(&self) -> &NyxVM {
2265 &self.vm
2266 }
2267
2268 pub fn vm_mut(&mut self) -> &mut NyxVM {
2270 &mut self.vm
2271 }
2272
2273 pub fn take_snapshot(&mut self) -> Arc<NyxSnapshot> {
2275 self.vm.take_snapshot()
2276 }
2277
2278 pub fn apply_snapshot(&mut self, snapshot: &Arc<NyxSnapshot>) {
2280 self.vm.apply_snapshot(snapshot);
2281 }
2282
2283 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 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 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 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
2333impl 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 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 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 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 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
2507fn 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#[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 #[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 #[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 #[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 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 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 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 #[cfg(feature = "backend-ctw")]
3145 #[test]
3146 fn trace_model_fac_ctw_msb_first_8bit_parity() {
3147 let data = b"trace-model regression: fac-ctw msb path";
3150 assert_fac_ctw_trace_parity(
3151 6,
3152 8,
3153 Some(true),
3154 data,
3155 );
3156 }
3157
3158 #[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 6,
3169 8,
3170 Some(false),
3171 data,
3172 );
3173
3174 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 #[cfg(feature = "backend-ctw")]
3194 #[test]
3195 fn trace_model_fac_ctw_sub_byte_4bit_msb_parity() {
3196 let data = &[0xA3u8, 0x5C, 0xF1, 0x7E, 0x29, 0xB4];
3199 assert_fac_ctw_trace_parity(
3200 4,
3201 4,
3202 Some(true),
3203 data,
3204 );
3205 }
3206
3207 #[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 assert_fac_ctw_trace_parity(
3225 5, 8, None, data,
3226 );
3227
3228 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 #[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 assert_fac_ctw_trace_parity(
3274 4, 4, None, data,
3275 );
3276
3277 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 #[cfg(feature = "backend-ctw")]
3314 #[test]
3315 fn trace_model_fac_ctw_encoding_bits_drives_width_not_num_percept_bits() {
3316 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, 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 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 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 #[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 let chunk_a: &[u8] = b"incremental trace chunk A";
3379 let chunk_b: &[u8] = b"incremental trace chunk B -- different continuation";
3380
3381 let trace_bits_a = model.update_and_score(chunk_a);
3383 let trace_bits_b = model.update_and_score(chunk_b);
3384
3385 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 #[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 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}