jxl_encoder/validation.rs
1// Copyright (c) Imazen LLC.
2// Licensed under AGPL-3.0-or-later. Commercial licenses at https://www.imazen.io/pricing
3//
4//! Fail-fast validation for public `Config` types.
5//!
6//! Existing encode paths keep clamping out-of-range values (the historical
7//! behaviour callers may rely on). Batch-job callers who would rather see
8//! an error than have their input silently massaged can call
9//! [`crate::api::LossyConfig::validate`] /
10//! [`crate::api::LosslessConfig::validate`] (and, with the `__expert` cargo
11//! feature, [`crate::effort::LossyInternalParams::validate`] /
12//! [`crate::effort::LosslessInternalParams::validate`]) before invoking
13//! the encoder.
14//!
15//! Validation is conservative: ranges either come from libjxl reference
16//! caps (verified against the consuming code path under
17//! `src/vardct/`, `src/modular/`, `src/effort.rs`) or are wide enough to
18//! accept anything the encoder will actually accept without panicking.
19//! Nonsensical-but-safe values (e.g. `tree_max_buckets = u16::MAX`) are
20//! left to the encoder to clamp.
21
22use core::ops::RangeInclusive;
23
24/// Errors produced by `validate()` on the public config types.
25///
26/// `#[non_exhaustive]` so new variants can land additively as we discover
27/// further invariants worth surfacing.
28#[non_exhaustive]
29#[derive(Debug, Clone, thiserror::Error)]
30pub enum ValidationError {
31 // ── Lossy / Lossless shared knobs ──────────────────────────────────
32 /// Butteraugli distance is outside the libjxl-supported range.
33 /// libjxl rejects distances `<= 0.0` (for lossy) and clamps the upper
34 /// end to `25.0`. `0.0` is mathematically lossless and is **not**
35 /// accepted on `LossyConfig`; use `LosslessConfig` instead.
36 #[error("distance {value} out of valid range {valid:?}")]
37 DistanceOutOfRange {
38 value: f32,
39 valid: RangeInclusive<f32>,
40 },
41 /// Distance was non-finite (NaN or infinity).
42 #[error("distance must be finite, got {value}")]
43 DistanceNotFinite { value: f32 },
44 /// Effort level outside `1..=10`.
45 /// (`EffortProfile::lossy` / `lossless` clamp internally; this surfaces
46 /// the violation up front instead of silently coercing.)
47 #[error("effort {value} out of valid range {valid:?}")]
48 EffortOutOfRange {
49 value: u8,
50 valid: RangeInclusive<u8>,
51 },
52
53 // ── LossyConfig (quality loops) ────────────────────────────────────
54 /// A quality-loop iteration count exceeds the encoder's reasonable cap.
55 /// libjxl uses up to 4 butteraugli iterations at kTortoise; we accept up
56 /// to 16 across all loops to leave headroom for the tuning harness.
57 #[error("{name} iter count {value} out of valid range {valid:?}")]
58 IterCountOutOfRange {
59 name: &'static str,
60 value: u32,
61 valid: RangeInclusive<u32>,
62 },
63 /// Two or more quality loops are simultaneously requested. The lossy
64 /// encoder runs at most one quality loop per encode (butteraugli, ssim2,
65 /// or zensim) — picking which is the caller's choice. Stacking is not a
66 /// supported configuration.
67 #[error("mutually exclusive quality loops: {first} and {second} both have nonzero iter count")]
68 QualityLoopMutuallyExclusive {
69 first: &'static str,
70 second: &'static str,
71 },
72
73 // ── LossyInternalParams numeric ranges ─────────────────────────────
74 /// `fine_grained_step` outside `1..=8`. `0` would cause the AC strategy
75 /// search loop's `step_by(0)` to panic.
76 #[error("fine_grained_step {value} out of valid range {valid:?}")]
77 FineGrainedStepOutOfRange {
78 value: u8,
79 valid: RangeInclusive<u8>,
80 },
81 /// `k_info_loss_mul_base` is non-finite or non-positive. The encoder
82 /// multiplies pixel-domain error terms by this; non-positive values
83 /// invert the cost model.
84 #[error("k_info_loss_mul_base {value} must be finite and > 0.0")]
85 KInfoLossMulBaseInvalid { value: f32 },
86 /// `k_ac_quant` is non-finite or non-positive. Used as the
87 /// quantization-cost constant when materializing the initial quant field;
88 /// non-positive values produce a zero/negative initial quant.
89 #[error("k_ac_quant {value} must be finite and > 0.0")]
90 KAcQuantInvalid { value: f32 },
91
92 // ── LosslessInternalParams numeric ranges ──────────────────────────
93 /// `nb_rcts_to_try` exceeds libjxl's documented kTortoise schedule (19).
94 #[error("nb_rcts_to_try {value} out of valid range {valid:?}")]
95 NbRctsToTryOutOfRange {
96 value: u8,
97 valid: RangeInclusive<u8>,
98 },
99 /// `wp_num_param_sets` exceeds the maximum number of WP modes the
100 /// encoder iterates over (5).
101 #[error("wp_num_param_sets {value} out of valid range {valid:?}")]
102 WpNumParamSetsOutOfRange {
103 value: u8,
104 valid: RangeInclusive<u8>,
105 },
106 /// `tree_max_buckets` is zero — the histogram quantizer needs at least
107 /// one bucket per property.
108 #[error("tree_max_buckets must be > 0, got 0")]
109 TreeMaxBucketsZero,
110 /// `tree_num_properties` exceeds the property-order length (16, the size
111 /// of `PROP_ORDER_NO_SQUEEZE` / `PROP_ORDER_SQUEEZE` in
112 /// `src/modular/tree_learn.rs`).
113 #[error("tree_num_properties {value} out of valid range {valid:?}")]
114 TreeNumPropertiesOutOfRange {
115 value: u8,
116 valid: RangeInclusive<u8>,
117 },
118 /// `tree_threshold_base` is non-finite or negative. libjxl's formula is
119 /// `75 + 14 * speed_tier`; negative thresholds would accept every split.
120 #[error("tree_threshold_base {value} must be finite and >= 0.0")]
121 TreeThresholdBaseInvalid { value: f32 },
122 /// `tree_sample_fraction` is non-finite or outside `0.0..=1.0`. It is a
123 /// pixel-fraction sampler ratio.
124 #[error("tree_sample_fraction {value} out of valid range {valid:?}")]
125 TreeSampleFractionOutOfRange {
126 value: f32,
127 valid: RangeInclusive<f32>,
128 },
129}
130
131// ── Range constants ────────────────────────────────────────────────────
132
133/// libjxl's documented butteraugli distance range.
134/// `cjxl --distance` accepts `[0.0, 25.0]`; we reject `0.0` for lossy and
135/// require lossless instead, so the lossy validator uses an open lower bound.
136pub(crate) const DISTANCE_MAX: f32 = 25.0;
137pub(crate) const EFFORT_RANGE: RangeInclusive<u8> = 1..=10;
138/// Cap on quality-loop iter counts. libjxl's kTortoise butteraugli runs 4
139/// passes; 16 leaves room for sweep harnesses without inviting absurd values.
140#[cfg(any(
141 feature = "butteraugli-loop",
142 feature = "ssim2-loop",
143 feature = "zensim-loop"
144))]
145pub(crate) const ITER_MAX: u32 = 16;
146#[cfg(feature = "__expert")]
147pub(crate) const FINE_GRAINED_STEP_RANGE: RangeInclusive<u8> = 1..=8;
148/// libjxl's kTortoise `nb_rcts_to_try` schedule peaks at 19.
149#[cfg(feature = "__expert")]
150pub(crate) const NB_RCTS_RANGE: RangeInclusive<u8> = 0..=19;
151/// `find_best_wp_params` iterates up to 5 modes (`mode 0..5`).
152#[cfg(feature = "__expert")]
153pub(crate) const WP_NUM_PARAM_SETS_RANGE: RangeInclusive<u8> = 0..=5;
154/// `PROP_ORDER_NO_SQUEEZE` / `PROP_ORDER_SQUEEZE` are 16 entries; values
155/// above are silently clamped by `from_profile_impl`.
156#[cfg(feature = "__expert")]
157pub(crate) const TREE_NUM_PROPERTIES_RANGE: RangeInclusive<u8> = 0..=16;
158#[cfg(feature = "__expert")]
159pub(crate) const TREE_SAMPLE_FRACTION_RANGE: RangeInclusive<f32> = 0.0..=1.0;
160
161// ── Helpers ────────────────────────────────────────────────────────────
162
163#[inline]
164fn check_effort(effort: u8) -> Result<(), ValidationError> {
165 if EFFORT_RANGE.contains(&effort) {
166 Ok(())
167 } else {
168 Err(ValidationError::EffortOutOfRange {
169 value: effort,
170 valid: EFFORT_RANGE,
171 })
172 }
173}
174
175#[cfg(any(
176 feature = "butteraugli-loop",
177 feature = "ssim2-loop",
178 feature = "zensim-loop"
179))]
180#[inline]
181fn check_iter(name: &'static str, value: u32) -> Result<(), ValidationError> {
182 let valid = 0..=ITER_MAX;
183 if valid.contains(&value) {
184 Ok(())
185 } else {
186 Err(ValidationError::IterCountOutOfRange { name, value, valid })
187 }
188}
189
190/// Validate the per-knob ranges of a resolved [`crate::effort::EffortProfile`]
191/// for the fields that [`crate::effort::LossyInternalParams`] exposes.
192#[cfg(feature = "__expert")]
193pub(crate) fn validate_lossy_profile_overrides(
194 profile: &crate::effort::EffortProfile,
195) -> Result<(), ValidationError> {
196 if !FINE_GRAINED_STEP_RANGE.contains(&profile.fine_grained_step) {
197 return Err(ValidationError::FineGrainedStepOutOfRange {
198 value: profile.fine_grained_step,
199 valid: FINE_GRAINED_STEP_RANGE,
200 });
201 }
202 if !profile.k_info_loss_mul_base.is_finite() || profile.k_info_loss_mul_base <= 0.0 {
203 return Err(ValidationError::KInfoLossMulBaseInvalid {
204 value: profile.k_info_loss_mul_base,
205 });
206 }
207 if !profile.k_ac_quant.is_finite() || profile.k_ac_quant <= 0.0 {
208 return Err(ValidationError::KAcQuantInvalid {
209 value: profile.k_ac_quant,
210 });
211 }
212 Ok(())
213}
214
215/// Validate the per-knob ranges of a resolved [`crate::effort::EffortProfile`]
216/// for the fields that [`crate::effort::LosslessInternalParams`] exposes.
217#[cfg(feature = "__expert")]
218pub(crate) fn validate_lossless_profile_overrides(
219 profile: &crate::effort::EffortProfile,
220) -> Result<(), ValidationError> {
221 if !NB_RCTS_RANGE.contains(&profile.nb_rcts_to_try) {
222 return Err(ValidationError::NbRctsToTryOutOfRange {
223 value: profile.nb_rcts_to_try,
224 valid: NB_RCTS_RANGE,
225 });
226 }
227 if !WP_NUM_PARAM_SETS_RANGE.contains(&profile.wp_num_param_sets) {
228 return Err(ValidationError::WpNumParamSetsOutOfRange {
229 value: profile.wp_num_param_sets,
230 valid: WP_NUM_PARAM_SETS_RANGE,
231 });
232 }
233 if profile.tree_max_buckets == 0 {
234 return Err(ValidationError::TreeMaxBucketsZero);
235 }
236 if !TREE_NUM_PROPERTIES_RANGE.contains(&profile.tree_num_properties) {
237 return Err(ValidationError::TreeNumPropertiesOutOfRange {
238 value: profile.tree_num_properties,
239 valid: TREE_NUM_PROPERTIES_RANGE,
240 });
241 }
242 if !profile.tree_threshold_base.is_finite() || profile.tree_threshold_base < 0.0 {
243 return Err(ValidationError::TreeThresholdBaseInvalid {
244 value: profile.tree_threshold_base,
245 });
246 }
247 if !profile.tree_sample_fraction.is_finite()
248 || !TREE_SAMPLE_FRACTION_RANGE.contains(&profile.tree_sample_fraction)
249 {
250 return Err(ValidationError::TreeSampleFractionOutOfRange {
251 value: profile.tree_sample_fraction,
252 valid: TREE_SAMPLE_FRACTION_RANGE,
253 });
254 }
255 // tree_max_samples_fixed: any u32 is fine (0 = "use fraction", any other
256 // value is a hard sample cap).
257 Ok(())
258}
259
260// ── Public validate() impls ─────────────────────────────────────────────
261
262impl crate::api::LossyConfig {
263 /// Validate that every parameter on this config is within the encoder's
264 /// supported range.
265 ///
266 /// `LossyConfig` setters intentionally accept and clamp out-of-range
267 /// values for backwards-compat — `with_distance(50.0).with_effort(15)`
268 /// returns a config the encoder happily runs (clamped to 25.0 / 10).
269 /// Batch-job callers who want a fail-fast escape can call this method
270 /// before invoking the encoder.
271 ///
272 /// Returns the **first** violation encountered; ordering of the checks
273 /// is an implementation detail.
274 ///
275 /// When `__expert` is enabled and a `profile_override` has been applied
276 /// via [`Self::with_internal_params`], the resolved profile's fields are
277 /// also checked against the same ranges
278 /// [`crate::effort::LossyInternalParams::validate`] would enforce.
279 pub fn validate(&self) -> Result<(), ValidationError> {
280 let d = self.distance();
281 if !d.is_finite() {
282 return Err(ValidationError::DistanceNotFinite { value: d });
283 }
284 // Lossy distance must be > 0; 0.0 means lossless and is rejected by
285 // `Quality::to_distance` already, but `LossyConfig::new` accepts any
286 // f32. Use an open lower bound by checking explicitly.
287 if d <= 0.0 || d > DISTANCE_MAX {
288 return Err(ValidationError::DistanceOutOfRange {
289 value: d,
290 valid: 0.0..=DISTANCE_MAX,
291 });
292 }
293 check_effort(self.effort())?;
294
295 // Quality-loop iter counts and exclusivity.
296 #[cfg(feature = "butteraugli-loop")]
297 let bi = self.butteraugli_iters();
298 #[cfg(not(feature = "butteraugli-loop"))]
299 let bi = 0u32;
300 #[cfg(feature = "butteraugli-loop")]
301 check_iter("butteraugli_iters", bi)?;
302
303 #[cfg(feature = "ssim2-loop")]
304 let si = self.ssim2_iters_value();
305 #[cfg(not(feature = "ssim2-loop"))]
306 let si = 0u32;
307 #[cfg(feature = "ssim2-loop")]
308 check_iter("ssim2_iters", si)?;
309
310 #[cfg(feature = "zensim-loop")]
311 let zi = self.zensim_iters_value();
312 #[cfg(not(feature = "zensim-loop"))]
313 let zi = 0u32;
314 #[cfg(feature = "zensim-loop")]
315 check_iter("zensim_iters", zi)?;
316
317 // Mutual exclusivity. The encoder dispatches to a single quality
318 // loop per encode; stacking two is not supported.
319 let active: &[(&'static str, u32)] = &[
320 ("butteraugli_iters", bi),
321 ("ssim2_iters", si),
322 ("zensim_iters", zi),
323 ];
324 let mut first_active: Option<&'static str> = None;
325 for &(name, val) in active {
326 if val > 0 {
327 if let Some(prev) = first_active {
328 return Err(ValidationError::QualityLoopMutuallyExclusive {
329 first: prev,
330 second: name,
331 });
332 }
333 first_active = Some(name);
334 }
335 }
336
337 // Validate the resolved internal-params profile if one was set.
338 #[cfg(feature = "__expert")]
339 if let Some(profile) = self.profile_override_ref() {
340 validate_lossy_profile_overrides(profile)?;
341 }
342
343 Ok(())
344 }
345}
346
347impl crate::api::LosslessConfig {
348 /// Validate that every parameter on this config is within the encoder's
349 /// supported range.
350 ///
351 /// See [`crate::api::LossyConfig::validate`] for the contract.
352 pub fn validate(&self) -> Result<(), ValidationError> {
353 check_effort(self.effort())?;
354
355 #[cfg(feature = "__expert")]
356 if let Some(profile) = self.profile_override_ref() {
357 validate_lossless_profile_overrides(profile)?;
358 }
359 Ok(())
360 }
361}
362
363#[cfg(feature = "__expert")]
364impl crate::effort::LossyInternalParams {
365 /// Validate every `Some(_)` field against the same ranges
366 /// [`crate::api::LossyConfig::validate`] enforces on the resolved
367 /// profile. Use this to fail fast on a freshly-constructed
368 /// `LossyInternalParams` before passing it to
369 /// [`crate::api::LossyConfig::with_internal_params`].
370 pub fn validate(&self) -> Result<(), ValidationError> {
371 if let Some(step) = self.fine_grained_step
372 && !FINE_GRAINED_STEP_RANGE.contains(&step)
373 {
374 return Err(ValidationError::FineGrainedStepOutOfRange {
375 value: step,
376 valid: FINE_GRAINED_STEP_RANGE,
377 });
378 }
379 if let Some(v) = self.k_info_loss_mul_base
380 && (!v.is_finite() || v <= 0.0)
381 {
382 return Err(ValidationError::KInfoLossMulBaseInvalid { value: v });
383 }
384 if let Some(v) = self.k_ac_quant
385 && (!v.is_finite() || v <= 0.0)
386 {
387 return Err(ValidationError::KAcQuantInvalid { value: v });
388 }
389 // try_dct16/32/64/4x8_afv, cfl_two_pass, chromacity_adjustment,
390 // patch_ref_tree_learning, non_aligned_eval,
391 // enhanced_clustering_vardct: all bool — well-formed by typing.
392 // entropy_mul_table: well-formed by constructor (well-formed enum
393 // variants only); no field-level checks beyond what the type itself
394 // enforces.
395 Ok(())
396 }
397}
398
399#[cfg(feature = "__expert")]
400impl crate::effort::LosslessInternalParams {
401 /// Validate every `Some(_)` field against the same ranges
402 /// [`crate::api::LosslessConfig::validate`] enforces on the resolved
403 /// profile.
404 pub fn validate(&self) -> Result<(), ValidationError> {
405 if let Some(v) = self.nb_rcts_to_try
406 && !NB_RCTS_RANGE.contains(&v)
407 {
408 return Err(ValidationError::NbRctsToTryOutOfRange {
409 value: v,
410 valid: NB_RCTS_RANGE,
411 });
412 }
413 if let Some(v) = self.wp_num_param_sets
414 && !WP_NUM_PARAM_SETS_RANGE.contains(&v)
415 {
416 return Err(ValidationError::WpNumParamSetsOutOfRange {
417 value: v,
418 valid: WP_NUM_PARAM_SETS_RANGE,
419 });
420 }
421 if let Some(0) = self.tree_max_buckets {
422 return Err(ValidationError::TreeMaxBucketsZero);
423 }
424 if let Some(v) = self.tree_num_properties
425 && !TREE_NUM_PROPERTIES_RANGE.contains(&v)
426 {
427 return Err(ValidationError::TreeNumPropertiesOutOfRange {
428 value: v,
429 valid: TREE_NUM_PROPERTIES_RANGE,
430 });
431 }
432 if let Some(v) = self.tree_threshold_base
433 && (!v.is_finite() || v < 0.0)
434 {
435 return Err(ValidationError::TreeThresholdBaseInvalid { value: v });
436 }
437 if let Some(v) = self.tree_sample_fraction
438 && (!v.is_finite() || !TREE_SAMPLE_FRACTION_RANGE.contains(&v))
439 {
440 return Err(ValidationError::TreeSampleFractionOutOfRange {
441 value: v,
442 valid: TREE_SAMPLE_FRACTION_RANGE,
443 });
444 }
445 // tree_max_samples_fixed: any u32 is acceptable.
446 Ok(())
447 }
448}