Skip to main content

infotheory/
spec.rs

1//! Canonical backend/spec parsing shared by Rust, CLI, and Python surfaces.
2
3pub 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
44/// Result type used by the shared spec/parsing layer.
45pub type SpecResult<T> = Result<T, SpecError>;
46
47/// Serialize JSON hash payloads with recursive lexicographic object-key order.
48///
49/// This is the crate-local byte contract for CRC/SHA commitments over ad-hoc
50/// JSON payloads. It deliberately avoids relying on `serde_json::Map`'s backing
51/// type or feature-unified insertion-order behavior.
52#[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
97/// Trait for deterministic canonical JSON serialization.
98pub trait CanonicalJson {
99    /// Serialize this value into canonical JSON value form.
100    fn to_canonical_json_value(&self) -> SpecResult<serde_json::Value>;
101
102    /// Serialize this value into deterministic canonical JSON text.
103    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/// Lightweight error type for spec/config parsing and loading.
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct SpecError {
111    message: String,
112}
113
114impl SpecError {
115    /// Create a new spec error from a message.
116    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/// Defaults for shorthand backend parsing such as CLI `--rate-backend ... --method ...`.
156#[derive(Clone, Debug)]
157#[non_exhaustive]
158pub struct RateBackendShorthandOptions {
159    /// Base directory used to resolve relative spec/model paths.
160    pub base_dir: PathBuf,
161    /// Default CTW depth when no numeric method is supplied.
162    pub ctw_depth: usize,
163    /// Default FAC-CTW base depth when no numeric method is supplied.
164    pub fac_ctw_base_depth: usize,
165    /// Default FAC-CTW percept width.
166    pub fac_ctw_num_percept_bits: usize,
167    /// Default FAC-CTW symbol encoding width.
168    pub fac_ctw_encoding_bits: usize,
169    /// Optional FAC-CTW MSB-first override for shorthand CLI parsing.
170    ///
171    /// `None` defers to compile-time default (`encoding_bits == 8` → MSB-first).
172    pub fac_ctw_msb_first: Option<bool>,
173    /// Default PPMD order.
174    pub ppmd_order: usize,
175    /// Default PPMD memory budget in MiB.
176    pub ppmd_memory_mb: usize,
177    /// Default Sequitur context window.
178    pub sequitur_context_bytes: usize,
179    /// Default ZPAQ rate method.
180    pub zpaq_method: String,
181    /// Optional default Mamba model path when no method is supplied.
182    pub default_mamba_model_path: Option<String>,
183    /// Optional default RWKV7 model path when no method is supplied.
184    pub default_rwkv_model_path: Option<String>,
185    /// Whether `particle` without a method should build `ParticleSpec::default()`.
186    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/// Defaults for shorthand compression-backend parsing such as
211/// CLI/Python `--compression-backend ... --method ...`.
212#[derive(Clone)]
213#[non_exhaustive]
214pub struct CompressionBackendShorthandOptions {
215    /// Base directory used to resolve relative model/spec paths.
216    pub base_dir: PathBuf,
217    /// Default ZPAQ compression method.
218    pub zpaq_method: String,
219    /// Default rate backend for `rate-ac`/`rate-rans` shorthands.
220    pub default_rate_backend: Option<RateBackend>,
221    /// Default framing mode for generic rate-coded compression backends.
222    pub default_framing: crate::compression::FramingMode,
223    /// Optional default RWKV7 model path when no method is supplied.
224    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
239/// Resolve a spec path against a base directory.
240///
241/// Absolute paths are returned unchanged; relative paths are joined to `base_dir`.
242pub 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
251/// Resolve a rate backend alias and require that it is enabled in the current build.
252fn 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
269/// Resolve a rate backend alias and require that it is enabled in the current build.
270pub 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
277/// Resolve a compression backend alias and require that it is enabled in the current build.
278fn 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
299/// Resolve a compression backend alias and require that it is enabled in the current build.
300pub 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
315/// Load a plain JSON value from disk, resolving relative paths against `base_dir`.
316pub 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/// Parse an RWKV7 compression backend from a method string or configured model path,
842/// preserving the shared direct-vs-rate-coded lowering semantics across CLI, JSON,
843/// and binding surfaces.
844#[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
865/// Parse RWKV7 compression backend shorthand into a canonical backend configuration.
866///
867/// When `method` is empty or absent, this falls back to
868/// [`CompressionBackendShorthandOptions::default_rwkv_model_path`].
869/// If the `backend-rwkv` feature is disabled, this returns a spec error.
870pub 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
904/// Serialize a `ParticleSpec` into the canonical JSON representation.
905pub 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
938/// Serialize a `MixtureExpertSpec` into the canonical JSON representation.
939pub 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
953/// Serialize a `MixtureSpec` into the canonical JSON representation.
954pub 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
968/// Serialize a `CalibratedSpec` into the canonical JSON representation.
969pub 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
1058/// Serialize a `RateBackend` into the canonical JSON representation.
1059pub 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
1093/// Serialize a `CompressionBackend` into the canonical JSON representation.
1094pub 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
1125/// Parse a `ParticleSpec` from JSON.
1126pub 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
1205/// Parse a canonical rate-backend JSON object.
1206pub 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
1428/// Parse a canonical compression-backend JSON object.
1429pub 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
1532/// Parse a calibrated backend specification.
1533pub 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
1562/// Parse one mixture expert entry.
1563pub 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
1584/// Parse a `MixtureSpec` from JSON.
1585pub 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
1666/// Load a mixture spec from disk.
1667pub fn load_mixture_spec(path: &str) -> SpecResult<MixtureSpec> {
1668    load_mixture_spec_with_depth(path, MAX_MIXTURE_NESTING)
1669}
1670
1671/// Load a mixture spec from disk with an explicit remaining nesting budget.
1672pub 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
1679/// Load a particle spec from disk.
1680pub 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
1700/// Load a calibrated spec from disk.
1701pub 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
1719/// Load a single expert spec from disk.
1720pub 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
1738/// Build a backend from shorthand CLI/Python-style `name` + optional `method` inputs.
1739pub 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
1904/// Parse and compile a shorthand CLI/Python-style rate backend.
1905pub 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
1914/// Build a compression backend from shorthand CLI/Python-style
1915/// `name` + optional `method` inputs.
1916pub 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
1989/// Parse and compile a shorthand CLI/Python-style compression backend.
1990pub 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}