saccade 0.2.7

Visual-regression CLI for renderers and graphics code, built on NVIDIA FLIP
//! Field-feedback command boundary, keeping core evidence reusable.
use crate::agent::CliError;
use std::path::PathBuf;
pub(crate) const RENDER_SCHEMA: &str = "saccade-render-evidence.v1";
#[derive(clap::Args, Default)]
#[group(id = "FieldEvidenceArgs")]
pub(crate) struct CompareArgs {
    /// Export native FLIP and tile grids as float32 NPY/EXR with a JSON index.
    #[arg(long)]
    pub export_maps: bool,
    /// Require an actual ID layer or nonempty mask scope.
    #[arg(long)]
    pub require_scope: bool,
    /// Same-arm repeat files or run directories (2..32); enables noise-aware deciding evidence.
    #[arg(long,num_args=2..,value_name="REPEAT")]
    pub noise_from: Vec<PathBuf>,
    /// Generic screen-space dump filename relative to each capture; used when no layer manifest exists.
    #[arg(long)]
    #[arg(alias = "mask-from-dump")]
    pub mask_dump: Option<String>,
    /// Named layer and native predicate, NAME=id=1,2 or NAME=label=pattern.
    #[arg(long, allow_hyphen_values = true)]
    pub mask_layer: Option<String>,
    /// Required occupancy from NAME=predicate[:MIN_PIXELS] or mask:FILE[:MIN_PIXELS].
    #[arg(long = "require-effect")]
    pub require_effect: Vec<String>,
    /// Per-ID rows and diagnostic crops retained, at most 32.
    #[arg(long)]
    pub id_top: Option<usize>,
    /// Declared normalized luminance threshold for colour per-ID statistics.
    #[arg(long)]
    pub id_threshold: Option<f64>,
}
impl CompareArgs {
    pub(crate) fn requested(&self) -> bool {
        self.export_maps
            || self.require_scope
            || !self.noise_from.is_empty()
            || self.mask_layer.is_some()
            || !self.require_effect.is_empty()
            || self.mask_dump.is_some()
            || self.id_top.is_some()
            || self.id_threshold.is_some()
    }
    pub(crate) fn apply(&self, cfg: &mut saccade_core::config::RunConfig) -> Result<(), CliError> {
        cfg.field.export_maps |= self.export_maps;
        cfg.field.require_scope |= self.require_scope;
        if !self.noise_from.is_empty() {
            cfg.field.noise_from.clone_from(&self.noise_from);
        }
        if let Some(top) = self.id_top {
            cfg.field.top = top;
        }
        if let Some(threshold) = self.id_threshold {
            cfg.field.threshold = threshold;
        }
        if let Some(dump) = &self.mask_dump {
            let p = cfg.layers.get_or_insert_with(Default::default);
            p.dump = Some(dump.clone());
            p.scope = Some(saccade_core::evidence_quality::layers::LayerScope {
                layer: "derived_instances".into(),
                predicate: saccade_core::evidence_quality::layers::Predicate::Mask,
                mode: Default::default(),
            });
            cfg.mask_mode = saccade_core::compare::MaskMode::Neutralize;
        }
        crate::wave11_cmd::apply_masks(cfg, self.mask_layer.as_deref(), &self.require_effect)?;
        if (cfg.field.export_maps || !cfg.field.noise_from.is_empty()) && cfg.buffers.is_empty() {
            cfg.spatial.get_or_insert_with(Default::default);
        }
        if !cfg.field.noise_from.is_empty()
            && let Some(p) = &mut cfg.spatial
        {
            p.decide = true;
        }
        cfg.field.validate()?;
        Ok(())
    }
}
#[derive(clap::Args)]
pub(crate) struct RenderArgs {
    baseline: PathBuf,
    candidate: PathBuf,
    #[arg(long, default_value = "render-evidence")]
    out: PathBuf,
    #[arg(long)]
    config: Option<PathBuf>,
    #[arg(long)]
    json: bool,
    #[command(flatten)]
    strict: crate::arms_cmd::StrictArgs,
    #[arg(long = "intended-variable")]
    intended: Vec<String>,
    #[command(flatten)]
    field: CompareArgs,
}
pub(crate) fn render(args: RenderArgs) -> Result<u8, CliError> {
    let mut cfg = crate::load_config(args.config.as_deref())?;
    args.strict.apply(&mut cfg.meta);
    cfg.meta.intended.extend(args.intended);
    args.field.apply(&mut cfg)?;
    if cfg.buffers.is_empty() {
        cfg.spatial.get_or_insert_with(Default::default).decide = true;
    }
    let report = saccade_core::run::run(&args.baseline, &args.candidate, &args.out, &cfg)?;
    if args.json {
        let scopes = report
            .entries
            .iter()
            .filter_map(|e| e.field_evidence.as_ref().map(|f| f.scope.as_str()))
            .collect::<std::collections::BTreeSet<_>>();
        let value = serde_json::json!({"schema":RENDER_SCHEMA,"scope":if scopes.len()==1{scopes.first().copied().unwrap_or("unknown")}else{"mixed"},"verdict":if report.is_regression(){"fail"}else{"pass"},"counts":report.totals,"allowed_unreached":report.config.meta.arm_validation.as_ref().map(|a|a.allowed_unreached.iter().take(4).collect::<Vec<_>>()),"artifact":crate::local_cmd::reference(&args.out.join(saccade_core::report::REPORT_FILE_NAME))?,"entries":report.entries.iter().take(3).map(|e|serde_json::json!({"name":crate::local_cmd::short(&e.name,120),"status":e.status,"scope":e.field_evidence.as_ref().map(|f|&f.scope),"class":e.field_evidence.as_ref().map(|f|&f.class),"top_ids":e.field_evidence.as_ref().map(|f|f.per_id.iter().take(3).map(|r|serde_json::json!({"id":r.id,"pixels":r.pixels,"shift":r.shift,"relative":r.relative})).collect::<Vec<_>>()),"error":e.error})).collect::<Vec<_>>()});
        crate::emit(&format!(
            "{}\n",
            serde_json::to_string(&crate::local_cmd::bounded(value, 4096)?)?
        ))?;
    } else {
        crate::emit_run(&report, &args.out, false, false)?;
    }
    Ok(u8::from(report.is_regression()))
}
#[derive(clap::Args)]
pub(crate) struct NoiseBuildArgs {
    /// 2..32 repeats from the same arm, files or run directories.
    #[arg(required=true,num_args=2..)]
    pub repeats: Vec<PathBuf>,
    #[arg(long, default_value = "repeat-noise.json")]
    pub out: PathBuf,
    #[arg(long, default_value_t = 32)]
    pub tile_size: u32,
    #[arg(long)]
    pub json: bool,
    #[command(flatten)]
    pub strict: crate::arms_cmd::StrictArgs,
}
pub(crate) fn noise_build(args: NoiseBuildArgs) -> Result<u8, CliError> {
    let mut cfg = saccade_core::config::RunConfig::default();
    args.strict.apply(&mut cfg.meta);
    for repeat in args.repeats.iter().skip(1) {
        saccade_core::arms::enforce(&args.repeats[0], repeat, &cfg)?;
    }
    let report =
        saccade_core::evidence_quality::repeat_noise::build_runs(&args.repeats, args.tile_size)?;
    crate::general_cmd::write_new(&args.out, &serde_json::to_value(&report)?)?;
    if args.json {
        crate::emit(&format!("{}\n", serde_json::to_string(&report)?))?;
    } else {
        crate::emit(&format!(
            "repeat noise: {} entries; method {}; wrote {}\n",
            report.entries.len(),
            report.method,
            args.out.display()
        ))?;
    }
    Ok(0)
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use clap::Parser;
    #[test]
    fn field_command_arguments_parse_without_changing_legacy_noise() {
        for args in [
            vec!["saccade", "noise", "a", "b", "--json"],
            vec!["saccade", "noise", "build", "a", "b", "--json"],
            vec![
                "saccade",
                "render-evidence",
                "a",
                "b",
                "--noise-from",
                "r1",
                "r2",
                "--allow-unreached",
                "warmup",
                "--json",
            ],
            vec![
                "saccade",
                "compare",
                "a",
                "b",
                "--export-maps",
                "--require-scope",
                "--json",
            ],
        ] {
            assert!(crate::Cli::try_parse_from(args.clone()).is_ok(), "{args:?}");
        }
    }
    #[test]
    #[cfg(not(feature = "local-models"))]
    fn missing_model_dependency_precedes_image_read() {
        let cli = crate::Cli::try_parse_from([
            "saccade",
            "locate",
            "nonexistent-image.png",
            "foreground",
        ])
        .unwrap();
        let err = crate::dispatch(cli.command, false).unwrap_err();
        assert!(err.message.contains("fix:"));
        assert!(err.message.contains("local-models"));
    }
    #[test]
    #[cfg(not(feature = "ocr"))]
    fn missing_ocr_dependency_precedes_image_read() {
        let cli = crate::Cli::try_parse_from([
            "saccade",
            "text",
            "nonexistent-a.png",
            "nonexistent-b.png",
        ])
        .unwrap();
        let err = crate::dispatch(cli.command, false).unwrap_err();
        assert!(err.message.contains("fix:"));
        assert!(err.message.contains("ocr build"));
    }
}