- jpegai_decode::{decode, decode_with_stages} load the bundle's networks
(load_networks, under acc64) beside its scale path and run the hyper decoders,
context stages and synthesis through rec_y, rec_uv, syn_y and syn_uv;
decode_loaded(data, icci, norm, scale path, networks, observe) decodes with
both loaded once. The direct networks are reached only from
jpegai_decode::legacy (Front::Direct, Back::Direct, test-only, legacy_int).
- The loaders no longer panic: Weights::from_json, IntNet::from_json and
IcciNet::from_json return Result<_, BadMember>; a mistyped member is
model_mismatch. IntNet keeps only well-formed tensors, so a member §13.6.1 reads
that is mistyped is missing, as the lowering of the bytes refuses it.
- The neural sink lowers and loads the networks from their eight files' bytes
through the store of loaded sets (keyed by their listing, at acc64), parses only
the eICCI net besides, and reports a refusal (model_mismatch, field_range)
without falling back: a_refused_exact_decode_does_not_fall_back gains a
non-integer network weight and a mistyped eICCI field.
- The browser crate's decodeJpegAiFull reports a mistyped member.
- Goldens, unchanged (build host, release): jpegai_cross_platform's nine pinned
hashes, the Synthesis stage d56695726bb6ebed… among them; the decoded RGB
SHA-256 dbc494a5a670914e… (jpegai_reconstruction_binding with
WAI_JPEGAI_EXPECT_SHA256, jpegai_e2e_full: max|Δ| = 1 on 4.446%).
- Memory (jpegai_memory, reference kernels): with the scale path and the networks
loaded beforehand the decode holds 72,752,387 bytes, the direct decode
175,465,380; the networks take 89,727,913 to load and hold 55,530,900.
- Also green: clippy's disallowed-methods guard and jpegai_decode_symbols, the
sink's three dispatch tests, wai-web's tests and its wasm32 build; include/wai.h
unchanged.
- CHANGELOG: the M7b entry.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
312 lines
20 KiB
Rust
312 lines
20 KiB
Rust
//! End-to-end: raw codestream → **decoded luma pixels**, in one pass with no per-image
|
||
//! fixture at all.
|
||
//!
|
||
//! ```text
|
||
//! test256.bin → parse_container → SOZ → mANS fac → z
|
||
//! → decode_hyper_scale(z) → i_s
|
||
//! → rvs_likely / rvs_additive / skip_mask → sigma, mask
|
||
//! → SORP → mANS sgm → y_q bit-exact
|
||
//! → dequantize_resi (RVS ∘ gain unit) → resi bit-exact
|
||
//! → decode_hyper(z) → ψ SNR ~70 dB
|
||
//! → reconstruct_yhat(resi, ψ) → y_hat SNR ~75 dB
|
||
//! → lsbs_refine → y_lsbs SNR ~75 dB
|
||
//! → decode_y → Y[256²] max|Δ| = 1
|
||
//! ```
|
||
//!
|
||
//! The integer stages are checked bit-exact; the PTQ nets (ψ, the four MCM stages
|
||
//! inside the reconstruction, and synthesis) are checked against the float reference by
|
||
//! signal-to-error, since bit-equality with a float net is not achievable by
|
||
//! construction. Where a stage is a small correction, the bar is set above its
|
||
//! do-nothing baseline — otherwise a no-op would pass.
|
||
//!
|
||
//! At the pixel end, every differing sample is off by exactly 1 (max|Δ| = 1). About
|
||
//! 1.4% of samples differ, which is looser than the reference decoder's own 0.1% bar
|
||
//! because that comparison runs float nets for ψ/MCM/synthesis while this path is
|
||
//! integer throughout. The test attributes the gap: feeding the reference latent into
|
||
//! synthesis alone already yields ~1.2%, so the 16-bit PTQ of the synthesis net is the
|
||
//! dominant term and the whole upstream chain contributes only ~0.2%.
|
||
//!
|
||
//! Every stage here was validated in isolation first; this runs them as one pipeline
|
||
//! off raw bytes. What makes it worth having separately from those unit calibrations
|
||
//! is that nothing per-image is supplied: the σ distribution indices, the coded-position
|
||
//! mask, the RVS band and the dequant multiplier are all *derived* from the bitstream,
|
||
//! so a break anywhere upstream desynchronizes the entropy decoder and the symbols come
|
||
//! out wrong. The mANS state threads across all 160 channel calls, which makes the
|
||
//! symbol check a stringent end-to-end assertion rather than 160 independent ones.
|
||
//!
|
||
//! Inputs are the codestream plus normative assets only — the factorized CDFs, the
|
||
//! me-tANS PMF/bounds tables, the hyper-scale net, and `gain_unit_mlog.csv`. The
|
||
//! transition tables are built here from the normative PMF via
|
||
//! [`build_decode_transitions`] rather than loaded from a fixture.
|
||
//!
|
||
//! env: WAI_JPEGAI_FRONTEND_FIXTURE = frontend_fixture.json
|
||
//! { codestream, cdfs_y, per_channel, z_shape, pmf, bounds, gain_vector_log,
|
||
//! grfs, channels, h, w, y_q_expected, rvs_out_expected,
|
||
//! psi_expected, resi_full_expected, yhat_expected }
|
||
//! WAI_JPEGAI_HYPERSCALE_WEIGHTS = hyperscale_y_weights.json
|
||
//! WAI_JPEGAI_HYPER_WEIGHTS = hyper_y_weights.json (optional; adds ψ)
|
||
//! WAI_JPEGAI_MCM_WEIGHTS_DIR = dir of mcm{0..3}_y.json (optional; adds y_hat)
|
||
//! WAI_JPEGAI_SYNTH_WEIGHTS = synth_y.json (optional; adds pixels)
|
||
#![cfg(all(feature = "neural_int", feature = "legacy_int"))]
|
||
|
||
use wai::jpegai_bitstream::{find_segment, parse_container, parse_picture_header, Marker};
|
||
use wai::jpegai_mans::{build_decode_transitions, cdf_from_pmf, MansDecoder};
|
||
use wai::jpegai_pipeline::{dequantize_resi, gain_unit_scaler_vec, lsbs_refine, reconstruct_yhat, rvs_additive,
|
||
rvs_dequant, rvs_likely, sigma_index, skip_mask};
|
||
use wai::jpegai_synthesis::{decode_hyper, decode_hyper_scale, decode_y, q16_from_symbols, IntNet, Weights};
|
||
|
||
const Z_OFFSET: i64 = 31;
|
||
|
||
#[test]
|
||
fn codestream_to_luma_pixels() {
|
||
let fx = match std::env::var("WAI_JPEGAI_FRONTEND_FIXTURE") {
|
||
Ok(p) if std::path::Path::new(&p).exists() => p,
|
||
_ => { eprintln!("skip: set WAI_JPEGAI_FRONTEND_FIXTURE"); return; }
|
||
};
|
||
let wj = match std::env::var("WAI_JPEGAI_HYPERSCALE_WEIGHTS") {
|
||
Ok(p) if std::path::Path::new(&p).exists() => p,
|
||
_ => { eprintln!("skip: set WAI_JPEGAI_HYPERSCALE_WEIGHTS"); return; }
|
||
};
|
||
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&fx).unwrap()).unwrap();
|
||
let u8v = |k: &str| v[k].as_array().unwrap().iter()
|
||
.map(|x| x.as_u64().unwrap() as u8).collect::<Vec<u8>>();
|
||
let i64v = |k: &str| v[k].as_array().unwrap().iter()
|
||
.map(|x| x.as_i64().unwrap()).collect::<Vec<i64>>();
|
||
|
||
let data = u8v("codestream");
|
||
let (channels, h, w) = (
|
||
v["channels"].as_u64().unwrap() as usize,
|
||
v["h"].as_u64().unwrap() as usize,
|
||
v["w"].as_u64().unwrap() as usize,
|
||
);
|
||
let per_channel = v["per_channel"].as_u64().unwrap() as usize;
|
||
let zs: Vec<usize> = v["z_shape"].as_array().unwrap().iter()
|
||
.map(|x| x.as_u64().unwrap() as usize).collect();
|
||
let (zc, zh, zw) = (zs[1], zs[2], zs[3]);
|
||
let gain_vector_log = i64v("gain_vector_log");
|
||
|
||
let segs = parse_container(&data);
|
||
|
||
// grfs and beta are DERIVED from the codestream's picture header, not the fixture:
|
||
// component [0] is luma. Same parser `jpegai_header_parser_matches_trace` checks
|
||
// against the reference trace, so nothing per-image is assumed.
|
||
let pih = find_segment(&segs, Marker::Pih).expect("PIH");
|
||
let hdr = parse_picture_header(&data, pih.start_bit());
|
||
let grfs = hdr.components[0].grfs_channel_flags.clone();
|
||
let beta = hdr.beta_displacement_log[0] as i64;
|
||
assert_eq!(grfs.len(), channels, "one GRFS flag per luma channel");
|
||
|
||
// 1. SOZ → z
|
||
let soz = find_segment(&segs, Marker::Soz).expect("SOZ");
|
||
let mem = &data[soz.start..soz.start + soz.len];
|
||
let mut dec_z = MansDecoder::new(mem, &[mem.len() as u32]);
|
||
let mut symbols = vec![0u8; zc * per_channel];
|
||
dec_z.decode_factorize(&u8v("cdfs_y"), &mut symbols, zc, per_channel);
|
||
let z: Vec<i64> = symbols.iter().map(|&s| s as i64 - Z_OFFSET).collect();
|
||
|
||
// 2. z → hyper-scale net → i_s
|
||
let net = IntNet::from_json(&serde_json::from_slice(&std::fs::read(&wj).unwrap()).unwrap()).unwrap();
|
||
let (i_s, ..) = decode_hyper_scale(&z, zc, zh, zw, &net);
|
||
assert_eq!(i_s.len(), channels * h * w, "i_s shape");
|
||
|
||
// 3. i_s → the entropy decoder's own inputs, all derived
|
||
let likely = rvs_likely(&i_s, &gain_vector_log, channels, h, w);
|
||
let rvs = rvs_additive(&i_s, &gain_vector_log, &grfs, channels, h, w);
|
||
let sigma = sigma_index(&i_s, &gain_vector_log, &rvs, channels);
|
||
let mask = skip_mask(&i_s, &gain_vector_log, channels);
|
||
let coded = mask.iter().filter(|&&m| m).count();
|
||
eprintln!("derived: {coded} coded positions of {}", mask.len());
|
||
|
||
// 4. SORP → mANS sgm → y_q. One decoder, state threading across every channel.
|
||
let sorp = find_segment(&segs, Marker::Sorp).expect("SORP");
|
||
let mem = &data[sorp.start..sorp.start + sorp.len];
|
||
let pmf: Vec<Vec<i64>> = v["pmf"].as_array().unwrap().iter()
|
||
.map(|r| r.as_array().unwrap().iter().map(|x| x.as_i64().unwrap()).collect())
|
||
.collect();
|
||
let transitions = build_decode_transitions(&pmf, &cdf_from_pmf(&pmf));
|
||
let flat: Vec<u32> = transitions.iter().flatten().copied().collect();
|
||
let bounds = u8v("bounds");
|
||
|
||
let mut dec = MansDecoder::new(mem, &[mem.len() as u32]);
|
||
dec.set_sgm_transitions(&flat, &bounds);
|
||
let plane = h * w;
|
||
let mut y_q = vec![0i16; channels * plane];
|
||
for c in 0..channels {
|
||
let (a, b) = (c * plane, (c + 1) * plane);
|
||
let mut row = vec![0i16; plane];
|
||
dec.decode_sgm(&sigma[a..b], &mut row, &mask[a..b]);
|
||
y_q[a..b].copy_from_slice(&row);
|
||
}
|
||
|
||
let y_q_expected: Vec<i16> = i64v("y_q_expected").iter().map(|&x| x as i16).collect();
|
||
let bad = y_q.iter().zip(&y_q_expected).filter(|(a, b)| a != b).count();
|
||
if let Some((i, (a, b))) = y_q.iter().zip(&y_q_expected).enumerate().find(|(_, (a, b))| a != b) {
|
||
eprintln!("first symbol mismatch @ {i} (ch {}): got {a} exp {b}", i / plane);
|
||
}
|
||
eprintln!("y_q from codestream: {}/{} exact", y_q.len() - bad, y_q.len());
|
||
assert_eq!(bad, 0, "codestream → y_q must be byte-exact");
|
||
|
||
// 1–4 again, through the integer operator set as the decoder runs them
|
||
// (integer-operators §13.6.1): the `fac` decode, the plane's scale graph loaded under
|
||
// acc32, the stage operators and the `sgm` decode. Every integer must be the one
|
||
// above, and y_q the reference's.
|
||
let graph = wai::jpegai_ops::load_scale(wai::jpegai_ops::scale_net(&net).unwrap(), zc, &gain_vector_log).expect("hs_y loads under acc32");
|
||
let rows = wai::jpegai_ops::fac_rows(&i64v("cdfs_y"), &[], wai::jpegai_ops::Plane::Luma);
|
||
let tables = wai_ops::stage::mans::build_latent_tables(&pmf).unwrap();
|
||
let bounds64 = i64v("bounds");
|
||
let setup = wai::jpegai_ops::PlaneSetup { graph: &graph, rows: &rows, skip: 0, tables: &tables, bounds: &bounds64 };
|
||
let ops = wai::jpegai_ops::plane_with(&setup, &data[soz.start..soz.start + soz.len], mem, &grfs).expect("the luma plane decodes through the operator set");
|
||
assert_eq!(ops.z, z, "z through the operator set");
|
||
let v_direct: Vec<i64> = i_s.iter().enumerate().map(|(i, &x)| x + gain_vector_log[i / plane]).collect();
|
||
assert_eq!(ops.v, v_direct, "v through the operator set");
|
||
assert_eq!((&ops.likely, &ops.rvs, &ops.mask), (&likely, &rvs, &mask), "likely, rvs and mask through the operator set");
|
||
assert_eq!(ops.sigma, sigma.iter().map(|&s| s as i64).collect::<Vec<_>>(), "σ_idx through the operator set");
|
||
let ops_bad = ops.y_q.iter().zip(&y_q_expected).filter(|(a, b)| **a != **b as i64).count();
|
||
eprintln!("y_q through the operator set: {}/{} exact", ops.y_q.len() - ops_bad, ops.y_q.len());
|
||
assert_eq!(ops_bad, 0, "codestream → y_q through the operator set must be byte-exact");
|
||
|
||
// 5. y_q → dequantized residual
|
||
let resi = rvs_dequant(&y_q, &likely, &grfs, channels);
|
||
let expected: Vec<f32> = v["rvs_out_expected"].as_array().unwrap().iter()
|
||
.map(|x| x.as_f64().unwrap() as f32).collect();
|
||
let exact = resi.iter().zip(&expected).filter(|(a, b)| a == b).count();
|
||
eprintln!("resi from codestream: {exact}/{} bit-exact", expected.len());
|
||
assert_eq!(exact, expected.len(), "codestream → resi must be bit-exact");
|
||
|
||
// 6. The same z drives the hyper decoder → ψ, the MCM entropy context. This is a
|
||
// PTQ net, so the bar is signal-to-error against the float reference rather than
|
||
// bit-equality — and it is reported against the do-nothing baseline, because a
|
||
// mis-scaled input degrades this stage to *almost exactly* the zero surface
|
||
// without failing (see `decode_hyper`). A raw-symbol z scores 0.7 dB here; a
|
||
// correctly lifted one scores ~70.
|
||
if let (Ok(hw), Some(psi_exp)) = (std::env::var("WAI_JPEGAI_HYPER_WEIGHTS"), v.get("psi_expected")) {
|
||
if std::path::Path::new(&hw).exists() {
|
||
let wt = Weights::from_json(&serde_json::from_slice(&std::fs::read(&hw).unwrap()).unwrap()).unwrap();
|
||
let (psi, ph, pw) = decode_hyper(&q16_from_symbols(&z), zc, zh, zw, &wt);
|
||
let exp: Vec<f32> = psi_exp.as_array().unwrap().iter()
|
||
.map(|x| x.as_f64().unwrap() as f32).collect();
|
||
assert_eq!(psi.len(), exp.len(), "ψ shape [{ph},{pw}]");
|
||
|
||
let (mut se, mut sig) = (0f64, 0f64);
|
||
for (&g, &e) in psi.iter().zip(&exp) {
|
||
let (q, f) = (g as f64 / 65536.0, e as f64);
|
||
se += (q - f) * (q - f);
|
||
sig += f * f;
|
||
}
|
||
let snr = 10.0 * (sig / se).log10();
|
||
eprintln!("ψ from codestream: {} vals [{ph}×{pw}], SNR {snr:.2} dB (zeros would score 0.00)", psi.len());
|
||
assert!(snr > 45.0, "ψ SNR {snr:.2} dB — check the z lift is Q16");
|
||
|
||
// 7. Full dequant (RVS ∘ gain unit) — bit-exact, and the composition the
|
||
// reference uses: the two stages applied in order, not fused.
|
||
let sv = gain_unit_scaler_vec(&gain_vector_log, beta);
|
||
let resi_full = dequantize_resi(&y_q, &likely, &grfs, &sv, channels);
|
||
let rf_exp: Vec<f32> = v["resi_full_expected"].as_array().unwrap().iter()
|
||
.map(|x| x.as_f64().unwrap() as f32).collect();
|
||
let rf_exact = resi_full.iter().zip(&rf_exp).filter(|(a, b)| a == b).count();
|
||
eprintln!("resi (RVS ∘ gain) : {rf_exact}/{} bit-exact", rf_exp.len());
|
||
assert_eq!(rf_exact, rf_exp.len(), "full dequant must be bit-exact");
|
||
|
||
// 8. resi + ψ → SGMM autoregressive reconstruction → y_hat. Four MCM
|
||
// stages, each conditioned on the previously reconstructed sub-lattices,
|
||
// so an error in stage i corrupts every stage after it.
|
||
if let Some(mcm_dir) = std::env::var_os("WAI_JPEGAI_MCM_WEIGHTS_DIR") {
|
||
let load = |i: usize| -> Weights {
|
||
let p = std::path::Path::new(&mcm_dir).join(format!("mcm{i}_y.json"));
|
||
Weights::from_json(&serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()).unwrap()
|
||
};
|
||
let (m0, m1, m2, m3) = (load(0), load(1), load(2), load(3));
|
||
let resi_q16: Vec<i64> = resi_full.iter().map(|&r| (r as f64 * 65536.0).round() as i64).collect();
|
||
let yhat = reconstruct_yhat(&resi_q16, &psi, channels, h, w, [&m0, &m1, &m2, &m3]);
|
||
let yh_exp: Vec<f32> = v["yhat_expected"].as_array().unwrap().iter()
|
||
.map(|x| x.as_f64().unwrap() as f32).collect();
|
||
let (mut se, mut sig) = (0f64, 0f64);
|
||
for (&g, &e) in yhat.iter().zip(&yh_exp) {
|
||
let (q, f) = (g as f64 / 65536.0, e as f64);
|
||
se += (q - f) * (q - f);
|
||
sig += f * f;
|
||
}
|
||
let snr = 10.0 * (sig / se).log10();
|
||
eprintln!("y_hat from codestream: {} vals, SNR {snr:.2} dB", yhat.len());
|
||
assert!(snr > 40.0, "y_hat SNR {snr:.2} dB");
|
||
|
||
// 9. y_hat → LSBS → the synthesis input. Indexes a 4352-entry table by
|
||
// the SAME `likely` the dequant used, so this also cross-checks that
|
||
// one shared derivation against a second, table-direct consumer.
|
||
if let Some(t0j) = v.get("lsbs_t0") {
|
||
let t0: Vec<i32> = t0j.as_array().unwrap().iter()
|
||
.map(|x| x.as_i64().unwrap() as i32).collect();
|
||
let t1: Vec<i32> = v["lsbs_t1"].as_array().unwrap().iter()
|
||
.map(|x| x.as_i64().unwrap() as i32).collect();
|
||
let yhat_f: Vec<f32> = yhat.iter().map(|&q| q as f64 as f32 / 65536.0).collect();
|
||
let y_lsbs = lsbs_refine(&yhat_f, &resi_full, &likely, &t0, &t1, channels, h, w);
|
||
let exp: Vec<f32> = v["lsbs_out_expected"].as_array().unwrap().iter()
|
||
.map(|x| x.as_f64().unwrap() as f32).collect();
|
||
let (mut se, mut sig) = (0f64, 0f64);
|
||
for (&g, &e) in y_lsbs.iter().zip(&exp) {
|
||
se += (g as f64 - e as f64) * (g as f64 - e as f64);
|
||
sig += (e as f64) * (e as f64);
|
||
}
|
||
let snr = 10.0 * (sig / se).log10();
|
||
// The bar must clear the IDENTITY baseline, not just be "high". LSBS
|
||
// is a small correction (max|Δ| ≈ 0.079): passing y_hat through
|
||
// unrefined already scores 51 dB against the refined reference, so a
|
||
// 40 dB bar would pass a no-op. 65 dB sits above identity with margin
|
||
// below the 74.5 dB this actually achieves.
|
||
eprintln!("y_lsbs from codestream: {} vals, SNR {snr:.2} dB (identity baseline 51.00)", y_lsbs.len());
|
||
assert!(snr > 65.0, "y_lsbs SNR {snr:.2} dB — at/below the 51 dB identity baseline means LSBS is a no-op; check the shared `likely`");
|
||
|
||
// 10. y_lsbs → synthesis → the luma plane. This is where the chain
|
||
// first produces pixels, so the bar is the reference decoder's own:
|
||
// deviation of at most 1 LSB, on at most 0.1% of samples.
|
||
if let (Ok(sw), Some(sy_exp)) = (std::env::var("WAI_JPEGAI_SYNTH_WEIGHTS"), v.get("synth_y_expected")) {
|
||
if std::path::Path::new(&sw).exists() {
|
||
let swt = Weights::from_json(&serde_json::from_slice(&std::fs::read(&sw).unwrap()).unwrap()).unwrap();
|
||
let lat: Vec<i64> = y_lsbs.iter().map(|&f| (f as f64 * 65536.0).round() as i64).collect();
|
||
let y_plane = decode_y(&lat, &swt);
|
||
let exp: Vec<f32> = sy_exp.as_array().unwrap().iter()
|
||
.map(|x| x.as_f64().unwrap() as f32).collect();
|
||
assert_eq!(y_plane.len(), exp.len(), "luma plane size");
|
||
let (mut maxd, mut off) = (0i32, 0usize);
|
||
for (&g, &e) in y_plane.iter().zip(&exp) {
|
||
let d = (g as i32 - e.round().clamp(0.0, 255.0) as i32).abs();
|
||
maxd = maxd.max(d);
|
||
if d != 0 { off += 1; }
|
||
}
|
||
let total = exp.len();
|
||
eprintln!("Y plane from codestream: 256×256, max|Δ| = {maxd}, {off}/{total} differ ({:.3}%)",
|
||
100.0 * off as f64 / total as f64);
|
||
|
||
// Isolate: run synthesis on the REFERENCE y_lsbs too. The
|
||
// difference between the two attributes the pixel error to
|
||
// the synthesis port versus everything accumulated upstream.
|
||
let ref_lat: Vec<i64> = v["lsbs_out_expected"].as_array().unwrap().iter()
|
||
.map(|x| (x.as_f64().unwrap() * 65536.0).round() as i64).collect();
|
||
let y_ref_in = decode_y(&ref_lat, &swt);
|
||
let (mut rmax, mut roff) = (0i32, 0usize);
|
||
for (&g, &e) in y_ref_in.iter().zip(&exp) {
|
||
let d = (g as i32 - e.round().clamp(0.0, 255.0) as i32).abs();
|
||
rmax = rmax.max(d);
|
||
if d != 0 { roff += 1; }
|
||
}
|
||
eprintln!("Y plane from REFERENCE latent: max|Δ| = {rmax}, {roff}/{total} differ ({:.3}%) \
|
||
⇒ synthesis-port error {:.3}%, accumulated upstream {:.3}%",
|
||
100.0 * roff as f64 / total as f64,
|
||
100.0 * roff as f64 / total as f64,
|
||
100.0 * (off as f64 - roff as f64) / total as f64);
|
||
|
||
assert!(maxd <= 1, "luma deviation beyond rounding: {maxd}");
|
||
// The whole-chain bar is looser than the reference decoder's
|
||
// 0.1%: this path is integer end-to-end, so PTQ error from ψ,
|
||
// the four MCM stages and LSBS all accumulates into the latent
|
||
// before synthesis sees it. The reference comparison runs the
|
||
// float nets for those stages. Every differing pixel is off by
|
||
// exactly 1, which is the property that actually matters.
|
||
assert!(off * 100 <= total * 2, "more than 2% mismatched: {off}/{total}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|