1pub mod core;
4mod document;
5
6pub use self::core::{
7 AssetRef, CanonicalBytes, CompiledCompressionBackend, CompiledRateBackend,
8 CompressionBackendCapabilities, MethodBackendFamily, RateBackendCapabilities,
9 RateBackendTraceStrategy, SpecEnvironment, ValidatedCompressionBackend, ValidatedRateBackend,
10};
11#[cfg(feature = "tuner")]
12pub(crate) use self::document::TuneInvalidReason;
13#[cfg(feature = "aixi")]
14pub use self::document::WarmStartExactJhControllerSpec;
15pub use self::document::{
16 AiqiDiscountedControllerSpec, AssetBinding, AssetId, BuiltinEnvironmentSpec,
17 CompiledPlannerController, CompiledPlannerRunSpec, CompiledSpecDocument, ControllerSpec,
18 EnvironmentSpec, McAixiControllerSpec, ParsedSpecDocument, PlannerInterfaceSpec,
19 PlannerRunSpec, PlannerRuntimeSpec, ResolvedAssetBinding, SharedMemoryPolicySpec, SpecDocument,
20 ValidatedPlannerRunSpec, ValidatedSpecDocument, VmActionFilterSpec, VmEnvironmentSpec,
21 VmFuzzMutatorSpec, VmObservationPolicySpec, VmObservationStreamModeSpec, VmPayloadEncodingSpec,
22 VmRewardPolicySpec, VmRewardShapingSpec, VmRuntimeActionSourceSpec, VmTraceSpec,
23 load_spec_document,
24};
25#[cfg(feature = "tuner")]
26pub use self::document::{
27 AiqiDiscountedTuneControllerSpec, AnnealedHillClimbingTuneControllerSpec,
28 CompiledTuneController, CompiledTuneSpec, McAixiFacCtwTuneControllerSpec, TuneBoundsSpec,
29 TuneControllerKind, TuneControllerSpec, TuneParameterRangeSpec, TunePlannerInterfaceSpec,
30 TuneSpec, ValidatedTuneSpec, WarmStartExactJhTuneControllerSpec,
31};
32
33use crate::api::{
34 CalibratedSpec, CalibrationContextKind, CompressionBackend, MAX_MIXTURE_NESTING,
35 MixtureExpertSpec, MixtureKind, MixtureScheduleMode, MixtureSpec, ParticleSpec, RateBackend,
36 parse_mixture_kind_name, parse_mixture_schedule_name,
37};
38use crate::validate_zpaq_rate_method;
39use std::error::Error;
40use std::fmt;
41use std::path::{Path, PathBuf};
42use std::sync::Arc;
43
44pub type SpecResult<T> = Result<T, SpecError>;
46
47#[cfg(feature = "aixi")]
53pub(crate) fn canonical_json_bytes(
54 value: &serde_json::Value,
55) -> Result<Vec<u8>, serde_json::Error> {
56 let mut bytes = Vec::<u8>::new();
57 write_canonical_json_value(value, &mut bytes)?;
58 Ok(bytes)
59}
60
61#[cfg(feature = "aixi")]
62fn write_canonical_json_value(
63 value: &serde_json::Value,
64 out: &mut Vec<u8>,
65) -> Result<(), serde_json::Error> {
66 match value {
67 serde_json::Value::Array(items) => {
68 out.push(b'[');
69 for (index, item) in items.iter().enumerate() {
70 if index > 0 {
71 out.push(b',');
72 }
73 write_canonical_json_value(item, out)?;
74 }
75 out.push(b']');
76 Ok(())
77 }
78 serde_json::Value::Object(object) => {
79 let mut entries = object.iter().collect::<Vec<_>>();
80 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
81 out.push(b'{');
82 for (index, (key, item)) in entries.into_iter().enumerate() {
83 if index > 0 {
84 out.push(b',');
85 }
86 serde_json::to_writer(&mut *out, key)?;
87 out.push(b':');
88 write_canonical_json_value(item, out)?;
89 }
90 out.push(b'}');
91 Ok(())
92 }
93 scalar => serde_json::to_writer(out, scalar),
94 }
95}
96
97pub trait CanonicalJson {
99 fn to_canonical_json_value(&self) -> SpecResult<serde_json::Value>;
101
102 fn to_canonical_json(&self) -> SpecResult<String> {
104 serde_json::to_string_pretty(&self.to_canonical_json_value()?).map_err(SpecError::from)
105 }
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct SpecError {
111 message: String,
112}
113
114impl SpecError {
115 pub fn new(message: impl Into<String>) -> Self {
117 Self {
118 message: message.into(),
119 }
120 }
121}
122
123impl fmt::Display for SpecError {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 f.write_str(&self.message)
126 }
127}
128
129impl Error for SpecError {}
130
131impl From<&str> for SpecError {
132 fn from(value: &str) -> Self {
133 Self::new(value)
134 }
135}
136
137impl From<String> for SpecError {
138 fn from(value: String) -> Self {
139 Self::new(value)
140 }
141}
142
143impl From<std::io::Error> for SpecError {
144 fn from(value: std::io::Error) -> Self {
145 Self::new(value.to_string())
146 }
147}
148
149impl From<serde_json::Error> for SpecError {
150 fn from(value: serde_json::Error) -> Self {
151 Self::new(value.to_string())
152 }
153}
154
155#[derive(Clone, Debug)]
157#[non_exhaustive]
158pub struct RateBackendShorthandOptions {
159 pub base_dir: PathBuf,
161 pub ctw_depth: usize,
163 pub fac_ctw_base_depth: usize,
165 pub fac_ctw_num_percept_bits: usize,
167 pub fac_ctw_encoding_bits: usize,
169 pub fac_ctw_msb_first: Option<bool>,
173 pub ppmd_order: usize,
175 pub ppmd_memory_mb: usize,
177 pub sequitur_context_bytes: usize,
179 pub zpaq_method: String,
181 pub default_mamba_model_path: Option<String>,
183 pub default_rwkv_model_path: Option<String>,
185 pub particle_default_if_missing_method: bool,
187}
188
189impl Default for RateBackendShorthandOptions {
190 fn default() -> Self {
191 Self {
192 base_dir: PathBuf::from("."),
193 ctw_depth: crate::rate_defaults::SHORTHAND_DEFAULT_CTW_DEPTH,
194 fac_ctw_base_depth: crate::rate_defaults::SHORTHAND_DEFAULT_FAC_CTW_BASE_DEPTH,
195 fac_ctw_num_percept_bits:
196 crate::rate_defaults::SHORTHAND_DEFAULT_FAC_CTW_NUM_PERCEPT_BITS,
197 fac_ctw_encoding_bits: crate::rate_defaults::SHORTHAND_DEFAULT_FAC_CTW_ENCODING_BITS,
198 fac_ctw_msb_first: None,
199 ppmd_order: crate::rate_defaults::SHORTHAND_DEFAULT_PPMD_ORDER,
200 ppmd_memory_mb: crate::rate_defaults::SHORTHAND_DEFAULT_PPMD_MEMORY_MB,
201 sequitur_context_bytes: crate::rate_defaults::SHORTHAND_DEFAULT_SEQUITUR_CONTEXT_BYTES,
202 zpaq_method: crate::rate_defaults::SHORTHAND_DEFAULT_ZPAQ_RATE_METHOD.to_string(),
203 default_mamba_model_path: None,
204 default_rwkv_model_path: None,
205 particle_default_if_missing_method: true,
206 }
207 }
208}
209
210#[derive(Clone)]
213#[non_exhaustive]
214pub struct CompressionBackendShorthandOptions {
215 pub base_dir: PathBuf,
217 pub zpaq_method: String,
219 pub default_rate_backend: Option<RateBackend>,
221 pub default_framing: crate::compression::FramingMode,
223 pub default_rwkv_model_path: Option<String>,
225}
226
227impl Default for CompressionBackendShorthandOptions {
228 fn default() -> Self {
229 Self {
230 base_dir: PathBuf::from("."),
231 zpaq_method: "5".to_string(),
232 default_rate_backend: None,
233 default_framing: crate::compression::FramingMode::Framed,
234 default_rwkv_model_path: None,
235 }
236 }
237}
238
239pub fn resolve_spec_path(base_dir: &Path, path: impl AsRef<Path>) -> PathBuf {
243 let path = path.as_ref();
244 if path.is_absolute() {
245 path.to_path_buf()
246 } else {
247 base_dir.join(path)
248 }
249}
250
251fn resolve_enabled_rate_backend_kind(input: &str) -> SpecResult<crate::runtime::RateBackendKind> {
253 match crate::runtime::find_backend_descriptor_in_registry(
254 crate::runtime::RATE_BACKEND_REGISTRY,
255 input,
256 ) {
257 Some(descriptor) if descriptor.enabled => Ok(descriptor.kind),
258 Some(descriptor) => Err(SpecError::new(format!(
259 "backend '{}' requires infotheory feature '{}'",
260 descriptor.canonical,
261 descriptor
262 .feature
263 .unwrap_or("__internal-registry-mismatch__")
264 ))),
265 None => Err(SpecError::new(format!("unknown backend '{input}'"))),
266 }
267}
268
269pub fn resolve_enabled_rate_backend_name(input: &str) -> SpecResult<&'static str> {
271 let kind = resolve_enabled_rate_backend_kind(input)?;
272 Ok(crate::runtime::describe_rate_backend_kind(kind)
273 .map_err(SpecError::new)?
274 .canonical)
275}
276
277fn resolve_enabled_compression_backend_kind(
279 input: &str,
280) -> SpecResult<crate::runtime::CompressionBackendKind> {
281 match crate::runtime::find_backend_descriptor_in_registry(
282 crate::runtime::COMPRESSION_BACKEND_REGISTRY,
283 input,
284 ) {
285 Some(descriptor) if descriptor.enabled => Ok(descriptor.kind),
286 Some(descriptor) => Err(SpecError::new(format!(
287 "compression backend '{}' requires infotheory feature '{}'",
288 descriptor.canonical,
289 descriptor
290 .feature
291 .unwrap_or("__internal-registry-mismatch__")
292 ))),
293 None => Err(SpecError::new(format!(
294 "unknown compression backend '{input}'"
295 ))),
296 }
297}
298
299pub fn resolve_enabled_compression_backend_name(input: &str) -> SpecResult<&'static str> {
301 let kind = resolve_enabled_compression_backend_kind(input)?;
302 Ok(crate::runtime::describe_compression_backend_kind(kind)
303 .map_err(SpecError::new)?
304 .canonical)
305}
306
307fn resolve_default_rate_backend_spec(
308 default_rate_backend: Option<RateBackend>,
309) -> SpecResult<RateBackend> {
310 default_rate_backend.map(Ok).unwrap_or_else(|| {
311 RateBackend::try_default().map_err(|err| SpecError::new(err.to_string()))
312 })
313}
314
315pub fn load_json_value_from_path(
317 base_dir: &Path,
318 path: &str,
319 label: &str,
320) -> SpecResult<(serde_json::Value, PathBuf)> {
321 let full = resolve_spec_path(base_dir, path);
322 let raw = std::fs::read(&full)
323 .map_err(|e| SpecError::new(format!("failed to read {label} '{}': {e}", full.display())))?;
324 let value = serde_json::from_slice(&raw)
325 .map_err(|e| SpecError::new(format!("invalid {label} JSON '{}': {e}", full.display())))?;
326 Ok((value, full))
327}
328
329fn parse_calibration_context_kind(value: Option<&str>) -> SpecResult<CalibrationContextKind> {
330 match value.unwrap_or("text").trim().to_ascii_lowercase().as_str() {
331 "global" => Ok(CalibrationContextKind::Global),
332 "byteclass" => Ok(CalibrationContextKind::ByteClass),
333 "text" => Ok(CalibrationContextKind::Text),
334 "repeat" => Ok(CalibrationContextKind::Repeat),
335 "textrepeat" => Ok(CalibrationContextKind::TextRepeat),
336 other => Err(SpecError::new(format!(
337 "unknown calibration context '{other}'"
338 ))),
339 }
340}
341
342fn parse_mixture_kind(kind: &str) -> SpecResult<MixtureKind> {
343 parse_mixture_kind_name(kind).map_err(SpecError::from)
344}
345
346fn parse_mixture_schedule(schedule: &str) -> SpecResult<MixtureScheduleMode> {
347 parse_mixture_schedule_name(schedule).map_err(SpecError::from)
348}
349
350fn parse_framing_mode(value: Option<&str>) -> SpecResult<crate::compression::FramingMode> {
351 match value
352 .unwrap_or("framed")
353 .trim()
354 .to_ascii_lowercase()
355 .as_str()
356 {
357 "framed" => Ok(crate::compression::FramingMode::Framed),
358 "raw" => Ok(crate::compression::FramingMode::Raw),
359 other => Err(SpecError::new(format!("unknown framing mode '{other}'"))),
360 }
361}
362
363fn mixture_kind_name(kind: MixtureKind) -> &'static str {
364 match kind {
365 MixtureKind::Bayes => "bayes",
366 MixtureKind::FadingBayes => "fading-bayes",
367 MixtureKind::Switching => "switching",
368 MixtureKind::Convex => "convex",
369 MixtureKind::Mdl => "mdl",
370 MixtureKind::Neural => "neural",
371 }
372}
373
374fn mixture_schedule_name(schedule: MixtureScheduleMode) -> &'static str {
375 match schedule {
376 MixtureScheduleMode::Default => "default",
377 MixtureScheduleMode::Theorem => "theorem",
378 }
379}
380
381fn calibration_context_kind_name(kind: CalibrationContextKind) -> &'static str {
382 match kind {
383 CalibrationContextKind::Global => "global",
384 CalibrationContextKind::ByteClass => "byteclass",
385 CalibrationContextKind::Text => "text",
386 CalibrationContextKind::Repeat => "repeat",
387 CalibrationContextKind::TextRepeat => "textrepeat",
388 }
389}
390
391fn framing_mode_name(mode: crate::compression::FramingMode) -> &'static str {
392 match mode {
393 crate::compression::FramingMode::Raw => "raw",
394 crate::compression::FramingMode::Framed => "framed",
395 }
396}
397
398#[cfg(any(feature = "backend-rwkv", feature = "backend-mamba"))]
399fn canonicalize_explicit_file_method(
400 base_dir: &Path,
401 method: &str,
402 backend_label: &str,
403) -> SpecResult<Option<String>> {
404 let (base, policy) = crate::backends::llm_policy::split_method_policy_segments(method)
405 .map_err(|err| SpecError::new(err.to_string()))?;
406 let Some(path) = base.strip_prefix("file:") else {
407 return Ok(None);
408 };
409 let path = crate::backends::llm_policy::parse_method_file_path(path.trim());
410 if path.as_os_str().is_empty() {
411 return Err(SpecError::new(format!(
412 "empty file path in {backend_label} method"
413 )));
414 }
415 let full = resolve_spec_path(base_dir, &path);
416 let mut canonical = format!(
417 "file:{}",
418 crate::backends::llm_policy::render_method_file_path(&full)
419 );
420 if let Some(policy) = policy {
421 canonical.push_str(";policy:");
422 canonical.push_str(policy.trim());
423 }
424 Ok(Some(canonical))
425}
426
427#[cfg(feature = "backend-rwkv")]
428fn validate_rwkv_method_eager(method: &str) -> SpecResult<()> {
429 crate::rwkvzip::Compressor::new_from_method(method)
430 .map(|_| ())
431 .map_err(|err| SpecError::new(err.to_string()))
432}
433
434#[cfg(feature = "backend-mamba")]
435fn validate_mamba_method_eager(method: &str) -> SpecResult<()> {
436 crate::mambazip::Compressor::new_from_method(method)
437 .map(|_| ())
438 .map_err(|err| SpecError::new(err.to_string()))
439}
440
441#[cfg(feature = "backend-rwkv")]
442fn normalize_rwkv_method_spec_for_base_dir(
443 base_dir: &Path,
444 method: &crate::rwkvzip::MethodSpec,
445) -> SpecResult<crate::rwkvzip::MethodSpec> {
446 let normalized = match method {
447 crate::rwkvzip::MethodSpec::File { path, policy } => crate::rwkvzip::MethodSpec::File {
448 path: resolve_spec_path(base_dir, path),
449 policy: policy.clone(),
450 },
451 crate::rwkvzip::MethodSpec::Online { cfg, policy } => crate::rwkvzip::MethodSpec::Online {
452 cfg: cfg.clone(),
453 policy: policy.clone(),
454 },
455 };
456 let canonical = crate::rwkvzip::canonical_method_string(&normalized)
457 .map_err(|err| SpecError::new(err.to_string()))?;
458 validate_rwkv_method_eager(&canonical)?;
459 Ok(normalized)
460}
461
462#[cfg(feature = "backend-rwkv")]
463fn normalize_rwkv_path_method(
464 base_dir: &Path,
465 model_path: &str,
466) -> SpecResult<crate::rwkvzip::MethodSpec> {
467 normalize_rwkv_method_spec_for_base_dir(
468 base_dir,
469 &crate::rwkvzip::MethodSpec::File {
470 path: PathBuf::from(model_path),
471 policy: None,
472 },
473 )
474}
475
476#[cfg(feature = "backend-mamba")]
477fn normalize_mamba_method_spec_for_base_dir(
478 base_dir: &Path,
479 method: &crate::mambazip::MethodSpec,
480) -> SpecResult<crate::mambazip::MethodSpec> {
481 let normalized = match method {
482 crate::mambazip::MethodSpec::File { path, policy } => crate::mambazip::MethodSpec::File {
483 path: resolve_spec_path(base_dir, path),
484 policy: policy.clone(),
485 },
486 crate::mambazip::MethodSpec::Online { cfg, policy } => {
487 crate::mambazip::MethodSpec::Online {
488 cfg: cfg.clone(),
489 policy: policy.clone(),
490 }
491 }
492 };
493 let canonical = crate::mambazip::canonical_method_string(&normalized)
494 .map_err(|err| SpecError::new(err.to_string()))?;
495 validate_mamba_method_eager(&canonical)?;
496 Ok(normalized)
497}
498
499#[cfg(feature = "backend-mamba")]
500fn normalize_mamba_path_method(
501 base_dir: &Path,
502 model_path: &str,
503) -> SpecResult<crate::mambazip::MethodSpec> {
504 normalize_mamba_method_spec_for_base_dir(
505 base_dir,
506 &crate::mambazip::MethodSpec::File {
507 path: PathBuf::from(model_path),
508 policy: None,
509 },
510 )
511}
512
513#[cfg(feature = "backend-rwkv")]
514fn normalize_rwkv_method_for_base_dir(
515 base_dir: &Path,
516 method: &str,
517) -> SpecResult<crate::rwkvzip::MethodSpec> {
518 if let Some(canonical) = canonicalize_explicit_file_method(base_dir, method, "rwkv")? {
519 let parsed = crate::rwkvzip::parse_method_spec(&canonical)
520 .map_err(|err| SpecError::new(err.to_string()))?;
521 normalize_rwkv_method_spec_for_base_dir(base_dir, &parsed)
522 } else {
523 let parsed = crate::rwkvzip::parse_method_spec(method)
524 .map_err(|err| SpecError::new(err.to_string()))?;
525 normalize_rwkv_method_spec_for_base_dir(base_dir, &parsed)
526 }
527}
528
529#[cfg(feature = "backend-mamba")]
530fn normalize_mamba_method_for_base_dir(
531 base_dir: &Path,
532 method: &str,
533) -> SpecResult<crate::mambazip::MethodSpec> {
534 if let Some(canonical) = canonicalize_explicit_file_method(base_dir, method, "mamba")? {
535 let parsed = crate::mambazip::parse_method_spec(&canonical)
536 .map_err(|err| SpecError::new(err.to_string()))?;
537 normalize_mamba_method_spec_for_base_dir(base_dir, &parsed)
538 } else {
539 let parsed = crate::mambazip::parse_method_spec(method)
540 .map_err(|err| SpecError::new(err.to_string()))?;
541 normalize_mamba_method_spec_for_base_dir(base_dir, &parsed)
542 }
543}
544
545#[cfg(feature = "backend-rwkv")]
546const RWKV_POLICY_SCOPES: &[&str] = &[
547 "embed",
548 "pre_norm",
549 "attn_norm",
550 "ffn_norm",
551 "attn",
552 "ffn",
553 "head",
554 "bias",
555 "all",
556 "none",
557];
558
559#[cfg(feature = "backend-mamba")]
560const MAMBA_POLICY_SCOPES: &[&str] = &[
561 "embed",
562 "layer_norm",
563 "mixer_conv",
564 "mixer_ssm",
565 "mixer_proj",
566 "head",
567 "bias",
568 "all",
569 "none",
570];
571
572fn zpaq_method_to_json_value(method: &crate::api::ZpaqMethodSpec) -> serde_json::Value {
573 match method {
574 crate::api::ZpaqMethodSpec::Literal { value } => serde_json::json!({
575 "kind": "literal",
576 "value": value,
577 }),
578 }
579}
580
581fn parse_zpaq_method_json_value(
582 value: &serde_json::Value,
583 default: &str,
584) -> SpecResult<crate::api::ZpaqMethodSpec> {
585 if value.is_null() {
586 return Ok(crate::api::ZpaqMethodSpec::literal(default));
587 }
588 if value.is_string() {
589 return Err(SpecError::new(
590 "zpaq method must use object form {'kind':'literal','value':'...'}",
591 ));
592 }
593
594 let kind = value["kind"]
595 .as_str()
596 .ok_or_else(|| SpecError::new("zpaq method.kind is required for object form"))?;
597 if kind != "literal" {
598 return Err(SpecError::new(format!("unknown zpaq method kind '{kind}'")));
599 }
600
601 let method = value["value"]
602 .as_str()
603 .ok_or_else(|| SpecError::new("zpaq method.value must be a string"))?;
604 Ok(crate::api::ZpaqMethodSpec::literal(method))
605}
606
607#[cfg(feature = "backend-rwkv")]
608fn rwkv_online_config_to_json_value(cfg: &crate::rwkvzip::OnlineConfig) -> serde_json::Value {
609 serde_json::json!({
610 "hidden": cfg.hidden,
611 "layers": cfg.layers,
612 "intermediate": cfg.intermediate,
613 "decay_rank": cfg.decay_rank,
614 "a_rank": cfg.a_rank,
615 "v_rank": cfg.v_rank,
616 "g_rank": cfg.g_rank,
617 "seed": cfg.seed,
618 "train_mode": match cfg.train_mode {
619 crate::rwkvzip::OnlineTrainMode::None => "none",
620 crate::rwkvzip::OnlineTrainMode::Sgd => "sgd",
621 crate::rwkvzip::OnlineTrainMode::Adam => "adam",
622 },
623 "lr": cfg.lr,
624 "stride": cfg.stride,
625 })
626}
627
628#[cfg(feature = "backend-rwkv")]
629fn rwkv_online_config_from_json_value(
630 value: &serde_json::Value,
631) -> SpecResult<crate::rwkvzip::OnlineConfig> {
632 let defaults = crate::rwkvzip::OnlineConfig::default();
633 let train_mode = match value["train_mode"].as_str().unwrap_or("none") {
634 "none" => crate::rwkvzip::OnlineTrainMode::None,
635 "sgd" => crate::rwkvzip::OnlineTrainMode::Sgd,
636 "adam" => crate::rwkvzip::OnlineTrainMode::Adam,
637 other => {
638 return Err(SpecError::new(format!("unknown rwkv train_mode '{other}'")));
639 }
640 };
641 Ok(crate::rwkvzip::OnlineConfig {
642 hidden: value["hidden"].as_u64().unwrap_or(defaults.hidden as u64) as usize,
643 layers: value["layers"].as_u64().unwrap_or(defaults.layers as u64) as usize,
644 intermediate: value["intermediate"]
645 .as_u64()
646 .unwrap_or(defaults.intermediate as u64) as usize,
647 decay_rank: value["decay_rank"]
648 .as_u64()
649 .unwrap_or(defaults.decay_rank as u64) as usize,
650 a_rank: value["a_rank"].as_u64().unwrap_or(defaults.a_rank as u64) as usize,
651 v_rank: value["v_rank"].as_u64().unwrap_or(defaults.v_rank as u64) as usize,
652 g_rank: value["g_rank"].as_u64().unwrap_or(defaults.g_rank as u64) as usize,
653 seed: value["seed"].as_u64().unwrap_or(defaults.seed),
654 train_mode,
655 lr: value["lr"].as_f64().unwrap_or(defaults.lr as f64) as f32,
656 stride: value["stride"].as_u64().unwrap_or(defaults.stride as u64) as usize,
657 })
658}
659
660#[cfg(feature = "backend-rwkv")]
661fn rwkv_method_to_json_value(method: &crate::rwkvzip::MethodSpec) -> SpecResult<serde_json::Value> {
662 Ok(match method {
663 crate::rwkvzip::MethodSpec::File { path, policy } => serde_json::json!({
664 "kind": "file",
665 "path": path.to_string_lossy(),
666 "policy": policy.as_ref().map(crate::backends::llm_policy::LlmPolicy::canonical),
667 }),
668 crate::rwkvzip::MethodSpec::Online { cfg, policy } => serde_json::json!({
669 "kind": "online",
670 "cfg": rwkv_online_config_to_json_value(cfg),
671 "policy": policy.as_ref().map(crate::backends::llm_policy::LlmPolicy::canonical),
672 }),
673 })
674}
675
676#[cfg(feature = "backend-rwkv")]
677fn parse_rwkv_method_json_value(
678 value: &serde_json::Value,
679 base_dir: &Path,
680) -> SpecResult<crate::rwkvzip::MethodSpec> {
681 if let Some(method) = value.as_str() {
682 return normalize_rwkv_method_for_base_dir(base_dir, method);
683 }
684 match value["kind"].as_str().unwrap_or("file") {
685 "file" => {
686 let path = value["path"]
687 .as_str()
688 .ok_or_else(|| SpecError::new("rwkv method.path is required"))?;
689 let policy = value["policy"]
690 .as_str()
691 .map(|raw| {
692 crate::backends::llm_policy::parse_policy_segment(raw, RWKV_POLICY_SCOPES)
693 })
694 .transpose()
695 .map_err(|err| SpecError::new(err.to_string()))?;
696 normalize_rwkv_method_spec_for_base_dir(
697 base_dir,
698 &crate::rwkvzip::MethodSpec::File {
699 path: resolve_spec_path(base_dir, path),
700 policy,
701 },
702 )
703 }
704 "online" => {
705 let cfg = rwkv_online_config_from_json_value(&value["cfg"])?;
706 let policy = value["policy"]
707 .as_str()
708 .map(|raw| {
709 crate::backends::llm_policy::parse_policy_segment(raw, RWKV_POLICY_SCOPES)
710 })
711 .transpose()
712 .map_err(|err| SpecError::new(err.to_string()))?;
713 normalize_rwkv_method_spec_for_base_dir(
714 base_dir,
715 &crate::rwkvzip::MethodSpec::Online { cfg, policy },
716 )
717 }
718 other => Err(SpecError::new(format!(
719 "unknown rwkv method kind '{other}'"
720 ))),
721 }
722}
723
724#[cfg(feature = "backend-mamba")]
725fn mamba_online_config_to_json_value(cfg: &crate::mambazip::OnlineConfig) -> serde_json::Value {
726 serde_json::json!({
727 "hidden": cfg.hidden,
728 "layers": cfg.layers,
729 "intermediate": cfg.intermediate,
730 "state": cfg.state,
731 "conv": cfg.conv,
732 "dt_rank": cfg.dt_rank,
733 "seed": cfg.seed,
734 "train_mode": match cfg.train_mode {
735 crate::mambazip::OnlineTrainMode::None => "none",
736 crate::mambazip::OnlineTrainMode::Sgd => "sgd",
737 crate::mambazip::OnlineTrainMode::Adam => "adam",
738 },
739 "lr": cfg.lr,
740 "stride": cfg.stride,
741 })
742}
743
744#[cfg(feature = "backend-mamba")]
745fn mamba_online_config_from_json_value(
746 value: &serde_json::Value,
747) -> SpecResult<crate::mambazip::OnlineConfig> {
748 let defaults = crate::mambazip::OnlineConfig::default();
749 let train_mode = match value["train_mode"].as_str().unwrap_or("none") {
750 "none" => crate::mambazip::OnlineTrainMode::None,
751 "sgd" => crate::mambazip::OnlineTrainMode::Sgd,
752 "adam" => crate::mambazip::OnlineTrainMode::Adam,
753 other => {
754 return Err(SpecError::new(format!(
755 "unknown mamba train_mode '{other}'"
756 )));
757 }
758 };
759 Ok(crate::mambazip::OnlineConfig {
760 hidden: value["hidden"].as_u64().unwrap_or(defaults.hidden as u64) as usize,
761 layers: value["layers"].as_u64().unwrap_or(defaults.layers as u64) as usize,
762 intermediate: value["intermediate"]
763 .as_u64()
764 .unwrap_or(defaults.intermediate as u64) as usize,
765 state: value["state"].as_u64().unwrap_or(defaults.state as u64) as usize,
766 conv: value["conv"].as_u64().unwrap_or(defaults.conv as u64) as usize,
767 dt_rank: value["dt_rank"].as_u64().unwrap_or(defaults.dt_rank as u64) as usize,
768 seed: value["seed"].as_u64().unwrap_or(defaults.seed),
769 train_mode,
770 lr: value["lr"].as_f64().unwrap_or(defaults.lr as f64) as f32,
771 stride: value["stride"].as_u64().unwrap_or(defaults.stride as u64) as usize,
772 })
773}
774
775#[cfg(feature = "backend-mamba")]
776fn mamba_method_to_json_value(
777 method: &crate::mambazip::MethodSpec,
778) -> SpecResult<serde_json::Value> {
779 Ok(match method {
780 crate::mambazip::MethodSpec::File { path, policy } => serde_json::json!({
781 "kind": "file",
782 "path": path.to_string_lossy(),
783 "policy": policy.as_ref().map(crate::backends::llm_policy::LlmPolicy::canonical),
784 }),
785 crate::mambazip::MethodSpec::Online { cfg, policy } => serde_json::json!({
786 "kind": "online",
787 "cfg": mamba_online_config_to_json_value(cfg),
788 "policy": policy.as_ref().map(crate::backends::llm_policy::LlmPolicy::canonical),
789 }),
790 })
791}
792
793#[cfg(feature = "backend-mamba")]
794fn parse_mamba_method_json_value(
795 value: &serde_json::Value,
796 base_dir: &Path,
797) -> SpecResult<crate::mambazip::MethodSpec> {
798 if let Some(method) = value.as_str() {
799 return normalize_mamba_method_for_base_dir(base_dir, method);
800 }
801 match value["kind"].as_str().unwrap_or("file") {
802 "file" => {
803 let path = value["path"]
804 .as_str()
805 .ok_or_else(|| SpecError::new("mamba method.path is required"))?;
806 let policy = value["policy"]
807 .as_str()
808 .map(|raw| {
809 crate::backends::llm_policy::parse_policy_segment(raw, MAMBA_POLICY_SCOPES)
810 })
811 .transpose()
812 .map_err(|err| SpecError::new(err.to_string()))?;
813 normalize_mamba_method_spec_for_base_dir(
814 base_dir,
815 &crate::mambazip::MethodSpec::File {
816 path: resolve_spec_path(base_dir, path),
817 policy,
818 },
819 )
820 }
821 "online" => {
822 let cfg = mamba_online_config_from_json_value(&value["cfg"])?;
823 let policy = value["policy"]
824 .as_str()
825 .map(|raw| {
826 crate::backends::llm_policy::parse_policy_segment(raw, MAMBA_POLICY_SCOPES)
827 })
828 .transpose()
829 .map_err(|err| SpecError::new(err.to_string()))?;
830 normalize_mamba_method_spec_for_base_dir(
831 base_dir,
832 &crate::mambazip::MethodSpec::Online { cfg, policy },
833 )
834 }
835 other => Err(SpecError::new(format!(
836 "unknown mamba method kind '{other}'"
837 ))),
838 }
839}
840
841#[cfg(feature = "backend-rwkv")]
845fn lower_rwkv7_compression_backend_method(
846 method: crate::rwkvzip::MethodSpec,
847 coder: crate::coders::CoderType,
848 framing: crate::compression::FramingMode,
849) -> CompressionBackend {
850 match method {
851 crate::rwkvzip::MethodSpec::File { policy: None, .. } => {
852 CompressionBackend::Rwkv7 { method, coder }
853 }
854 crate::rwkvzip::MethodSpec::File {
855 policy: Some(_), ..
856 }
857 | crate::rwkvzip::MethodSpec::Online { .. } => CompressionBackend::Rate {
858 rate_backend: RateBackend::Rwkv7Method { method },
859 coder,
860 framing,
861 },
862 }
863}
864
865pub fn parse_rwkv7_compression_backend_method(
871 method: Option<&str>,
872 coder: crate::coders::CoderType,
873 options: &CompressionBackendShorthandOptions,
874) -> SpecResult<CompressionBackend> {
875 #[cfg(feature = "backend-rwkv")]
876 {
877 let method = if let Some(method) = method.filter(|value| !value.is_empty()) {
878 normalize_rwkv_method_for_base_dir(&options.base_dir, method)?
879 } else {
880 let model_path = options.default_rwkv_model_path.as_deref().ok_or_else(|| {
881 SpecError::new(
882 "rwkv7 compression backend requires a method string or a configured model path",
883 )
884 })?;
885 normalize_rwkv_path_method(&options.base_dir, model_path)?
886 };
887 Ok(lower_rwkv7_compression_backend_method(
888 method,
889 coder,
890 options.default_framing,
891 ))
892 }
893 #[cfg(not(feature = "backend-rwkv"))]
894 {
895 let _ = method;
896 let _ = coder;
897 let _ = options;
898 Err(SpecError::new(
899 "rwkv7 compression backend disabled at compile time",
900 ))
901 }
902}
903
904pub fn particle_spec_to_json_value(spec: &ParticleSpec) -> serde_json::Value {
906 serde_json::json!({
907 "num_particles": spec.num_particles,
908 "context_window": spec.context_window,
909 "unroll_steps": spec.unroll_steps,
910 "num_cells": spec.num_cells,
911 "cell_dim": spec.cell_dim,
912 "num_rules": spec.num_rules,
913 "selector_hidden": spec.selector_hidden,
914 "rule_hidden": spec.rule_hidden,
915 "noise_dim": spec.noise_dim,
916 "deterministic": spec.deterministic,
917 "enable_noise": spec.enable_noise,
918 "noise_scale": spec.noise_scale,
919 "noise_anneal_steps": spec.noise_anneal_steps,
920 "learning_rate_readout": spec.learning_rate_readout,
921 "learning_rate_selector": spec.learning_rate_selector,
922 "learning_rate_rule": spec.learning_rate_rule,
923 "bptt_depth": spec.bptt_depth,
924 "optimizer_momentum": spec.optimizer_momentum,
925 "grad_clip": spec.grad_clip,
926 "state_clip": spec.state_clip,
927 "forget_lambda": spec.forget_lambda,
928 "resample_threshold": spec.resample_threshold,
929 "mutate_fraction": spec.mutate_fraction,
930 "mutate_scale": spec.mutate_scale,
931 "mutate_model_params": spec.mutate_model_params,
932 "diagnostics_interval": spec.diagnostics_interval,
933 "min_prob": spec.min_prob,
934 "seed": spec.seed,
935 })
936}
937
938pub fn mixture_expert_spec_to_json_value(
940 spec: &MixtureExpertSpec,
941) -> SpecResult<serde_json::Value> {
942 let mut value = rate_backend_to_json_value(&spec.backend)?;
943 let object = value
944 .as_object_mut()
945 .expect("rate backend serialization must produce a JSON object");
946 if let Some(name) = &spec.name {
947 object.insert("name".to_string(), serde_json::Value::String(name.clone()));
948 }
949 object.insert("log_prior".to_string(), serde_json::json!(spec.log_prior));
950 Ok(value)
951}
952
953pub fn mixture_spec_to_json_value(spec: &MixtureSpec) -> SpecResult<serde_json::Value> {
955 let mut experts = Vec::with_capacity(spec.experts.len());
956 for expert in &spec.experts {
957 experts.push(mixture_expert_spec_to_json_value(expert)?);
958 }
959 Ok(serde_json::json!({
960 "kind": mixture_kind_name(spec.kind),
961 "schedule": mixture_schedule_name(spec.schedule),
962 "alpha": spec.alpha,
963 "decay": spec.decay,
964 "experts": experts,
965 }))
966}
967
968pub fn calibrated_spec_to_json_value(spec: &CalibratedSpec) -> SpecResult<serde_json::Value> {
970 Ok(serde_json::json!({
971 "base": rate_backend_to_json_value(&spec.base)?,
972 "context": calibration_context_kind_name(spec.context),
973 "bins": spec.bins,
974 "learning_rate": spec.learning_rate,
975 "bias_clip": spec.bias_clip,
976 }))
977}
978
979fn rate_backend_to_json_leaf_value(
980 canonical: &str,
981 backend: &RateBackend,
982) -> Option<SpecResult<serde_json::Value>> {
983 match backend {
984 RateBackend::RosaPlus { max_order } => Some(Ok(serde_json::json!({
985 "kind": canonical,
986 "max_order": max_order,
987 }))),
988 RateBackend::Match {
989 hash_bits,
990 min_len,
991 max_len,
992 base_mix,
993 confidence_scale,
994 } => Some(Ok(serde_json::json!({
995 "kind": canonical,
996 "hash_bits": hash_bits,
997 "min_len": min_len,
998 "max_len": max_len,
999 "base_mix": base_mix,
1000 "confidence_scale": confidence_scale,
1001 }))),
1002 RateBackend::SparseMatch {
1003 hash_bits,
1004 min_len,
1005 max_len,
1006 gap_min,
1007 gap_max,
1008 base_mix,
1009 confidence_scale,
1010 } => Some(Ok(serde_json::json!({
1011 "kind": canonical,
1012 "hash_bits": hash_bits,
1013 "min_len": min_len,
1014 "max_len": max_len,
1015 "gap_min": gap_min,
1016 "gap_max": gap_max,
1017 "base_mix": base_mix,
1018 "confidence_scale": confidence_scale,
1019 }))),
1020 RateBackend::Ppmd { order, memory_mb } => Some(Ok(serde_json::json!({
1021 "kind": canonical,
1022 "order": order,
1023 "memory_mb": memory_mb,
1024 }))),
1025 RateBackend::Sequitur { context_bytes } => Some(Ok(serde_json::json!({
1026 "kind": canonical,
1027 "context_bytes": context_bytes,
1028 }))),
1029 RateBackend::Zpaq { method } => Some(Ok(serde_json::json!({
1030 "kind": canonical,
1031 "method": zpaq_method_to_json_value(method),
1032 }))),
1033 RateBackend::Ctw { depth } => Some(Ok(serde_json::json!({
1034 "kind": canonical,
1035 "depth": depth,
1036 }))),
1037 RateBackend::FacCtw {
1038 base_depth,
1039 num_percept_bits,
1040 encoding_bits,
1041 msb_first,
1042 } => {
1043 let mut value = serde_json::json!({
1044 "kind": canonical,
1045 "base_depth": base_depth,
1046 "num_percept_bits": num_percept_bits,
1047 "encoding_bits": encoding_bits,
1048 });
1049 if let Some(msb_first) = msb_first {
1050 value["msb_first"] = serde_json::Value::Bool(*msb_first);
1051 }
1052 Some(Ok(value))
1053 }
1054 _ => None,
1055 }
1056}
1057
1058pub fn rate_backend_to_json_value(backend: &RateBackend) -> SpecResult<serde_json::Value> {
1060 let canonical = backend.descriptor().map_err(SpecError::new)?.canonical;
1061 if let Some(value) = rate_backend_to_json_leaf_value(canonical, backend) {
1062 return value;
1063 }
1064 match backend {
1065 #[cfg(feature = "backend-mamba")]
1066 RateBackend::MambaMethod { method } => Ok(serde_json::json!({
1067 "kind": canonical,
1068 "method": mamba_method_to_json_value(method)?,
1069 })),
1070 #[cfg(feature = "backend-rwkv")]
1071 RateBackend::Rwkv7Method { method } => Ok(serde_json::json!({
1072 "kind": canonical,
1073 "method": rwkv_method_to_json_value(method)?,
1074 })),
1075 RateBackend::Mixture { spec } => Ok(serde_json::json!({
1076 "kind": canonical,
1077 "spec": mixture_spec_to_json_value(spec.as_ref())?,
1078 })),
1079 RateBackend::Particle { spec } => Ok(serde_json::json!({
1080 "kind": canonical,
1081 "spec": particle_spec_to_json_value(spec.as_ref()),
1082 })),
1083 RateBackend::Calibrated { spec } => Ok(serde_json::json!({
1084 "kind": canonical,
1085 "spec": calibrated_spec_to_json_value(spec.as_ref())?,
1086 })),
1087 _ => Err(SpecError::new(
1088 "internal backend serialization mismatch for current feature set",
1089 )),
1090 }
1091}
1092
1093pub fn compression_backend_to_json_value(
1095 backend: &CompressionBackend,
1096) -> SpecResult<serde_json::Value> {
1097 let canonical = backend.descriptor().map_err(SpecError::new)?.canonical;
1098 match backend {
1099 CompressionBackend::Zpaq { method, threads } => Ok(serde_json::json!({
1100 "kind": canonical,
1101 "method": zpaq_method_to_json_value(method),
1102 "threads": threads.get(),
1103 })),
1104 #[cfg(feature = "backend-rwkv")]
1105 CompressionBackend::Rwkv7 { method, coder } => Ok(serde_json::json!({
1106 "kind": canonical,
1107 "method": rwkv_method_to_json_value(method)?,
1108 "coder": match coder {
1109 crate::coders::CoderType::AC => "ac",
1110 crate::coders::CoderType::RANS => "rans",
1111 },
1112 })),
1113 CompressionBackend::Rate {
1114 rate_backend,
1115 coder: _,
1116 framing,
1117 } => Ok(serde_json::json!({
1118 "kind": canonical,
1119 "rate_backend": rate_backend_to_json_value(rate_backend)?,
1120 "framing": framing_mode_name(*framing),
1121 })),
1122 }
1123}
1124
1125pub fn parse_particle_spec_value(v: &serde_json::Value) -> SpecResult<ParticleSpec> {
1127 if v.get("experts").is_some() {
1128 return Err(SpecError::new(
1129 "looks like a mixture spec (found 'experts'); expected ParticleSpec JSON",
1130 ));
1131 }
1132 if let Some(kind) = v.get("kind").and_then(|k| k.as_str()) {
1133 let k = kind.to_ascii_lowercase();
1134 if matches!(
1135 k.as_str(),
1136 "bayes"
1137 | "fading"
1138 | "fading-bayes"
1139 | "switch"
1140 | "switching"
1141 | "mdl"
1142 | "neural"
1143 | "mixture"
1144 ) {
1145 return Err(SpecError::new(format!(
1146 "looks like a mixture spec (kind='{kind}'); expected ParticleSpec JSON"
1147 )));
1148 }
1149 }
1150 let d = ParticleSpec::default();
1151 Ok(ParticleSpec {
1152 num_particles: v["num_particles"]
1153 .as_u64()
1154 .unwrap_or(d.num_particles as u64) as usize,
1155 context_window: v["context_window"]
1156 .as_u64()
1157 .unwrap_or(d.context_window as u64) as usize,
1158 unroll_steps: v["unroll_steps"].as_u64().unwrap_or(d.unroll_steps as u64) as usize,
1159 num_cells: v["num_cells"].as_u64().unwrap_or(d.num_cells as u64) as usize,
1160 cell_dim: v["cell_dim"].as_u64().unwrap_or(d.cell_dim as u64) as usize,
1161 num_rules: v["num_rules"].as_u64().unwrap_or(d.num_rules as u64) as usize,
1162 selector_hidden: v["selector_hidden"]
1163 .as_u64()
1164 .unwrap_or(d.selector_hidden as u64) as usize,
1165 rule_hidden: v["rule_hidden"].as_u64().unwrap_or(d.rule_hidden as u64) as usize,
1166 noise_dim: v["noise_dim"].as_u64().unwrap_or(d.noise_dim as u64) as usize,
1167 deterministic: v["deterministic"].as_bool().unwrap_or(d.deterministic),
1168 enable_noise: v["enable_noise"].as_bool().unwrap_or(d.enable_noise),
1169 noise_scale: v["noise_scale"].as_f64().unwrap_or(d.noise_scale),
1170 noise_anneal_steps: v["noise_anneal_steps"]
1171 .as_u64()
1172 .unwrap_or(d.noise_anneal_steps as u64) as usize,
1173 learning_rate_readout: v["learning_rate_readout"]
1174 .as_f64()
1175 .unwrap_or(d.learning_rate_readout),
1176 learning_rate_selector: v["learning_rate_selector"]
1177 .as_f64()
1178 .unwrap_or(d.learning_rate_selector),
1179 learning_rate_rule: v["learning_rate_rule"]
1180 .as_f64()
1181 .unwrap_or(d.learning_rate_rule),
1182 bptt_depth: v["bptt_depth"].as_u64().unwrap_or(d.bptt_depth as u64) as usize,
1183 optimizer_momentum: v["optimizer_momentum"]
1184 .as_f64()
1185 .unwrap_or(d.optimizer_momentum),
1186 grad_clip: v["grad_clip"].as_f64().unwrap_or(d.grad_clip),
1187 state_clip: v["state_clip"].as_f64().unwrap_or(d.state_clip),
1188 forget_lambda: v["forget_lambda"].as_f64().unwrap_or(d.forget_lambda),
1189 resample_threshold: v["resample_threshold"]
1190 .as_f64()
1191 .unwrap_or(d.resample_threshold),
1192 mutate_fraction: v["mutate_fraction"].as_f64().unwrap_or(d.mutate_fraction),
1193 mutate_scale: v["mutate_scale"].as_f64().unwrap_or(d.mutate_scale),
1194 mutate_model_params: v["mutate_model_params"]
1195 .as_bool()
1196 .unwrap_or(d.mutate_model_params),
1197 diagnostics_interval: v["diagnostics_interval"]
1198 .as_u64()
1199 .unwrap_or(d.diagnostics_interval as u64) as usize,
1200 min_prob: v["min_prob"].as_f64().unwrap_or(d.min_prob),
1201 seed: v["seed"].as_u64().unwrap_or(d.seed),
1202 })
1203}
1204
1205pub fn parse_rate_backend_json(
1207 v: &serde_json::Value,
1208 base_dir: &Path,
1209 depth: usize,
1210) -> SpecResult<RateBackend> {
1211 if depth == 0 {
1212 return Err(SpecError::new("backend spec nesting too deep"));
1213 }
1214
1215 let raw_kind = v["kind"]
1216 .as_str()
1217 .ok_or_else(|| SpecError::new("backend spec missing 'kind'"))?;
1218 let kind = resolve_enabled_rate_backend_kind(raw_kind)?;
1219 if let Some(backend) = parse_rate_backend_json_leaf(kind, v)? {
1220 return Ok(backend);
1221 }
1222
1223 match kind {
1224 crate::runtime::RateBackendKind::Mamba => {
1225 #[cfg(feature = "backend-mamba")]
1226 {
1227 if !v["method"].is_null() {
1228 Ok(RateBackend::MambaMethod {
1229 method: parse_mamba_method_json_value(&v["method"], base_dir)?,
1230 })
1231 } else {
1232 let model_path = v["model_path"].as_str().ok_or_else(|| {
1233 SpecError::new("mamba backend requires 'method' or 'model_path'")
1234 })?;
1235 Ok(RateBackend::MambaMethod {
1236 method: normalize_mamba_path_method(base_dir, model_path)?,
1237 })
1238 }
1239 }
1240 #[cfg(not(feature = "backend-mamba"))]
1241 {
1242 Err(SpecError::new("mamba backend disabled at compile time"))
1243 }
1244 }
1245 crate::runtime::RateBackendKind::Rwkv7 => {
1246 #[cfg(feature = "backend-rwkv")]
1247 {
1248 if !v["method"].is_null() {
1249 Ok(RateBackend::Rwkv7Method {
1250 method: parse_rwkv_method_json_value(&v["method"], base_dir)?,
1251 })
1252 } else {
1253 let model_path = v["model_path"].as_str().ok_or_else(|| {
1254 SpecError::new("rwkv7 backend requires 'method' or 'model_path'")
1255 })?;
1256 Ok(RateBackend::Rwkv7Method {
1257 method: normalize_rwkv_path_method(base_dir, model_path)?,
1258 })
1259 }
1260 }
1261 #[cfg(not(feature = "backend-rwkv"))]
1262 {
1263 Err(SpecError::new("rwkv backend disabled at compile time"))
1264 }
1265 }
1266 crate::runtime::RateBackendKind::Mixture => {
1267 let spec = if let Some(spec_v) = v.get("spec").filter(|value| value.is_object()) {
1268 parse_mixture_spec_value(spec_v, base_dir, depth - 1)?
1269 } else if let Some(path) = v["spec_path"].as_str() {
1270 let full = resolve_spec_path(base_dir, path);
1271 load_mixture_spec_with_depth(full.to_string_lossy().as_ref(), depth - 1)?
1272 } else {
1273 parse_mixture_spec_value(v, base_dir, depth - 1)?
1274 };
1275 Ok(RateBackend::Mixture {
1276 spec: Arc::new(spec),
1277 })
1278 }
1279 crate::runtime::RateBackendKind::Particle => {
1280 let spec = if let Some(spec_v) = v.get("spec").filter(|value| value.is_object()) {
1281 parse_particle_spec_value(spec_v)?
1282 } else if let Some(path) = v["spec_path"].as_str() {
1283 let full = resolve_spec_path(base_dir, path);
1284 load_particle_spec(full.to_string_lossy().as_ref())?
1285 } else {
1286 parse_particle_spec_value(v)?
1287 };
1288 spec.validate()
1289 .map_err(|err| SpecError::new(err.to_string()))?;
1290 Ok(RateBackend::Particle {
1291 spec: Arc::new(spec),
1292 })
1293 }
1294 crate::runtime::RateBackendKind::Calibrated => {
1295 let spec = if let Some(spec_v) = v.get("spec").filter(|value| value.is_object()) {
1296 parse_calibrated_spec_value(spec_v, base_dir, depth - 1)?
1297 } else if let Some(path) = v["spec_path"].as_str() {
1298 let full = resolve_spec_path(base_dir, path);
1299 load_calibrated_spec(full.to_string_lossy().as_ref())?
1300 } else {
1301 parse_calibrated_spec_value(v, base_dir, depth - 1)?
1302 };
1303 Ok(RateBackend::Calibrated {
1304 spec: Arc::new(spec),
1305 })
1306 }
1307 _ => Err(SpecError::new(
1308 "internal backend parse mismatch for current feature set",
1309 )),
1310 }
1311}
1312
1313fn parse_rate_backend_json_leaf(
1314 kind: crate::runtime::RateBackendKind,
1315 v: &serde_json::Value,
1316) -> SpecResult<Option<RateBackend>> {
1317 let backend = match kind {
1318 crate::runtime::RateBackendKind::RosaPlus => RateBackend::RosaPlus {
1319 max_order: v["max_order"]
1320 .as_i64()
1321 .or_else(|| v["order"].as_i64())
1322 .unwrap_or(-1),
1323 },
1324 crate::runtime::RateBackendKind::Ctw => RateBackend::Ctw {
1325 depth: v["depth"]
1326 .as_u64()
1327 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_CTW_DEPTH as u64)
1328 as usize,
1329 },
1330 crate::runtime::RateBackendKind::FacCtw => {
1331 let base_depth = v["base_depth"]
1332 .as_u64()
1333 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_FAC_CTW_BASE_DEPTH as u64)
1334 as usize;
1335 let encoding_bits = v["encoding_bits"]
1336 .as_u64()
1337 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_FAC_CTW_ENCODING_BITS as u64)
1338 as usize;
1339 let num_percept_bits = v["num_percept_bits"]
1340 .as_u64()
1341 .unwrap_or(encoding_bits as u64) as usize;
1342 let msb_first = v.get("msb_first").and_then(serde_json::Value::as_bool);
1343 RateBackend::FacCtw {
1344 base_depth,
1345 num_percept_bits,
1346 encoding_bits,
1347 msb_first,
1348 }
1349 }
1350 crate::runtime::RateBackendKind::Match => RateBackend::Match {
1351 hash_bits: v["hash_bits"]
1352 .as_u64()
1353 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_MATCH_HASH_BITS as u64)
1354 as usize,
1355 min_len: v["min_len"]
1356 .as_u64()
1357 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_MATCH_MIN_LEN as u64)
1358 as usize,
1359 max_len: v["max_len"]
1360 .as_u64()
1361 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_MATCH_MAX_LEN as u64)
1362 as usize,
1363 base_mix: v["base_mix"]
1364 .as_f64()
1365 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_MATCH_BASE_MIX),
1366 confidence_scale: v["confidence_scale"]
1367 .as_f64()
1368 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_MATCH_CONFIDENCE_SCALE),
1369 },
1370 crate::runtime::RateBackendKind::SparseMatch => RateBackend::SparseMatch {
1371 hash_bits: v["hash_bits"]
1372 .as_u64()
1373 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_HASH_BITS as u64)
1374 as usize,
1375 min_len: v["min_len"]
1376 .as_u64()
1377 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_MIN_LEN as u64)
1378 as usize,
1379 max_len: v["max_len"]
1380 .as_u64()
1381 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_MAX_LEN as u64)
1382 as usize,
1383 gap_min: v["gap_min"]
1384 .as_u64()
1385 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_GAP_MIN as u64)
1386 as usize,
1387 gap_max: v["gap_max"]
1388 .as_u64()
1389 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_GAP_MAX as u64)
1390 as usize,
1391 base_mix: v["base_mix"]
1392 .as_f64()
1393 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_BASE_MIX),
1394 confidence_scale: v["confidence_scale"]
1395 .as_f64()
1396 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_CONFIDENCE_SCALE),
1397 },
1398 crate::runtime::RateBackendKind::Ppmd => RateBackend::Ppmd {
1399 order: v["order"]
1400 .as_u64()
1401 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_PPMD_ORDER as u64)
1402 as usize,
1403 memory_mb: v["memory_mb"]
1404 .as_u64()
1405 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_PPMD_MEMORY_MB as u64)
1406 as usize,
1407 },
1408 crate::runtime::RateBackendKind::Sequitur => RateBackend::Sequitur {
1409 context_bytes: v["context_bytes"]
1410 .as_u64()
1411 .unwrap_or(crate::rate_defaults::JSON_DEFAULT_SEQUITUR_CONTEXT_BYTES as u64)
1412 as usize,
1413 },
1414 crate::runtime::RateBackendKind::Zpaq => {
1415 let method = parse_zpaq_method_json_value(
1416 &v["method"],
1417 crate::rate_defaults::JSON_DEFAULT_ZPAQ_RATE_METHOD,
1418 )?;
1419 validate_zpaq_rate_method(method.value())
1420 .map_err(|err| SpecError::new(err.to_string()))?;
1421 RateBackend::Zpaq { method }
1422 }
1423 _ => return Ok(None),
1424 };
1425 Ok(Some(backend))
1426}
1427
1428pub fn parse_compression_backend_json(
1430 v: &serde_json::Value,
1431 base_dir: &Path,
1432 default_rate_backend: Option<RateBackend>,
1433 default_framing: crate::compression::FramingMode,
1434) -> SpecResult<CompressionBackend> {
1435 let raw_kind = v["kind"]
1436 .as_str()
1437 .ok_or_else(|| SpecError::new("compression backend spec missing 'kind'"))?;
1438 let kind = resolve_enabled_compression_backend_kind(raw_kind)?;
1439 let framing = v["framing"]
1440 .as_str()
1441 .map(|value| parse_framing_mode(Some(value)))
1442 .transpose()?
1443 .unwrap_or(default_framing);
1444
1445 match kind {
1446 crate::runtime::CompressionBackendKind::Zpaq => {
1447 let method = parse_zpaq_method_json_value(&v["method"], "5")?;
1448 let threads = if let Some(raw_value) = v.get("threads").filter(|value| !value.is_null())
1449 {
1450 let raw_u64 = raw_value
1451 .as_u64()
1452 .ok_or_else(|| SpecError::new("zpaq threads must be an integer >= 1"))?;
1453 let raw = usize::try_from(raw_u64)
1454 .map_err(|_| SpecError::new("zpaq threads exceeds usize::MAX"))?;
1455 std::num::NonZeroUsize::new(raw)
1456 .ok_or_else(|| SpecError::new("zpaq threads must be >= 1"))?
1457 } else {
1458 std::num::NonZeroUsize::MIN
1459 };
1460 crate::zpaq_compress_to_vec(&[], method.value()).map_err(|err| {
1461 SpecError::new(format!(
1462 "invalid zpaq compression method '{}': {err}",
1463 method.value()
1464 ))
1465 })?;
1466 Ok(CompressionBackend::Zpaq { method, threads })
1467 }
1468 crate::runtime::CompressionBackendKind::RateAc
1469 | crate::runtime::CompressionBackendKind::RateRans => {
1470 let rate_backend = if let Some(rate_backend_v) = v.get("rate_backend") {
1471 parse_rate_backend_json(rate_backend_v, base_dir, MAX_MIXTURE_NESTING)?
1472 } else if let Some(backend_v) = v.get("backend_spec") {
1473 parse_rate_backend_json(backend_v, base_dir, MAX_MIXTURE_NESTING)?
1474 } else {
1475 resolve_default_rate_backend_spec(default_rate_backend.clone())?
1476 };
1477 let coder = if kind == crate::runtime::CompressionBackendKind::RateAc {
1478 crate::coders::CoderType::AC
1479 } else {
1480 crate::coders::CoderType::RANS
1481 };
1482 Ok(CompressionBackend::Rate {
1483 rate_backend,
1484 coder,
1485 framing,
1486 })
1487 }
1488 crate::runtime::CompressionBackendKind::Rwkv7 => {
1489 #[cfg(feature = "backend-rwkv")]
1490 {
1491 let coder = if let Some(value) = v["coder"].as_str() {
1492 crate::backends::parse_rwkv7_coder(value).ok_or_else(|| {
1493 SpecError::new("rwkv7 compression coder must be 'ac' or 'rans'")
1494 })?
1495 } else {
1496 crate::coders::CoderType::AC
1497 };
1498 let parsed_method = if !v["method"].is_null() {
1499 Some(parse_rwkv_method_json_value(&v["method"], base_dir)?)
1500 } else {
1501 None
1502 };
1503 let model_path = v["model_path"].as_str();
1504 if parsed_method.is_none() && model_path.is_none() {
1505 Err(SpecError::new(
1506 "rwkv7 compression backend requires 'method' or 'model_path'",
1507 ))
1508 } else if let Some(method) = parsed_method {
1509 Ok(lower_rwkv7_compression_backend_method(
1510 method, coder, framing,
1511 ))
1512 } else {
1513 let opts = CompressionBackendShorthandOptions {
1514 base_dir: base_dir.to_path_buf(),
1515 default_framing: framing,
1516 default_rwkv_model_path: model_path.map(ToOwned::to_owned),
1517 ..Default::default()
1518 };
1519 parse_rwkv7_compression_backend_method(None, coder, &opts)
1520 }
1521 }
1522 #[cfg(not(feature = "backend-rwkv"))]
1523 {
1524 Err(SpecError::new(
1525 "rwkv7 compression backend disabled at compile time",
1526 ))
1527 }
1528 }
1529 }
1530}
1531
1532pub fn parse_calibrated_spec_value(
1534 v: &serde_json::Value,
1535 base_dir: &Path,
1536 depth: usize,
1537) -> SpecResult<CalibratedSpec> {
1538 if depth == 0 {
1539 return Err(SpecError::new("calibrated spec nesting too deep"));
1540 }
1541
1542 let base_backend = if let Some(base_v) = v.get("base") {
1543 parse_rate_backend_json(base_v, base_dir, depth - 1)?
1544 } else if let Some(path) = v["base_path"].as_str() {
1545 let (value, full) = load_json_value_from_path(base_dir, path, "calibrated base backend")?;
1546 parse_rate_backend_json(&value, full.parent().unwrap_or(base_dir), depth - 1)?
1547 } else {
1548 return Err(SpecError::new(
1549 "calibrated backend requires 'base' or 'base_path'",
1550 ));
1551 };
1552
1553 Ok(CalibratedSpec {
1554 base: base_backend,
1555 context: parse_calibration_context_kind(v["context"].as_str())?,
1556 bins: v["bins"].as_u64().unwrap_or(32) as usize,
1557 learning_rate: v["learning_rate"].as_f64().unwrap_or(1.0 / 32.0),
1558 bias_clip: v["bias_clip"].as_f64().unwrap_or(16.0),
1559 })
1560}
1561
1562pub fn parse_mixture_expert_value(
1564 v: &serde_json::Value,
1565 base_dir: &Path,
1566 depth: usize,
1567) -> SpecResult<MixtureExpertSpec> {
1568 if depth == 0 {
1569 return Err(SpecError::new("mixture spec nesting too deep"));
1570 }
1571
1572 let backend = parse_rate_backend_json(v, base_dir, depth - 1)?;
1573
1574 Ok(MixtureExpertSpec {
1575 name: v["name"].as_str().map(|s| s.to_string()),
1576 log_prior: v["log_prior"]
1577 .as_f64()
1578 .or_else(|| v["prior"].as_f64())
1579 .unwrap_or(0.0),
1580 backend,
1581 })
1582}
1583
1584pub fn parse_mixture_spec_value(
1586 v: &serde_json::Value,
1587 base_dir: &Path,
1588 depth: usize,
1589) -> SpecResult<MixtureSpec> {
1590 if depth == 0 {
1591 return Err(SpecError::new("mixture spec nesting too deep"));
1592 }
1593
1594 let kind_str = v["kind"]
1595 .as_str()
1596 .or_else(|| v["mixture_kind"].as_str())
1597 .or_else(|| v["mix_kind"].as_str())
1598 .unwrap_or("bayes");
1599 let kind = parse_mixture_kind(kind_str)?;
1600 let schedule = v["schedule"]
1601 .as_str()
1602 .or_else(|| v["schedule_mode"].as_str())
1603 .or_else(|| v["mixture_schedule"].as_str())
1604 .map(parse_mixture_schedule)
1605 .transpose()?
1606 .unwrap_or(MixtureScheduleMode::Default);
1607
1608 let experts_v = v["experts"]
1609 .as_array()
1610 .ok_or_else(|| SpecError::new("mixture spec missing 'experts' array"))?;
1611 if experts_v.is_empty() {
1612 return Err(SpecError::new(
1613 "mixture spec must include at least one expert",
1614 ));
1615 }
1616
1617 let mut experts = Vec::with_capacity(experts_v.len());
1618 for expert in experts_v {
1619 experts.push(parse_mixture_expert_value(expert, base_dir, depth - 1)?);
1620 }
1621
1622 let mut spec = MixtureSpec::new(kind, experts)
1623 .with_schedule(schedule)
1624 .with_alpha(v["alpha"].as_f64().unwrap_or(0.01));
1625 if let Some(decay) = v["decay"].as_f64() {
1626 spec = spec.with_decay(decay);
1627 }
1628 spec.validate()
1629 .map_err(|err| SpecError::new(err.to_string()))?;
1630 Ok(spec)
1631}
1632
1633fn read_mixture_value_with_zpaq_fallback(path: &Path) -> SpecResult<serde_json::Value> {
1634 let raw = std::fs::read(path).map_err(|e| {
1635 SpecError::new(format!(
1636 "failed to read mixture spec '{}': {e}",
1637 path.display()
1638 ))
1639 })?;
1640 match serde_json::from_slice(&raw) {
1641 Ok(value) => Ok(value),
1642 Err(json_err) => {
1643 #[cfg(feature = "backend-zpaq")]
1644 {
1645 let decompressed = zpaq_rs::decompress_to_vec(&raw).map_err(|_| {
1646 SpecError::new(format!(
1647 "failed to parse mixture JSON '{}': {json_err}",
1648 path.display()
1649 ))
1650 })?;
1651 serde_json::from_slice(&decompressed).map_err(|e| {
1652 SpecError::new(format!("invalid mixture JSON '{}': {e}", path.display()))
1653 })
1654 }
1655 #[cfg(not(feature = "backend-zpaq"))]
1656 {
1657 Err(SpecError::new(format!(
1658 "failed to parse mixture JSON '{}', and zpaq support is disabled at compile time: {json_err}",
1659 path.display()
1660 )))
1661 }
1662 }
1663 }
1664}
1665
1666pub fn load_mixture_spec(path: &str) -> SpecResult<MixtureSpec> {
1668 load_mixture_spec_with_depth(path, MAX_MIXTURE_NESTING)
1669}
1670
1671pub fn load_mixture_spec_with_depth(path: &str, depth: usize) -> SpecResult<MixtureSpec> {
1673 let full = Path::new(path);
1674 let value = read_mixture_value_with_zpaq_fallback(full)?;
1675 let base_dir = full.parent().unwrap_or_else(|| Path::new("."));
1676 parse_mixture_spec_value(&value, base_dir, depth)
1677}
1678
1679pub fn load_particle_spec(path: &str) -> SpecResult<ParticleSpec> {
1681 let full = Path::new(path);
1682 let raw = std::fs::read(full).map_err(|e| {
1683 SpecError::new(format!(
1684 "failed to read particle spec '{}': {e}",
1685 full.display()
1686 ))
1687 })?;
1688 let value: serde_json::Value = serde_json::from_slice(&raw).map_err(|e| {
1689 SpecError::new(format!(
1690 "invalid particle spec JSON '{}': {e}",
1691 full.display()
1692 ))
1693 })?;
1694 let spec = parse_particle_spec_value(&value)?;
1695 spec.validate()
1696 .map_err(|err| SpecError::new(err.to_string()))?;
1697 Ok(spec)
1698}
1699
1700pub fn load_calibrated_spec(path: &str) -> SpecResult<CalibratedSpec> {
1702 let full = Path::new(path);
1703 let raw = std::fs::read(full).map_err(|e| {
1704 SpecError::new(format!(
1705 "failed to read calibrated spec '{}': {e}",
1706 full.display()
1707 ))
1708 })?;
1709 let value: serde_json::Value = serde_json::from_slice(&raw).map_err(|e| {
1710 SpecError::new(format!(
1711 "invalid calibrated spec JSON '{}': {e}",
1712 full.display()
1713 ))
1714 })?;
1715 let base_dir = full.parent().unwrap_or_else(|| Path::new("."));
1716 parse_calibrated_spec_value(&value, base_dir, MAX_MIXTURE_NESTING)
1717}
1718
1719pub fn load_expert_spec(path: &str) -> SpecResult<MixtureExpertSpec> {
1721 let full = Path::new(path);
1722 let raw = std::fs::read(full).map_err(|e| {
1723 SpecError::new(format!(
1724 "failed to read expert spec '{}': {e}",
1725 full.display()
1726 ))
1727 })?;
1728 let value: serde_json::Value = serde_json::from_slice(&raw).map_err(|e| {
1729 SpecError::new(format!(
1730 "invalid expert spec JSON '{}': {e}",
1731 full.display()
1732 ))
1733 })?;
1734 let base_dir = full.parent().unwrap_or_else(|| Path::new("."));
1735 parse_mixture_expert_value(&value, base_dir, MAX_MIXTURE_NESTING)
1736}
1737
1738pub fn parse_rate_backend_name_method(
1740 name: &str,
1741 method: Option<&str>,
1742 options: &RateBackendShorthandOptions,
1743) -> SpecResult<RateBackend> {
1744 let kind = resolve_enabled_rate_backend_kind(name)?;
1745 let method = method.filter(|value| !value.is_empty());
1746 if let Some(backend) = parse_rate_backend_name_method_leaf(kind, method, options)? {
1747 return Ok(backend);
1748 }
1749
1750 match kind {
1751 crate::runtime::RateBackendKind::Mamba => {
1752 #[cfg(feature = "backend-mamba")]
1753 {
1754 if let Some(method) = method {
1755 Ok(RateBackend::MambaMethod {
1756 method: normalize_mamba_method_for_base_dir(&options.base_dir, method)?,
1757 })
1758 } else if let Some(path) = options.default_mamba_model_path.as_deref() {
1759 Ok(RateBackend::MambaMethod {
1760 method: normalize_mamba_path_method(&options.base_dir, path)?,
1761 })
1762 } else {
1763 Err(SpecError::new(
1764 "mamba backend requires method string (cfg:...;policy:... or file:...) or a configured model path",
1765 ))
1766 }
1767 }
1768 #[cfg(not(feature = "backend-mamba"))]
1769 {
1770 Err(SpecError::new("mamba backend disabled at compile time"))
1771 }
1772 }
1773 crate::runtime::RateBackendKind::Rwkv7 => {
1774 #[cfg(feature = "backend-rwkv")]
1775 {
1776 if let Some(method) = method {
1777 Ok(RateBackend::Rwkv7Method {
1778 method: normalize_rwkv_method_for_base_dir(&options.base_dir, method)?,
1779 })
1780 } else if let Some(path) = options.default_rwkv_model_path.as_deref() {
1781 Ok(RateBackend::Rwkv7Method {
1782 method: normalize_rwkv_path_method(&options.base_dir, path)?,
1783 })
1784 } else {
1785 Err(SpecError::new(
1786 "rwkv backend requires method string (cfg:...;policy:... or file:...) or a configured model path",
1787 ))
1788 }
1789 }
1790 #[cfg(not(feature = "backend-rwkv"))]
1791 {
1792 Err(SpecError::new("rwkv backend disabled at compile time"))
1793 }
1794 }
1795 crate::runtime::RateBackendKind::Mixture => {
1796 let path = method.ok_or_else(|| {
1797 SpecError::new("mixture backend requires a path to a MixtureSpec JSON file")
1798 })?;
1799 let full = resolve_spec_path(&options.base_dir, path);
1800 Ok(RateBackend::Mixture {
1801 spec: Arc::new(load_mixture_spec(full.to_string_lossy().as_ref())?),
1802 })
1803 }
1804 crate::runtime::RateBackendKind::Particle => {
1805 let spec = if let Some(path) = method {
1806 let full = resolve_spec_path(&options.base_dir, path);
1807 load_particle_spec(full.to_string_lossy().as_ref())?
1808 } else if options.particle_default_if_missing_method {
1809 ParticleSpec::default()
1810 } else {
1811 return Err(SpecError::new(
1812 "particle backend requires a path to a ParticleSpec JSON file",
1813 ));
1814 };
1815 spec.validate()
1816 .map_err(|err| SpecError::new(err.to_string()))?;
1817 Ok(RateBackend::Particle {
1818 spec: Arc::new(spec),
1819 })
1820 }
1821 crate::runtime::RateBackendKind::Calibrated => {
1822 let path = method.ok_or_else(|| {
1823 SpecError::new("calibrated backend requires a path to a CalibratedSpec JSON file")
1824 })?;
1825 let full = resolve_spec_path(&options.base_dir, path);
1826 Ok(RateBackend::Calibrated {
1827 spec: Arc::new(load_calibrated_spec(full.to_string_lossy().as_ref())?),
1828 })
1829 }
1830 _ => Err(SpecError::new(
1831 "internal backend shorthand parse mismatch for current feature set",
1832 )),
1833 }
1834}
1835
1836fn parse_rate_backend_name_method_leaf(
1837 kind: crate::runtime::RateBackendKind,
1838 method: Option<&str>,
1839 options: &RateBackendShorthandOptions,
1840) -> SpecResult<Option<RateBackend>> {
1841 let backend = match kind {
1842 crate::runtime::RateBackendKind::RosaPlus => RateBackend::RosaPlus {
1843 max_order: method
1844 .and_then(|value| value.parse::<i64>().ok())
1845 .unwrap_or(-1),
1846 },
1847 crate::runtime::RateBackendKind::Match => RateBackend::Match {
1848 hash_bits: crate::rate_defaults::JSON_DEFAULT_MATCH_HASH_BITS,
1849 min_len: crate::rate_defaults::JSON_DEFAULT_MATCH_MIN_LEN,
1850 max_len: crate::rate_defaults::JSON_DEFAULT_MATCH_MAX_LEN,
1851 base_mix: crate::rate_defaults::JSON_DEFAULT_MATCH_BASE_MIX,
1852 confidence_scale: crate::rate_defaults::JSON_DEFAULT_MATCH_CONFIDENCE_SCALE,
1853 },
1854 crate::runtime::RateBackendKind::SparseMatch => RateBackend::SparseMatch {
1855 hash_bits: crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_HASH_BITS,
1856 min_len: crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_MIN_LEN,
1857 max_len: crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_MAX_LEN,
1858 gap_min: crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_GAP_MIN,
1859 gap_max: crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_GAP_MAX,
1860 base_mix: crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_BASE_MIX,
1861 confidence_scale: crate::rate_defaults::JSON_DEFAULT_SPARSE_MATCH_CONFIDENCE_SCALE,
1862 },
1863 crate::runtime::RateBackendKind::Ppmd => RateBackend::Ppmd {
1864 order: method
1865 .and_then(|value| value.parse::<usize>().ok())
1866 .unwrap_or(options.ppmd_order),
1867 memory_mb: options.ppmd_memory_mb,
1868 },
1869 crate::runtime::RateBackendKind::Sequitur => RateBackend::Sequitur {
1870 context_bytes: method
1871 .and_then(|value| value.parse::<usize>().ok())
1872 .unwrap_or(options.sequitur_context_bytes),
1873 },
1874 crate::runtime::RateBackendKind::Ctw => RateBackend::Ctw {
1875 depth: method
1876 .and_then(|value| value.parse::<usize>().ok())
1877 .unwrap_or(options.ctw_depth),
1878 },
1879 crate::runtime::RateBackendKind::FacCtw => {
1880 let base_depth = method
1881 .and_then(|value| value.parse::<usize>().ok())
1882 .unwrap_or(options.fac_ctw_base_depth);
1883 RateBackend::FacCtw {
1884 base_depth,
1885 num_percept_bits: options.fac_ctw_num_percept_bits,
1886 encoding_bits: options.fac_ctw_encoding_bits,
1887 msb_first: options.fac_ctw_msb_first,
1888 }
1889 }
1890 crate::runtime::RateBackendKind::Zpaq => {
1891 let method = method
1892 .map(ToOwned::to_owned)
1893 .unwrap_or_else(|| options.zpaq_method.clone());
1894 validate_zpaq_rate_method(&method).map_err(|err| SpecError::new(err.to_string()))?;
1895 RateBackend::Zpaq {
1896 method: crate::api::ZpaqMethodSpec::literal(method),
1897 }
1898 }
1899 _ => return Ok(None),
1900 };
1901 Ok(Some(backend))
1902}
1903
1904pub fn compile_rate_backend_name_method(
1906 name: &str,
1907 method: Option<&str>,
1908 options: &RateBackendShorthandOptions,
1909) -> SpecResult<CompiledRateBackend> {
1910 let env = SpecEnvironment::new(options.base_dir.clone());
1911 parse_rate_backend_name_method(name, method, options)?.compile_in(&env)
1912}
1913
1914pub fn parse_compression_backend_name_method(
1917 name: &str,
1918 method: Option<&str>,
1919 rate_backend: Option<RateBackend>,
1920 options: &CompressionBackendShorthandOptions,
1921) -> SpecResult<CompressionBackend> {
1922 let kind = resolve_enabled_compression_backend_kind(name)?;
1923 let method = method.filter(|value| !value.is_empty());
1924
1925 match kind {
1926 crate::runtime::CompressionBackendKind::Zpaq => {
1927 let method = method
1928 .map(ToOwned::to_owned)
1929 .unwrap_or_else(|| options.zpaq_method.clone());
1930 crate::zpaq_compress_to_vec(&[], &method).map_err(|err| {
1931 SpecError::new(format!("invalid zpaq compression method '{method}': {err}"))
1932 })?;
1933 Ok(CompressionBackend::zpaq(method))
1934 }
1935 crate::runtime::CompressionBackendKind::RateAc => Ok(CompressionBackend::Rate {
1936 rate_backend: rate_backend
1937 .or_else(|| options.default_rate_backend.clone())
1938 .map(Ok)
1939 .unwrap_or_else(|| resolve_default_rate_backend_spec(None))?,
1940 coder: crate::coders::CoderType::AC,
1941 framing: options.default_framing,
1942 }),
1943 crate::runtime::CompressionBackendKind::RateRans => Ok(CompressionBackend::Rate {
1944 rate_backend: rate_backend
1945 .or_else(|| options.default_rate_backend.clone())
1946 .map(Ok)
1947 .unwrap_or_else(|| resolve_default_rate_backend_spec(None))?,
1948 coder: crate::coders::CoderType::RANS,
1949 framing: options.default_framing,
1950 }),
1951 crate::runtime::CompressionBackendKind::Rwkv7 => {
1952 #[cfg(feature = "backend-rwkv")]
1953 {
1954 match method {
1955 Some(m) if crate::backends::parse_rwkv7_coder(m).is_some() => {
1956 let model_path = options.default_rwkv_model_path.as_deref().ok_or_else(|| {
1957 SpecError::new(
1958 "rwkv7 compression backend requires a configured model path when only a coder alias is provided",
1959 )
1960 })?;
1961 Ok(CompressionBackend::Rwkv7 {
1962 method: normalize_rwkv_path_method(&options.base_dir, model_path)?,
1963 coder: crate::backends::parse_rwkv7_coder(m)
1964 .expect("coder alias already validated"),
1965 })
1966 }
1967 Some(m) => parse_rwkv7_compression_backend_method(
1968 Some(m),
1969 crate::coders::CoderType::AC,
1970 options,
1971 ),
1972 None => parse_rwkv7_compression_backend_method(
1973 None,
1974 crate::coders::CoderType::AC,
1975 options,
1976 ),
1977 }
1978 }
1979 #[cfg(not(feature = "backend-rwkv"))]
1980 {
1981 Err(SpecError::new(
1982 "rwkv7 compression backend disabled at compile time",
1983 ))
1984 }
1985 }
1986 }
1987}
1988
1989pub fn compile_compression_backend_name_method(
1991 name: &str,
1992 method: Option<&str>,
1993 rate_backend: Option<RateBackend>,
1994 options: &CompressionBackendShorthandOptions,
1995) -> SpecResult<CompiledCompressionBackend> {
1996 let env = SpecEnvironment::new(options.base_dir.clone());
1997 parse_compression_backend_name_method(name, method, rate_backend, options)?.compile_in(&env)
1998}
1999
2000#[cfg(test)]
2001mod tests {
2002 #[allow(unused_imports)]
2003 use super::*;
2004 use crate::api::{
2005 CalibratedSpec, CalibrationContextKind, CompressionBackend, MAX_MIXTURE_NESTING,
2006 MixtureExpertSpec, MixtureKind, MixtureSpec, RateBackend,
2007 };
2008 #[cfg(any(
2009 feature = "all-backends",
2010 feature = "backend-rwkv",
2011 feature = "backend-mamba"
2012 ))]
2013 use crate::coders::CoderType;
2014 use std::fs;
2015 use std::path::Path;
2016 use std::sync::Arc;
2017 use std::time::{SystemTime, UNIX_EPOCH};
2018
2019 fn unique_temp_dir(label: &str) -> std::path::PathBuf {
2020 let nonce = SystemTime::now()
2021 .duration_since(UNIX_EPOCH)
2022 .expect("system clock must be after unix epoch")
2023 .as_nanos();
2024 std::env::temp_dir().join(format!(
2025 "infotheory-spec-tests-{label}-{}-{nonce}",
2026 std::process::id()
2027 ))
2028 }
2029
2030 fn sample_self_contained_rate_backend(
2031 kind: crate::runtime::RateBackendKind,
2032 ) -> Option<RateBackend> {
2033 crate::runtime::default_rate_backend_spec(kind)
2034 }
2035
2036 #[cfg(feature = "aixi")]
2037 #[test]
2038 fn canonical_json_bytes_sort_object_keys_recursively_and_preserve_array_order() {
2039 let value = serde_json::json!({
2040 "b": 1,
2041 "a": {
2042 "z": [2, 1],
2043 "a": false
2044 },
2045 "c": [
2046 {
2047 "b": 2,
2048 "a": 1
2049 },
2050 null
2051 ]
2052 });
2053 let bytes = canonical_json_bytes(&value).expect("canonical JSON bytes");
2054 assert_eq!(
2055 std::str::from_utf8(&bytes).expect("canonical JSON is UTF-8"),
2056 r#"{"a":{"a":false,"z":[2,1]},"b":1,"c":[{"a":1,"b":2},null]}"#
2057 );
2058 }
2059
2060 #[cfg(feature = "backend-rosa")]
2061 #[test]
2062 fn shorthand_rate_aliases_compile_to_identical_canonical_bytes() {
2063 let opts = RateBackendShorthandOptions::default();
2064 let rosa =
2065 compile_rate_backend_name_method("rosa", None, &opts).expect("compile rosa alias");
2066 let rosaplus = compile_rate_backend_name_method("rosaplus", None, &opts)
2067 .expect("compile rosaplus canonical");
2068 assert_eq!(
2069 rosa.canonical_bytes().as_slice(),
2070 rosaplus.canonical_bytes().as_slice()
2071 );
2072 assert_eq!(
2073 rosa.canonical_spec().to_canonical_json().unwrap(),
2074 rosaplus.canonical_spec().to_canonical_json().unwrap()
2075 );
2076 }
2077
2078 #[test]
2079 fn shorthand_compression_requires_canonical_names() {
2080 let Some(default_rate_backend) = sample_enabled_leaf_rate_backend() else {
2081 return;
2082 };
2083 let opts = CompressionBackendShorthandOptions {
2084 default_rate_backend: Some(default_rate_backend),
2085 ..CompressionBackendShorthandOptions::default()
2086 };
2087 let canonical = compile_compression_backend_name_method("rate-ac", None, None, &opts)
2088 .expect("compile rate-ac canonical");
2089 let canonical_kind = canonical.canonical_spec().kind();
2090 assert_eq!(
2091 canonical_kind,
2092 crate::runtime::CompressionBackendKind::RateAc
2093 );
2094
2095 let err = match compile_compression_backend_name_method("rate_ac", None, None, &opts) {
2096 Ok(_) => panic!("legacy alias must be rejected"),
2097 Err(err) => err,
2098 };
2099 let msg = err.to_string();
2100 assert!(
2101 msg.contains("unknown compression backend") || msg.contains("not available"),
2102 "unexpected error: {msg}"
2103 );
2104 }
2105
2106 fn sample_enabled_leaf_rate_backend() -> Option<RateBackend> {
2107 crate::runtime::RATE_BACKEND_REGISTRY
2108 .iter()
2109 .filter(|descriptor| descriptor.enabled)
2110 .find_map(|descriptor| sample_self_contained_rate_backend(descriptor.kind))
2111 }
2112
2113 fn sample_roundtrip_rate_backends() -> Vec<RateBackend> {
2114 let mut backends = Vec::new();
2115 let leaf = sample_enabled_leaf_rate_backend();
2116
2117 for descriptor in crate::runtime::RATE_BACKEND_REGISTRY {
2118 if !descriptor.enabled {
2119 continue;
2120 }
2121
2122 match descriptor.kind {
2123 crate::runtime::RateBackendKind::Mixture => {
2124 if let Some(base) = leaf.clone() {
2125 backends.push(RateBackend::Mixture {
2126 spec: Arc::new(MixtureSpec::new(
2127 MixtureKind::Bayes,
2128 vec![MixtureExpertSpec {
2129 name: Some("leaf".to_string()),
2130 log_prior: 0.0,
2131 backend: base,
2132 }],
2133 )),
2134 });
2135 }
2136 }
2137 crate::runtime::RateBackendKind::Calibrated => {
2138 if let Some(base) = leaf.clone() {
2139 backends.push(RateBackend::Calibrated {
2140 spec: Arc::new(CalibratedSpec {
2141 base,
2142 context: CalibrationContextKind::Text,
2143 bins: 17,
2144 learning_rate: 0.05,
2145 bias_clip: 3.0,
2146 }),
2147 });
2148 }
2149 }
2150 _ => {
2151 if let Some(backend) = sample_self_contained_rate_backend(descriptor.kind) {
2152 backends.push(backend);
2153 }
2154 }
2155 }
2156 }
2157
2158 backends
2159 }
2160
2161 fn sample_roundtrip_compression_backends() -> Vec<CompressionBackend> {
2162 let mut backends = Vec::new();
2163 let leaf = sample_enabled_leaf_rate_backend();
2164
2165 if cfg!(feature = "backend-zpaq") {
2166 backends.push(CompressionBackend::zpaq("5"));
2167 }
2168
2169 if let Some(rate_backend) = leaf.clone() {
2170 backends.push(CompressionBackend::Rate {
2171 rate_backend: rate_backend.clone(),
2172 coder: crate::coders::CoderType::AC,
2173 framing: crate::compression::FramingMode::Raw,
2174 });
2175 backends.push(CompressionBackend::Rate {
2176 rate_backend,
2177 coder: crate::coders::CoderType::RANS,
2178 framing: crate::compression::FramingMode::Framed,
2179 });
2180 }
2181
2182 #[cfg(feature = "backend-rwkv")]
2183 {
2184 let opts = CompressionBackendShorthandOptions {
2185 default_framing: crate::compression::FramingMode::Raw,
2186 ..Default::default()
2187 };
2188 let backend = parse_compression_backend_name_method(
2189 "rwkv7",
2190 Some(
2191 "cfg:hidden=64,intermediate=64,layers=1,train=sgd,lr=0.01;policy:schedule=0..100:infer",
2192 ),
2193 None,
2194 &opts,
2195 )
2196 .expect("rwkv shorthand should parse");
2197 backends.push(backend);
2198 }
2199
2200 backends
2201 }
2202
2203 #[test]
2204 fn rate_backend_aliases_share_registry_resolution_and_feature_errors() {
2205 for descriptor in crate::runtime::RATE_BACKEND_REGISTRY {
2206 for alias in descriptor.aliases {
2207 if descriptor.enabled {
2208 assert_eq!(
2209 resolve_enabled_rate_backend_name(alias)
2210 .expect("enabled alias should resolve"),
2211 descriptor.canonical
2212 );
2213 } else {
2214 let err = resolve_enabled_rate_backend_name(alias)
2215 .expect_err("disabled alias should fail");
2216 let message = err.to_string();
2217 assert!(message.contains(descriptor.canonical));
2218 assert!(
2219 message.contains(descriptor.feature.expect("disabled feature metadata"))
2220 );
2221 }
2222 }
2223 }
2224 }
2225
2226 #[test]
2227 fn compression_backend_aliases_share_registry_resolution_and_feature_errors() {
2228 for descriptor in crate::runtime::COMPRESSION_BACKEND_REGISTRY {
2229 for alias in descriptor.aliases {
2230 if descriptor.enabled {
2231 assert_eq!(
2232 resolve_enabled_compression_backend_name(alias)
2233 .expect("enabled alias should resolve"),
2234 descriptor.canonical
2235 );
2236 } else {
2237 let err = resolve_enabled_compression_backend_name(alias)
2238 .expect_err("disabled alias should fail");
2239 let message = err.to_string();
2240 assert!(message.contains(descriptor.canonical));
2241 assert!(
2242 message.contains(descriptor.feature.expect("disabled feature metadata"))
2243 );
2244 }
2245 }
2246 }
2247 }
2248
2249 #[test]
2250 fn enabled_self_contained_rate_backends_validate_and_roundtrip() {
2251 for backend in sample_roundtrip_rate_backends() {
2252 crate::api::validate_rate_backend(&backend).expect("sample backend should validate");
2253 let json = rate_backend_to_json_value(&backend).expect("serialize backend");
2254 let reparsed = parse_rate_backend_json(&json, Path::new("."), MAX_MIXTURE_NESTING)
2255 .expect("parse backend");
2256 let roundtrip = rate_backend_to_json_value(&reparsed).expect("re-serialize backend");
2257 assert_eq!(json, roundtrip);
2258 }
2259 }
2260
2261 #[test]
2262 fn enabled_compression_parse_paths_validate_and_roundtrip() {
2263 for backend in sample_roundtrip_compression_backends() {
2264 crate::api::validate_compression_backend(&backend)
2265 .expect("sample compression backend should validate");
2266 let json =
2267 compression_backend_to_json_value(&backend).expect("serialize compression backend");
2268 let reparsed = parse_compression_backend_json(
2269 &json,
2270 Path::new("."),
2271 None,
2272 crate::compression::FramingMode::Framed,
2273 )
2274 .expect("parse compression backend");
2275 let roundtrip = compression_backend_to_json_value(&reparsed)
2276 .expect("re-serialize compression backend");
2277 assert_eq!(json, roundtrip);
2278 }
2279 }
2280
2281 #[cfg(feature = "all-backends")]
2282 #[test]
2283 fn rate_backend_json_roundtrip_handles_nested_specs() {
2284 let backend = RateBackend::Calibrated {
2285 spec: Arc::new(CalibratedSpec {
2286 base: RateBackend::Mixture {
2287 spec: Arc::new(
2288 MixtureSpec::new(
2289 MixtureKind::Switching,
2290 vec![
2291 MixtureExpertSpec {
2292 name: Some("rosa".to_string()),
2293 log_prior: -0.2,
2294 backend: RateBackend::RosaPlus { max_order: 8 },
2295 },
2296 MixtureExpertSpec {
2297 name: Some("ctw".to_string()),
2298 log_prior: -1.4,
2299 backend: RateBackend::Ctw { depth: 12 },
2300 },
2301 MixtureExpertSpec {
2302 name: Some("particle".to_string()),
2303 log_prior: -2.0,
2304 backend: RateBackend::Particle {
2305 spec: Arc::new(ParticleSpec {
2306 num_particles: 4,
2307 ..ParticleSpec::default()
2308 }),
2309 },
2310 },
2311 ],
2312 )
2313 .with_schedule(MixtureScheduleMode::Theorem)
2314 .with_alpha(0.25),
2315 ),
2316 },
2317 context: CalibrationContextKind::TextRepeat,
2318 bins: 31,
2319 learning_rate: 0.05,
2320 bias_clip: 3.0,
2321 }),
2322 };
2323
2324 let json = rate_backend_to_json_value(&backend).expect("serialize backend");
2325 let reparsed = parse_rate_backend_json(&json, Path::new("."), MAX_MIXTURE_NESTING)
2326 .expect("parse backend");
2327 let roundtrip = rate_backend_to_json_value(&reparsed).expect("re-serialize backend");
2328 assert_eq!(json, roundtrip);
2329 }
2330
2331 #[cfg(feature = "all-backends")]
2332 #[test]
2333 fn compression_backend_json_roundtrip_handles_rate_wrappers() {
2334 let backend = CompressionBackend::Rate {
2335 rate_backend: RateBackend::Match {
2336 hash_bits: 18,
2337 min_len: 5,
2338 max_len: 128,
2339 base_mix: 0.03,
2340 confidence_scale: 0.8,
2341 },
2342 coder: CoderType::RANS,
2343 framing: crate::compression::FramingMode::Framed,
2344 };
2345
2346 let json =
2347 compression_backend_to_json_value(&backend).expect("serialize compression backend");
2348 let reparsed = parse_compression_backend_json(
2349 &json,
2350 Path::new("."),
2351 None,
2352 crate::compression::FramingMode::Framed,
2353 )
2354 .expect("parse compression backend");
2355 let roundtrip =
2356 compression_backend_to_json_value(&reparsed).expect("re-serialize compression backend");
2357 assert_eq!(json, roundtrip);
2358 }
2359
2360 #[cfg(feature = "all-backends")]
2361 #[test]
2362 fn parse_compression_backend_name_method_uses_shared_shorthand_defaults() {
2363 let rate_backend = RateBackend::Ppmd {
2364 order: 7,
2365 memory_mb: 32,
2366 };
2367 let opts = CompressionBackendShorthandOptions {
2368 default_rate_backend: Some(rate_backend.clone()),
2369 default_framing: crate::compression::FramingMode::Framed,
2370 ..Default::default()
2371 };
2372
2373 let ac = parse_compression_backend_name_method("rate-ac", None, None, &opts)
2374 .expect("parse rate-ac");
2375 let rans = parse_compression_backend_name_method("rate-rans", None, None, &opts)
2376 .expect("parse rate-rans");
2377 let zpaq =
2378 parse_compression_backend_name_method("zpaq", None, None, &opts).expect("parse zpaq");
2379
2380 match ac {
2381 CompressionBackend::Rate {
2382 rate_backend: RateBackend::Ppmd { order, memory_mb },
2383 coder,
2384 framing,
2385 } => {
2386 assert_eq!(order, 7);
2387 assert_eq!(memory_mb, 32);
2388 assert_eq!(coder, CoderType::AC);
2389 assert_eq!(framing, crate::compression::FramingMode::Framed);
2390 }
2391 _ => panic!("unexpected rate-ac backend"),
2392 }
2393
2394 match rans {
2395 CompressionBackend::Rate { coder, .. } => {
2396 assert_eq!(coder, CoderType::RANS);
2397 }
2398 _ => panic!("unexpected rate-rans backend"),
2399 }
2400
2401 match zpaq {
2402 CompressionBackend::Zpaq { method, .. } => assert_eq!(method.value(), "5"),
2403 _ => panic!("unexpected zpaq backend"),
2404 }
2405 }
2406
2407 #[cfg(feature = "backend-rwkv")]
2408 #[test]
2409 fn parse_compression_backend_name_method_wraps_rwkv_cfg_methods_as_rate_backend() {
2410 let opts = CompressionBackendShorthandOptions {
2411 default_framing: crate::compression::FramingMode::Raw,
2412 ..Default::default()
2413 };
2414
2415 let backend = parse_compression_backend_name_method(
2416 "rwkv7",
2417 Some(
2418 "cfg:hidden=64,intermediate=64,layers=1,train=sgd,lr=0.01;policy:schedule=0..100:infer",
2419 ),
2420 None,
2421 &opts,
2422 )
2423 .expect("parse rwkv cfg compression backend");
2424
2425 match backend {
2426 CompressionBackend::Rate {
2427 rate_backend: RateBackend::Rwkv7Method { .. },
2428 coder,
2429 framing,
2430 } => {
2431 assert_eq!(coder, CoderType::AC);
2432 assert_eq!(framing, crate::compression::FramingMode::Raw);
2433 }
2434 _ => panic!("expected rate-coded RWKV backend"),
2435 }
2436 }
2437
2438 #[cfg(feature = "backend-rwkv")]
2439 #[test]
2440 fn parse_compression_backend_json_wraps_rwkv_cfg_methods_as_rate_backend() {
2441 let json = serde_json::json!({
2442 "kind": "rwkv7",
2443 "method": "cfg:hidden=64,intermediate=64,layers=1,train=sgd,lr=0.01;policy:schedule=0..100:infer",
2444 "coder": "rans",
2445 "framing": "raw"
2446 });
2447
2448 let backend = parse_compression_backend_json(
2449 &json,
2450 Path::new("."),
2451 None,
2452 crate::compression::FramingMode::Framed,
2453 )
2454 .expect("parse rwkv cfg compression backend json");
2455
2456 match backend {
2457 CompressionBackend::Rate {
2458 rate_backend: RateBackend::Rwkv7Method { .. },
2459 coder,
2460 framing,
2461 } => {
2462 assert_eq!(coder, CoderType::RANS);
2463 assert_eq!(framing, crate::compression::FramingMode::Raw);
2464 }
2465 _ => panic!("expected rate-coded RWKV backend"),
2466 }
2467 }
2468
2469 #[cfg(feature = "backend-rwkv")]
2470 #[test]
2471 fn parse_compression_backend_json_wraps_typed_rwkv_methods_as_rate_backend() {
2472 let json = serde_json::json!({
2473 "kind": "rwkv7",
2474 "method": {
2475 "kind": "online",
2476 "cfg": {
2477 "hidden": 64,
2478 "intermediate": 64,
2479 "layers": 1,
2480 "train_mode": "sgd",
2481 "lr": 0.01
2482 },
2483 "policy": "schedule=0..100:infer"
2484 },
2485 "coder": "ac",
2486 "framing": "framed"
2487 });
2488
2489 let backend = parse_compression_backend_json(
2490 &json,
2491 Path::new("."),
2492 None,
2493 crate::compression::FramingMode::Raw,
2494 )
2495 .expect("parse typed rwkv compression backend json");
2496
2497 match backend {
2498 CompressionBackend::Rate {
2499 rate_backend: RateBackend::Rwkv7Method { .. },
2500 coder,
2501 framing,
2502 } => {
2503 assert_eq!(coder, CoderType::AC);
2504 assert_eq!(framing, crate::compression::FramingMode::Framed);
2505 }
2506 _ => panic!("expected rate-coded RWKV backend"),
2507 }
2508 }
2509
2510 #[cfg(feature = "backend-rwkv")]
2511 #[test]
2512 fn normalize_rwkv_method_for_base_dir_decodes_reserved_file_escapes_once() {
2513 let base_dir = Path::new("/tmp/spec-base");
2514 let method = "file:weights/model%3Bv1%25done.safetensors";
2515 let normalized = canonicalize_explicit_file_method(base_dir, method, "rwkv")
2516 .expect("canonicalize rwkv method")
2517 .expect("file method");
2518 assert_eq!(
2519 normalized,
2520 "file:/tmp/spec-base/weights/model%3Bv1%25done.safetensors"
2521 );
2522 }
2523
2524 #[cfg(all(feature = "backend-rwkv", windows))]
2525 #[test]
2526 fn normalize_rwkv_method_for_base_dir_renders_forward_slashes_on_windows() {
2527 let base_dir = Path::new(r"C:\tmp\spec-base");
2528 let method = "file:weights/model%3Bv1%25done.safetensors";
2529 let normalized = canonicalize_explicit_file_method(base_dir, method, "rwkv")
2530 .expect("canonicalize rwkv method")
2531 .expect("file method");
2532 assert_eq!(
2533 normalized,
2534 "file:C:/tmp/spec-base/weights/model%3Bv1%25done.safetensors"
2535 );
2536 }
2537
2538 #[cfg(feature = "backend-rwkv")]
2539 #[test]
2540 fn normalize_rwkv_method_for_base_dir_rejects_ambiguous_file_suffixes() {
2541 let err = normalize_rwkv_method_for_base_dir(
2542 Path::new("/tmp/spec-base"),
2543 "file:weights/model;polciy:infer",
2544 )
2545 .unwrap_err();
2546 assert!(
2547 err.message
2548 .contains("ambiguous file method segment ';polciy:'")
2549 );
2550 }
2551
2552 #[cfg(feature = "backend-mamba")]
2553 #[test]
2554 fn parse_compression_backend_json_wraps_mamba_cfg_rate_backend() {
2555 let json = serde_json::json!({
2556 "kind": "rate-ac",
2557 "framing": "raw",
2558 "rate_backend": {
2559 "kind": "mamba",
2560 "method": "cfg:hidden=64,layers=1,intermediate=96,state=16,conv=4,dt_rank=16,seed=26,train=none,lr=0.0,stride=1;policy:schedule=0..100:infer"
2561 }
2562 });
2563
2564 let backend = parse_compression_backend_json(
2565 &json,
2566 Path::new("."),
2567 None,
2568 crate::compression::FramingMode::Framed,
2569 )
2570 .expect("parse mamba rate-coded compression backend json");
2571
2572 match backend {
2573 CompressionBackend::Rate {
2574 rate_backend: RateBackend::MambaMethod { .. },
2575 coder,
2576 framing,
2577 } => {
2578 assert_eq!(coder, CoderType::AC);
2579 assert_eq!(framing, crate::compression::FramingMode::Raw);
2580 }
2581 _ => panic!("expected rate-coded mamba backend"),
2582 }
2583 }
2584
2585 #[cfg(feature = "backend-zpaq")]
2586 #[test]
2587 fn canonical_json_emits_typed_zpaq_method_objects() {
2588 let rate = serde_json::from_str::<serde_json::Value>(
2589 &RateBackend::Zpaq {
2590 method: crate::api::ZpaqMethodSpec::literal("5"),
2591 }
2592 .to_canonical_json()
2593 .expect("rate json"),
2594 )
2595 .expect("valid rate json");
2596 assert_eq!(rate["kind"], "zpaq");
2597 assert_eq!(rate["method"]["kind"], "literal");
2598 assert_eq!(rate["method"]["value"], "5");
2599
2600 let compression = serde_json::from_str::<serde_json::Value>(
2601 &CompressionBackend::zpaq("5")
2602 .to_canonical_json()
2603 .expect("compression json"),
2604 )
2605 .expect("valid compression json");
2606 assert_eq!(compression["kind"], "zpaq");
2607 assert_eq!(compression["method"]["kind"], "literal");
2608 assert_eq!(compression["method"]["value"], "5");
2609 }
2610
2611 #[cfg(feature = "backend-zpaq")]
2612 #[test]
2613 fn parse_typed_zpaq_method_objects_are_strictly_validated() {
2614 let err = parse_rate_backend_json(
2615 &serde_json::json!({
2616 "kind": "zpaq",
2617 "method": {"kind": "literal"}
2618 }),
2619 Path::new("."),
2620 MAX_MIXTURE_NESTING,
2621 )
2622 .err()
2623 .expect("missing typed zpaq value must fail");
2624 assert!(err.message.contains("method.value"), "{err}");
2625
2626 let err = parse_rate_backend_json(
2627 &serde_json::json!({
2628 "kind": "zpaq",
2629 "method": {"value": "2"}
2630 }),
2631 Path::new("."),
2632 MAX_MIXTURE_NESTING,
2633 )
2634 .err()
2635 .expect("missing typed zpaq kind must fail");
2636 assert!(err.message.contains("method.kind"), "{err}");
2637
2638 let err = parse_compression_backend_json(
2639 &serde_json::json!({
2640 "kind": "zpaq",
2641 "method": {"kind": "nonliteral", "value": "5"}
2642 }),
2643 Path::new("."),
2644 None,
2645 crate::compression::FramingMode::Framed,
2646 )
2647 .err()
2648 .expect("unknown typed zpaq kind must fail");
2649 assert!(err.message.contains("unknown zpaq method kind"), "{err}");
2650 }
2651
2652 #[cfg(feature = "backend-zpaq")]
2653 #[test]
2654 fn parse_zpaq_method_requires_typed_object_form() {
2655 let err = parse_rate_backend_json(
2656 &serde_json::json!({"kind": "zpaq", "method": "2"}),
2657 Path::new("."),
2658 MAX_MIXTURE_NESTING,
2659 )
2660 .err()
2661 .expect("legacy zpaq method string must be rejected");
2662 assert!(
2663 err.message
2664 .contains("zpaq method must use object form {'kind':'literal','value':'...'}"),
2665 "{err}"
2666 );
2667
2668 let compression = parse_compression_backend_json(
2669 &serde_json::json!({"kind": "zpaq"}),
2670 Path::new("."),
2671 None,
2672 crate::compression::FramingMode::Framed,
2673 )
2674 .expect("missing method should use default compression method");
2675 assert!(
2676 matches!(compression, CompressionBackend::Zpaq { method, .. } if method.value() == "5")
2677 );
2678 }
2679
2680 #[cfg(feature = "backend-rwkv")]
2681 #[test]
2682 fn canonical_json_emits_typed_rwkv_method_objects() {
2683 let value = serde_json::from_str::<serde_json::Value>(
2684 &RateBackend::Rwkv7Method {
2685 method: crate::rwkvzip::parse_method_spec("cfg:hidden=64,intermediate=64,layers=1")
2686 .expect("rwkv method spec"),
2687 }
2688 .to_canonical_json()
2689 .expect("rate json"),
2690 )
2691 .expect("valid rate json");
2692
2693 assert_eq!(value["kind"], "rwkv7");
2694 assert_eq!(value["method"]["kind"], "online");
2695 assert_eq!(value["method"]["cfg"]["hidden"], 64);
2696 assert_eq!(value["method"]["cfg"]["layers"], 1);
2697 assert_eq!(value["method"]["cfg"]["intermediate"], 64);
2698 }
2699
2700 #[cfg(feature = "backend-mamba")]
2701 #[test]
2702 fn canonical_json_emits_typed_mamba_method_objects() {
2703 let value = serde_json::from_str::<serde_json::Value>(
2704 &RateBackend::MambaMethod {
2705 method: crate::mambazip::parse_method_spec(
2706 "cfg:hidden=64,layers=1,intermediate=96,state=16,conv=4,dt_rank=16",
2707 )
2708 .expect("mamba method spec"),
2709 }
2710 .to_canonical_json()
2711 .expect("rate json"),
2712 )
2713 .expect("valid rate json");
2714
2715 assert_eq!(value["kind"], "mamba");
2716 assert_eq!(value["method"]["kind"], "online");
2717 assert_eq!(value["method"]["cfg"]["hidden"], 64);
2718 assert_eq!(value["method"]["cfg"]["layers"], 1);
2719 assert_eq!(value["method"]["cfg"]["intermediate"], 96);
2720 }
2721
2722 #[test]
2723 fn helper_parsers_and_name_renderers_use_canonical_forms() {
2724 assert_eq!(
2725 resolve_spec_path(Path::new("/tmp/base"), "child/spec.json"),
2726 Path::new("/tmp/base").join("child/spec.json")
2727 );
2728 assert_eq!(
2729 resolve_spec_path(Path::new("/tmp/base"), Path::new("/tmp/absolute.json")),
2730 Path::new("/tmp/absolute.json")
2731 );
2732
2733 assert_eq!(
2734 parse_calibration_context_kind(None).expect("default calibration context"),
2735 CalibrationContextKind::Text
2736 );
2737 assert_eq!(
2738 parse_calibration_context_kind(Some("repeat")).expect("repeat context"),
2739 CalibrationContextKind::Repeat
2740 );
2741 assert!(parse_calibration_context_kind(Some("legacy")).is_err());
2742
2743 assert_eq!(
2744 parse_mixture_kind("switching").expect("switching"),
2745 MixtureKind::Switching
2746 );
2747 assert_eq!(
2748 parse_mixture_schedule("theorem").expect("theorem schedule"),
2749 MixtureScheduleMode::Theorem
2750 );
2751 assert_eq!(mixture_kind_name(MixtureKind::Neural), "neural");
2752 assert_eq!(
2753 mixture_schedule_name(MixtureScheduleMode::Default),
2754 "default"
2755 );
2756 assert_eq!(
2757 calibration_context_kind_name(CalibrationContextKind::Text),
2758 "text"
2759 );
2760
2761 assert_eq!(
2762 parse_framing_mode(None).expect("default framing"),
2763 crate::compression::FramingMode::Framed
2764 );
2765 assert_eq!(
2766 parse_framing_mode(Some("raw")).expect("raw framing"),
2767 crate::compression::FramingMode::Raw
2768 );
2769 assert!(parse_framing_mode(Some("legacy")).is_err());
2770 assert_eq!(
2771 framing_mode_name(crate::compression::FramingMode::Framed),
2772 "framed"
2773 );
2774
2775 let zpaq_json = zpaq_method_to_json_value(&crate::api::ZpaqMethodSpec::literal("3"));
2776 assert_eq!(zpaq_json["kind"], "literal");
2777 assert_eq!(
2778 parse_zpaq_method_json_value(&zpaq_json, "5")
2779 .expect("typed zpaq method")
2780 .value(),
2781 "3"
2782 );
2783 }
2784
2785 #[test]
2786 fn load_json_value_from_path_reports_read_and_parse_context() {
2787 let dir = unique_temp_dir("load-json");
2788 fs::create_dir_all(&dir).expect("create temp dir");
2789 let valid = dir.join("valid.json");
2790 let invalid = dir.join("invalid.json");
2791 fs::write(&valid, br#"{ "alpha": 1 }"#).expect("write valid json");
2792 fs::write(&invalid, b"{ invalid").expect("write invalid json");
2793
2794 let (value, full) =
2795 load_json_value_from_path(&dir, "valid.json", "spec fixture").expect("load valid");
2796 assert_eq!(value["alpha"], 1);
2797 assert_eq!(full, valid);
2798
2799 let err = load_json_value_from_path(&dir, "missing.json", "spec fixture")
2800 .expect_err("missing json must fail");
2801 assert!(err.to_string().contains("failed to read spec fixture"));
2802 assert!(err.to_string().contains("missing.json"));
2803
2804 let err = load_json_value_from_path(&dir, "invalid.json", "spec fixture")
2805 .expect_err("invalid json must fail");
2806 assert!(err.to_string().contains("invalid spec fixture JSON"));
2807 assert!(err.to_string().contains("invalid.json"));
2808
2809 let _ = fs::remove_file(valid);
2810 let _ = fs::remove_file(invalid);
2811 let _ = fs::remove_dir(dir);
2812 }
2813
2814 #[cfg(feature = "backend-ctw")]
2815 #[test]
2816 fn shorthand_rate_backend_parsers_load_file_backed_specs_and_respect_particle_default_policy() {
2817 let dir = unique_temp_dir("backend-files");
2818 fs::create_dir_all(&dir).expect("create temp dir");
2819
2820 let mixture_path = dir.join("mixture.json");
2821 let calibrated_path = dir.join("calibrated.json");
2822 let particle_path = dir.join("particle.json");
2823
2824 let mixture = MixtureSpec::new(
2825 MixtureKind::Bayes,
2826 vec![MixtureExpertSpec {
2827 name: Some("ctw".to_string()),
2828 log_prior: 0.0,
2829 backend: RateBackend::Ctw { depth: 4 },
2830 }],
2831 );
2832 let calibrated = CalibratedSpec {
2833 base: RateBackend::Ctw { depth: 5 },
2834 context: CalibrationContextKind::Text,
2835 bins: 17,
2836 learning_rate: 0.05,
2837 bias_clip: 3.0,
2838 };
2839 let particle = ParticleSpec::default();
2840
2841 fs::write(
2842 &mixture_path,
2843 mixture.to_canonical_json().expect("mixture canonical json"),
2844 )
2845 .expect("write mixture spec");
2846 fs::write(
2847 &calibrated_path,
2848 calibrated
2849 .to_canonical_json()
2850 .expect("calibrated canonical json"),
2851 )
2852 .expect("write calibrated spec");
2853 fs::write(
2854 &particle_path,
2855 particle
2856 .to_canonical_json()
2857 .expect("particle canonical json"),
2858 )
2859 .expect("write particle spec");
2860
2861 let options = RateBackendShorthandOptions {
2862 base_dir: dir.clone(),
2863 particle_default_if_missing_method: false,
2864 ..RateBackendShorthandOptions::default()
2865 };
2866
2867 let mixture_result =
2868 parse_rate_backend_name_method("mixture", Some("mixture.json"), &options);
2869 #[cfg(feature = "backend-mixture")]
2870 {
2871 let mixture_backend = mixture_result.expect("mixture shorthand should load JSON file");
2872 assert!(matches!(mixture_backend, RateBackend::Mixture { .. }));
2873 }
2874 #[cfg(not(feature = "backend-mixture"))]
2875 {
2876 let err = match mixture_result {
2877 Ok(_) => {
2878 panic!("mixture shorthand should fail when feature is disabled")
2879 }
2880 Err(err) => err,
2881 };
2882 assert!(
2883 err.to_string()
2884 .contains("backend 'mixture' requires infotheory feature 'backend-mixture'"),
2885 "unexpected mixture error: {err}"
2886 );
2887 }
2888
2889 let particle_result =
2890 parse_rate_backend_name_method("particle", Some("particle.json"), &options);
2891 #[cfg(feature = "backend-particle")]
2892 {
2893 let particle_backend =
2894 particle_result.expect("particle shorthand should load JSON file");
2895 assert!(matches!(particle_backend, RateBackend::Particle { .. }));
2896 }
2897 #[cfg(not(feature = "backend-particle"))]
2898 {
2899 let err = match particle_result {
2900 Ok(_) => {
2901 panic!("particle shorthand should fail when feature is disabled")
2902 }
2903 Err(err) => err,
2904 };
2905 assert!(
2906 err.to_string()
2907 .contains("backend 'particle' requires infotheory feature 'backend-particle'"),
2908 "unexpected particle error: {err}"
2909 );
2910 }
2911
2912 let calibrated_result =
2913 parse_rate_backend_name_method("calibrated", Some("calibrated.json"), &options);
2914 #[cfg(feature = "backend-calibrated")]
2915 {
2916 let calibrated_backend =
2917 calibrated_result.expect("calibrated shorthand should load JSON file");
2918 assert!(matches!(calibrated_backend, RateBackend::Calibrated { .. }));
2919 }
2920 #[cfg(not(feature = "backend-calibrated"))]
2921 {
2922 let err = match calibrated_result {
2923 Ok(_) => {
2924 panic!("calibrated shorthand should fail when feature is disabled")
2925 }
2926 Err(err) => err,
2927 };
2928 assert!(
2929 err.to_string().contains(
2930 "backend 'calibrated' requires infotheory feature 'backend-calibrated'"
2931 ),
2932 "unexpected calibrated error: {err}"
2933 );
2934 }
2935
2936 let particle_missing_method_result =
2937 parse_rate_backend_name_method("particle", None, &options);
2938 #[cfg(feature = "backend-particle")]
2939 {
2940 let err = match particle_missing_method_result {
2941 Ok(_) => {
2942 panic!("particle shorthand should require path when disabled")
2943 }
2944 Err(err) => err,
2945 };
2946 assert!(
2947 err.to_string()
2948 .contains("particle backend requires a path to a ParticleSpec JSON file"),
2949 "unexpected particle missing-method error: {err}"
2950 );
2951 }
2952 #[cfg(not(feature = "backend-particle"))]
2953 {
2954 let err = match particle_missing_method_result {
2955 Ok(_) => {
2956 panic!("particle shorthand without feature should report capability error")
2957 }
2958 Err(err) => err,
2959 };
2960 assert!(
2961 err.to_string()
2962 .contains("backend 'particle' requires infotheory feature 'backend-particle'"),
2963 "unexpected particle feature error: {err}"
2964 );
2965 }
2966
2967 let _ = fs::remove_file(mixture_path);
2968 let _ = fs::remove_file(calibrated_path);
2969 let _ = fs::remove_file(particle_path);
2970 let _ = fs::remove_dir(dir);
2971 }
2972
2973 #[test]
2974 fn parse_particle_spec_value_preserves_defaults_and_rejects_mixture_shapes() {
2975 let defaults = ParticleSpec::default();
2976 let parsed = parse_particle_spec_value(&serde_json::json!({
2977 "num_particles": defaults.num_particles + 7,
2978 "deterministic": !defaults.deterministic,
2979 }))
2980 .expect("particle subset should parse with defaults");
2981 assert_eq!(parsed.num_particles, defaults.num_particles + 7);
2982 assert_eq!(parsed.context_window, defaults.context_window);
2983 assert_eq!(parsed.seed, defaults.seed);
2984 assert_eq!(parsed.deterministic, !defaults.deterministic);
2985
2986 let err = parse_particle_spec_value(&serde_json::json!({
2987 "kind": "mixture",
2988 "num_particles": 8,
2989 }))
2990 .expect_err("mixture-looking kind must be rejected");
2991 assert!(
2992 err.to_string()
2993 .contains("looks like a mixture spec (kind='mixture')"),
2994 "unexpected error: {err}"
2995 );
2996
2997 let err = parse_particle_spec_value(&serde_json::json!({
2998 "experts": [],
2999 }))
3000 .expect_err("mixture-shaped object must be rejected");
3001 assert!(
3002 err.to_string()
3003 .contains("looks like a mixture spec (found 'experts')"),
3004 "unexpected error: {err}"
3005 );
3006 }
3007
3008 #[test]
3009 fn fac_ctw_default_projections_remain_explicit_and_consistent() {
3010 let shorthand = RateBackendShorthandOptions::default();
3011 assert_eq!(
3012 shorthand.fac_ctw_num_percept_bits,
3013 crate::rate_defaults::FAC_CTW_DEFAULT_NUM_PERCEPT_BITS
3014 );
3015 assert_eq!(
3016 shorthand.fac_ctw_encoding_bits,
3017 crate::rate_defaults::JSON_DEFAULT_FAC_CTW_ENCODING_BITS
3018 );
3019
3020 #[cfg(feature = "backend-ctw")]
3021 {
3022 let parsed = parse_rate_backend_json(
3023 &serde_json::json!({"kind":"fac-ctw"}),
3024 Path::new("."),
3025 MAX_MIXTURE_NESTING,
3026 )
3027 .expect("fac-ctw json parse");
3028 let parsed_for_compile = parsed.clone();
3029 match parsed {
3030 RateBackend::FacCtw {
3031 base_depth,
3032 num_percept_bits,
3033 encoding_bits,
3034 msb_first,
3035 } => {
3036 assert_eq!(
3037 base_depth,
3038 crate::rate_defaults::JSON_DEFAULT_FAC_CTW_BASE_DEPTH
3039 );
3040 assert_eq!(
3041 encoding_bits,
3042 crate::rate_defaults::JSON_DEFAULT_FAC_CTW_ENCODING_BITS
3043 );
3044 assert_eq!(num_percept_bits, encoding_bits);
3045 assert_eq!(msb_first, None);
3046 }
3047 _ => panic!("expected fac-ctw backend"),
3048 }
3049
3050 let explicit_lsb = parse_rate_backend_json(
3051 &serde_json::json!({
3052 "kind": "fac-ctw",
3053 "base_depth": 9,
3054 "encoding_bits": 8,
3055 "num_percept_bits": 8,
3056 "msb_first": false,
3057 }),
3058 Path::new("."),
3059 MAX_MIXTURE_NESTING,
3060 )
3061 .expect("fac-ctw explicit LSB json parse");
3062 let compiled_lsb = explicit_lsb
3063 .compile()
3064 .expect("fac-ctw explicit LSB compiles");
3065 match compiled_lsb.plan() {
3066 crate::spec::core::RateBackendPlan::FacCtw { msb_first, .. } => {
3067 assert!(!*msb_first, "explicit msb_first=false must survive compile");
3068 }
3069 _ => panic!("expected fac-ctw compiled plan"),
3070 }
3071
3072 let compiled_default = parsed_for_compile
3073 .compile()
3074 .expect("fac-ctw default compiles");
3075 match compiled_default.plan() {
3076 crate::spec::core::RateBackendPlan::FacCtw {
3077 encoding_bits,
3078 msb_first,
3079 ..
3080 } => {
3081 assert_eq!(*encoding_bits, 8);
3082 assert!(
3083 *msb_first,
3084 "omitted msb_first defaults to MSB-first for byte-width FacCtw"
3085 );
3086 }
3087 _ => panic!("expected fac-ctw compiled plan"),
3088 }
3089
3090 let invalid_width = parse_rate_backend_json(
3091 &serde_json::json!({
3092 "kind": "fac-ctw",
3093 "base_depth": 9,
3094 "encoding_bits": 9,
3095 "num_percept_bits": 9,
3096 }),
3097 Path::new("."),
3098 MAX_MIXTURE_NESTING,
3099 )
3100 .expect("fac-ctw invalid-width json parses before semantic compile validation");
3101 let err = match invalid_width.compile() {
3102 Ok(_) => panic!("fac-ctw encoding_bits outside 1..=8 must be rejected"),
3103 Err(err) => err,
3104 };
3105 assert!(
3106 err.to_string()
3107 .contains("fac-ctw encoding_bits must be in 1..=8, got 9"),
3108 "unexpected fac-ctw encoding_bits error: {err}"
3109 );
3110 }
3111
3112 #[cfg(not(feature = "backend-ctw"))]
3113 {
3114 let err = match parse_rate_backend_json(
3115 &serde_json::json!({"kind":"fac-ctw"}),
3116 Path::new("."),
3117 MAX_MIXTURE_NESTING,
3118 ) {
3119 Ok(_) => panic!("disabled fac-ctw backend must report a feature error"),
3120 Err(err) => err,
3121 };
3122 assert!(
3123 err.to_string()
3124 .contains("backend 'fac-ctw' requires infotheory feature 'backend-ctw'"),
3125 "unexpected fac-ctw feature error: {err}"
3126 );
3127 }
3128
3129 let runtime_default =
3130 crate::runtime::default_rate_backend_spec(crate::runtime::RateBackendKind::FacCtw)
3131 .expect("runtime fac-ctw default");
3132 match runtime_default {
3133 RateBackend::FacCtw {
3134 base_depth,
3135 num_percept_bits,
3136 encoding_bits,
3137 msb_first,
3138 } => {
3139 assert_eq!(base_depth, 8);
3140 assert_eq!(
3141 encoding_bits,
3142 crate::rate_defaults::JSON_DEFAULT_FAC_CTW_ENCODING_BITS
3143 );
3144 assert_eq!(
3145 num_percept_bits,
3146 crate::rate_defaults::FAC_CTW_DEFAULT_NUM_PERCEPT_BITS
3147 );
3148 assert_eq!(msb_first, None);
3149 }
3150 _ => panic!("expected runtime fac-ctw default backend"),
3151 }
3152 let fac_ctw_default_json = rate_backend_to_json_value(&runtime_default)
3153 .expect("serialize runtime fac-ctw default backend");
3154 assert!(
3155 fac_ctw_default_json.get("msb_first").is_none(),
3156 "canonical fac-ctw json must omit msb_first when unset"
3157 );
3158
3159 #[cfg(feature = "backend-ctw")]
3160 {
3161 let msb_shorthand = RateBackendShorthandOptions {
3162 fac_ctw_msb_first: Some(true),
3163 ..RateBackendShorthandOptions::default()
3164 };
3165 let parsed_msb = parse_rate_backend_name_method("fac-ctw", Some("9"), &msb_shorthand)
3166 .expect("fac-ctw shorthand with msb_first");
3167 match parsed_msb {
3168 RateBackend::FacCtw { msb_first, .. } => {
3169 assert_eq!(msb_first, Some(true));
3170 }
3171 _ => panic!("expected fac-ctw backend"),
3172 }
3173 let compiled_msb = parsed_msb.compile().expect("fac-ctw msb compiles");
3174 match compiled_msb.plan() {
3175 crate::spec::core::RateBackendPlan::FacCtw { msb_first, .. } => {
3176 assert!(*msb_first, "shorthand msb_first=true must survive compile");
3177 }
3178 _ => panic!("expected fac-ctw compiled plan"),
3179 }
3180
3181 let lsb_shorthand = RateBackendShorthandOptions {
3182 fac_ctw_msb_first: Some(false),
3183 ..RateBackendShorthandOptions::default()
3184 };
3185 let parsed_lsb = parse_rate_backend_name_method("fac-ctw", Some("9"), &lsb_shorthand)
3186 .expect("fac-ctw shorthand with lsb_first");
3187 match parsed_lsb.compile().expect("fac-ctw lsb compiles").plan() {
3188 crate::spec::core::RateBackendPlan::FacCtw { msb_first, .. } => {
3189 assert!(
3190 !*msb_first,
3191 "shorthand msb_first=false must survive compile"
3192 );
3193 }
3194 _ => panic!("expected fac-ctw compiled plan"),
3195 }
3196
3197 let factory_json = crate::rate_defaults::fac_ctw_spec_json(9, 8, 8, Some(false));
3198 assert_eq!(factory_json["msb_first"], serde_json::json!(false));
3199 let factory_default = crate::rate_defaults::fac_ctw_spec_json(9, 8, 8, None);
3200 assert!(
3201 factory_default.get("msb_first").is_none(),
3202 "factory omits msb_first when None"
3203 );
3204 }
3205 }
3206
3207 #[cfg(feature = "all-backends")]
3208 #[test]
3209 fn load_sidecar_specs_report_read_and_json_error_context() {
3210 let dir = unique_temp_dir("load-sidecar-errors");
3211 fs::create_dir_all(&dir).expect("create temp dir");
3212
3213 let particle_invalid = dir.join("particle-invalid.json");
3214 let calibrated_invalid = dir.join("calibrated-invalid.json");
3215 let expert_invalid = dir.join("expert-invalid.json");
3216 fs::write(&particle_invalid, b"{ invalid").expect("write invalid particle json");
3217 fs::write(&calibrated_invalid, b"{ invalid").expect("write invalid calibrated json");
3218 fs::write(&expert_invalid, b"{ invalid").expect("write invalid expert json");
3219
3220 let missing_particle = dir.join("particle-missing.json");
3221 let err = match load_particle_spec(missing_particle.to_str().expect("utf8 path")) {
3222 Ok(_) => panic!("missing particle spec should fail"),
3223 Err(err) => err,
3224 };
3225 assert!(
3226 err.to_string().contains("failed to read particle spec"),
3227 "{err}"
3228 );
3229
3230 let err = match load_particle_spec(particle_invalid.to_str().expect("utf8 path")) {
3231 Ok(_) => panic!("invalid particle spec JSON should fail"),
3232 Err(err) => err,
3233 };
3234 assert!(
3235 err.to_string().contains("invalid particle spec JSON"),
3236 "{err}"
3237 );
3238
3239 let missing_calibrated = dir.join("calibrated-missing.json");
3240 let err = match load_calibrated_spec(missing_calibrated.to_str().expect("utf8 path")) {
3241 Ok(_) => panic!("missing calibrated spec should fail"),
3242 Err(err) => err,
3243 };
3244 assert!(
3245 err.to_string().contains("failed to read calibrated spec"),
3246 "{err}"
3247 );
3248
3249 let err = match load_calibrated_spec(calibrated_invalid.to_str().expect("utf8 path")) {
3250 Ok(_) => panic!("invalid calibrated spec JSON should fail"),
3251 Err(err) => err,
3252 };
3253 assert!(
3254 err.to_string().contains("invalid calibrated spec JSON"),
3255 "{err}"
3256 );
3257
3258 let missing_expert = dir.join("expert-missing.json");
3259 let err = match load_expert_spec(missing_expert.to_str().expect("utf8 path")) {
3260 Ok(_) => panic!("missing expert spec should fail"),
3261 Err(err) => err,
3262 };
3263 assert!(
3264 err.to_string().contains("failed to read expert spec"),
3265 "{err}"
3266 );
3267
3268 let err = match load_expert_spec(expert_invalid.to_str().expect("utf8 path")) {
3269 Ok(_) => panic!("invalid expert spec JSON should fail"),
3270 Err(err) => err,
3271 };
3272 assert!(
3273 err.to_string().contains("invalid expert spec JSON"),
3274 "{err}"
3275 );
3276
3277 let _ = fs::remove_file(particle_invalid);
3278 let _ = fs::remove_file(calibrated_invalid);
3279 let _ = fs::remove_file(expert_invalid);
3280 let _ = fs::remove_dir(dir);
3281 }
3282
3283 #[cfg(feature = "all-backends")]
3284 #[test]
3285 fn shorthand_rate_backend_parser_covers_leaf_defaults_and_model_path_contracts() {
3286 let options = RateBackendShorthandOptions::default();
3287
3288 let sparse_match = parse_rate_backend_name_method("sparse-match", None, &options)
3289 .expect("sparse-match shorthand should parse");
3290 match sparse_match {
3291 RateBackend::SparseMatch {
3292 hash_bits,
3293 min_len,
3294 max_len,
3295 gap_min,
3296 gap_max,
3297 base_mix,
3298 confidence_scale,
3299 } => {
3300 assert_eq!(hash_bits, 19);
3301 assert_eq!(min_len, 3);
3302 assert_eq!(max_len, 64);
3303 assert_eq!(gap_min, 1);
3304 assert_eq!(gap_max, 2);
3305 assert!((base_mix - 0.05).abs() < f64::EPSILON);
3306 assert!((confidence_scale - 1.0).abs() < f64::EPSILON);
3307 }
3308 _ => panic!("expected sparse-match backend"),
3309 }
3310
3311 let fac_ctw = parse_rate_backend_name_method("fac-ctw", Some("11"), &options)
3312 .expect("fac-ctw shorthand should parse");
3313 match fac_ctw {
3314 RateBackend::FacCtw {
3315 base_depth,
3316 num_percept_bits,
3317 encoding_bits,
3318 msb_first,
3319 } => {
3320 assert_eq!(base_depth, 11);
3321 assert_eq!(num_percept_bits, options.fac_ctw_num_percept_bits);
3322 assert_eq!(encoding_bits, options.fac_ctw_encoding_bits);
3323 assert_eq!(msb_first, None);
3324 }
3325 _ => panic!("expected fac-ctw backend"),
3326 }
3327
3328 let zpaq = parse_rate_backend_name_method("zpaq", None, &options)
3329 .expect("zpaq shorthand should parse");
3330 assert!(
3331 matches!(zpaq, RateBackend::Zpaq { method } if method.value() == options.zpaq_method)
3332 );
3333
3334 let particle = parse_rate_backend_name_method("particle", None, &options)
3335 .expect("particle shorthand should use default spec");
3336 assert!(matches!(particle, RateBackend::Particle { .. }));
3337
3338 let mixture_err = match parse_rate_backend_name_method("mixture", None, &options) {
3339 Ok(_) => panic!("mixture shorthand without path should fail"),
3340 Err(err) => err,
3341 };
3342 assert!(
3343 mixture_err
3344 .to_string()
3345 .contains("mixture backend requires a path to a MixtureSpec JSON file"),
3346 "{mixture_err}"
3347 );
3348
3349 let calibrated_err = match parse_rate_backend_name_method("calibrated", None, &options) {
3350 Ok(_) => panic!("calibrated shorthand without path should fail"),
3351 Err(err) => err,
3352 };
3353 assert!(
3354 calibrated_err
3355 .to_string()
3356 .contains("calibrated backend requires a path to a CalibratedSpec JSON file"),
3357 "{calibrated_err}"
3358 );
3359
3360 let mamba_err = match parse_rate_backend_name_method("mamba", None, &options) {
3361 Ok(_) => panic!("mamba shorthand without method/model path should fail"),
3362 Err(err) => err,
3363 };
3364 assert!(
3365 mamba_err
3366 .to_string()
3367 .contains("mamba backend requires method string"),
3368 "{mamba_err}"
3369 );
3370
3371 let rwkv_err = match parse_rate_backend_name_method("rwkv7", None, &options) {
3372 Ok(_) => panic!("rwkv shorthand without method/model path should fail"),
3373 Err(err) => err,
3374 };
3375 assert!(
3376 rwkv_err
3377 .to_string()
3378 .contains("rwkv backend requires method string"),
3379 "{rwkv_err}"
3380 );
3381 }
3382
3383 #[cfg(feature = "backend-ctw")]
3384 #[test]
3385 fn parse_rate_backend_json_loads_nested_spec_paths_relative_to_base_dir() {
3386 let dir = unique_temp_dir("nested-backend-specs");
3387 fs::create_dir_all(&dir).expect("create temp dir");
3388
3389 let nested_dir = dir.join("nested");
3390 fs::create_dir_all(&nested_dir).expect("create nested dir");
3391
3392 let particle_path = nested_dir.join("particle.json");
3393 let calibrated_path = nested_dir.join("calibrated.json");
3394 let mixture_path = nested_dir.join("mixture.json");
3395
3396 let particle = ParticleSpec {
3397 num_particles: 11,
3398 context_window: 19,
3399 ..ParticleSpec::default()
3400 };
3401 let calibrated = CalibratedSpec {
3402 base: RateBackend::Ctw { depth: 9 },
3403 context: CalibrationContextKind::Repeat,
3404 bins: 21,
3405 learning_rate: 0.03,
3406 bias_clip: 2.5,
3407 };
3408 let mixture = MixtureSpec::new(
3409 MixtureKind::Bayes,
3410 vec![MixtureExpertSpec {
3411 name: Some("ctw-nine".to_string()),
3412 log_prior: -0.5,
3413 backend: RateBackend::Ctw { depth: 9 },
3414 }],
3415 );
3416
3417 fs::write(
3418 &particle_path,
3419 particle.to_canonical_json().expect("particle json"),
3420 )
3421 .expect("write particle spec");
3422 fs::write(
3423 &calibrated_path,
3424 calibrated.to_canonical_json().expect("calibrated json"),
3425 )
3426 .expect("write calibrated spec");
3427 fs::write(
3428 &mixture_path,
3429 mixture.to_canonical_json().expect("mixture json"),
3430 )
3431 .expect("write mixture spec");
3432
3433 let particle_result = parse_rate_backend_json(
3434 &serde_json::json!({
3435 "kind": "particle",
3436 "spec_path": "nested/particle.json",
3437 }),
3438 &dir,
3439 MAX_MIXTURE_NESTING,
3440 );
3441 #[cfg(feature = "backend-particle")]
3442 {
3443 let particle_backend =
3444 particle_result.expect("particle spec_path should resolve relative to base dir");
3445 match particle_backend {
3446 RateBackend::Particle { spec } => {
3447 assert_eq!(spec.num_particles, 11);
3448 assert_eq!(spec.context_window, 19);
3449 }
3450 _ => panic!("expected particle backend"),
3451 }
3452 }
3453 #[cfg(not(feature = "backend-particle"))]
3454 {
3455 let err = match particle_result {
3456 Ok(_) => panic!("particle backend should report feature gate in this slice"),
3457 Err(err) => err,
3458 };
3459 assert!(
3460 err.to_string()
3461 .contains("backend 'particle' requires infotheory feature 'backend-particle'"),
3462 "unexpected particle error: {err}"
3463 );
3464 }
3465
3466 let calibrated_result = parse_rate_backend_json(
3467 &serde_json::json!({
3468 "kind": "calibrated",
3469 "spec_path": "nested/calibrated.json",
3470 }),
3471 &dir,
3472 MAX_MIXTURE_NESTING,
3473 );
3474 #[cfg(feature = "backend-calibrated")]
3475 {
3476 let calibrated_backend = calibrated_result
3477 .expect("calibrated spec_path should resolve relative to base dir");
3478 match calibrated_backend {
3479 RateBackend::Calibrated { spec } => {
3480 assert_eq!(spec.bins, 21);
3481 assert_eq!(spec.context, CalibrationContextKind::Repeat);
3482 match spec.base {
3483 RateBackend::Ctw { depth } => assert_eq!(depth, 9),
3484 _ => panic!("expected ctw base backend"),
3485 }
3486 }
3487 _ => panic!("expected calibrated backend"),
3488 }
3489 }
3490 #[cfg(not(feature = "backend-calibrated"))]
3491 {
3492 let err = match calibrated_result {
3493 Ok(_) => panic!("calibrated backend should report feature gate in this slice"),
3494 Err(err) => err,
3495 };
3496 assert!(
3497 err.to_string().contains(
3498 "backend 'calibrated' requires infotheory feature 'backend-calibrated'"
3499 ),
3500 "unexpected calibrated error: {err}"
3501 );
3502 }
3503
3504 let mixture_result = parse_rate_backend_json(
3505 &serde_json::json!({
3506 "kind": "mixture",
3507 "spec_path": "nested/mixture.json",
3508 }),
3509 &dir,
3510 MAX_MIXTURE_NESTING,
3511 );
3512 #[cfg(feature = "backend-mixture")]
3513 {
3514 let mixture_backend =
3515 mixture_result.expect("mixture spec_path should resolve relative to base dir");
3516 match mixture_backend {
3517 RateBackend::Mixture { spec } => {
3518 assert_eq!(spec.kind, MixtureKind::Bayes);
3519 assert_eq!(spec.experts.len(), 1);
3520 assert_eq!(spec.experts[0].name.as_deref(), Some("ctw-nine"));
3521 }
3522 _ => panic!("expected mixture backend"),
3523 }
3524 }
3525 #[cfg(not(feature = "backend-mixture"))]
3526 {
3527 let err = match mixture_result {
3528 Ok(_) => panic!("mixture backend should report feature gate in this slice"),
3529 Err(err) => err,
3530 };
3531 assert!(
3532 err.to_string()
3533 .contains("backend 'mixture' requires infotheory feature 'backend-mixture'"),
3534 "unexpected mixture error: {err}"
3535 );
3536 }
3537
3538 let _ = fs::remove_file(particle_path);
3539 let _ = fs::remove_file(calibrated_path);
3540 let _ = fs::remove_file(mixture_path);
3541 let _ = fs::remove_dir(nested_dir);
3542 let _ = fs::remove_dir(dir);
3543 }
3544
3545 #[cfg(feature = "backend-rwkv")]
3546 #[test]
3547 fn parse_rwkv7_compression_backend_json_requires_method_or_model_path() {
3548 let err = match parse_compression_backend_json(
3549 &serde_json::json!({
3550 "kind": "rwkv7",
3551 }),
3552 Path::new("."),
3553 None,
3554 crate::compression::FramingMode::Framed,
3555 ) {
3556 Ok(_) => panic!("rwkv7 compression json without method/model_path must fail"),
3557 Err(err) => err,
3558 };
3559 assert!(
3560 err.to_string()
3561 .contains("rwkv7 compression backend requires 'method' or 'model_path'"),
3562 "unexpected error: {err}"
3563 );
3564 }
3565
3566 #[cfg(feature = "backend-rwkv")]
3567 #[test]
3568 fn parse_rwkv7_compression_backend_json_lowers_typed_method_and_coder() {
3569 let backend = parse_compression_backend_json(
3570 &serde_json::json!({
3571 "kind": "rwkv7",
3572 "coder": "rans",
3573 "framing": "raw",
3574 "method": {
3575 "kind": "online",
3576 "cfg": {
3577 "hidden": 64,
3578 "layers": 1,
3579 "intermediate": 64,
3580 "decay_rank": 8,
3581 "a_rank": 8,
3582 "v_rank": 8,
3583 "g_rank": 8,
3584 "seed": 5,
3585 "train": "none",
3586 "lr": 0.01,
3587 "stride": 2
3588 },
3589 "policy": "schedule=0..100:infer"
3590 }
3591 }),
3592 Path::new("."),
3593 None,
3594 crate::compression::FramingMode::Framed,
3595 )
3596 .expect("typed rwkv7 compression backend should parse");
3597
3598 match backend {
3599 CompressionBackend::Rate {
3600 rate_backend,
3601 coder,
3602 framing,
3603 } => {
3604 assert_eq!(coder, crate::coders::CoderType::RANS);
3605 assert_eq!(framing, crate::compression::FramingMode::Raw);
3606 match rate_backend {
3607 RateBackend::Rwkv7Method { method } => match method {
3608 crate::rwkvzip::MethodSpec::Online { cfg, policy } => {
3609 assert_eq!(cfg.hidden, 64);
3610 assert_eq!(cfg.layers, 1);
3611 assert_eq!(cfg.stride, 2);
3612 assert!(policy.is_some(), "policy should be preserved");
3613 }
3614 _ => panic!("expected online rwkv method"),
3615 },
3616 _ => panic!("expected rwkv7 rate backend"),
3617 }
3618 }
3619 CompressionBackend::Rwkv7 { method, coder } => {
3620 assert_eq!(coder, crate::coders::CoderType::RANS);
3621 match method {
3622 crate::rwkvzip::MethodSpec::Online { cfg, policy } => {
3623 assert_eq!(cfg.hidden, 64);
3624 assert_eq!(cfg.layers, 1);
3625 assert_eq!(cfg.stride, 2);
3626 assert!(policy.is_some(), "policy should be preserved");
3627 }
3628 _ => panic!("expected online rwkv method"),
3629 }
3630 }
3631 _ => panic!("expected rwkv7-derived compression backend"),
3632 }
3633 }
3634
3635 #[cfg(feature = "backend-rwkv")]
3636 #[test]
3637 fn parse_rwkv7_compression_shorthand_coder_alias_requires_or_uses_model_path() {
3638 let base_dir = unique_temp_dir("rwkv-coder-alias");
3639 fs::create_dir_all(&base_dir).expect("create temp dir");
3640
3641 let missing_path_options = CompressionBackendShorthandOptions {
3642 base_dir: base_dir.clone(),
3643 ..CompressionBackendShorthandOptions::default()
3644 };
3645 let err = match parse_compression_backend_name_method(
3646 "rwkv7",
3647 Some("ac"),
3648 None,
3649 &missing_path_options,
3650 ) {
3651 Ok(_) => panic!("coder alias without default model path should fail"),
3652 Err(err) => err,
3653 };
3654 assert!(
3655 err.to_string().contains(
3656 "rwkv7 compression backend requires a configured model path when only a coder alias is provided"
3657 ),
3658 "{err}"
3659 );
3660
3661 let with_path_options = CompressionBackendShorthandOptions {
3662 base_dir: base_dir.clone(),
3663 default_rwkv_model_path: Some("weights/model.safetensors".to_string()),
3664 ..CompressionBackendShorthandOptions::default()
3665 };
3666 let with_path_err = match parse_compression_backend_name_method(
3667 "rwkv7",
3668 Some("rans"),
3669 None,
3670 &with_path_options,
3671 ) {
3672 Ok(_) => panic!("missing RWKV model weights should fail deterministically"),
3673 Err(err) => err,
3674 };
3675 assert!(
3676 with_path_err
3677 .to_string()
3678 .contains("Failed to load model weights"),
3679 "{with_path_err}"
3680 );
3681 assert!(
3682 with_path_err
3683 .to_string()
3684 .contains("weights/model.safetensors"),
3685 "{with_path_err}"
3686 );
3687
3688 let _ = fs::remove_dir(base_dir);
3689 }
3690}