Skip to main content

dsfb_semiconductor/
cli.rs

1use crate::calibration::{run_secom_calibration, run_secom_dsa_calibration, CalibrationGrid};
2use crate::config::PipelineConfig;
3use crate::dataset::phm2018;
4use crate::dataset::secom;
5use crate::error::Result;
6use crate::non_intrusive::materialize_non_intrusive_artifacts;
7use crate::output_paths::{default_data_root, default_output_root};
8use crate::phm2018_loader::run_phm2018_benchmark;
9use crate::pipeline::{run_secom_benchmark, PaperLockMetrics};
10use crate::unified_value_figure::{render_unified_value_figure, resolve_latest_completed_run};
11use clap::{Args, Parser, Subcommand};
12use std::path::PathBuf;
13
14#[derive(Debug, Parser)]
15#[command(name = "dsfb-semiconductor")]
16#[command(about = "DSFB semiconductor benchmark companion")]
17pub struct Cli {
18    #[command(subcommand)]
19    command: Command,
20}
21
22#[derive(Debug, Subcommand)]
23enum Command {
24    FetchSecom(DataArgs),
25    RunSecom(RunSecomArgs),
26    CalibrateSecom(CalibrateSecomArgs),
27    CalibrateSecomDsa(CalibrateSecomDsaArgs),
28    ProbePhm2018(ProbePhm2018Args),
29    RunPhm2018(RunPhm2018Args),
30    RenderNonIntrusiveArtifacts(RenderNonIntrusiveArtifactsArgs),
31    RenderUnifiedValueFigure(RenderUnifiedValueFigureArgs),
32    SbirDemo(SbirDemoArgs),
33    /// Verify that the crate reproduces the paper headline numbers.
34    ///
35    /// Runs the SECOM benchmark with the fixed paper-lock configuration
36    /// (all_features [compression_biased], W=5, K=20, tau=2.0, m=2) and
37    /// checks that:
38    ///   - episode count  == 71
39    ///   - precision      >= 0.80 (paper value: 80.3 %)
40    ///   - recall count   == 104 / 104
41    ///
42    /// Exits 0 on match, 1 on mismatch.
43    PaperLock(PaperLockArgs),
44}
45
46#[derive(Debug, Args)]
47struct DataArgs {
48    #[arg(long)]
49    data_root: Option<PathBuf>,
50}
51
52#[derive(Debug, Args)]
53struct RunSecomArgs {
54    #[command(flatten)]
55    data: DataArgs,
56    #[arg(long)]
57    output_root: Option<PathBuf>,
58    #[arg(long, default_value_t = false)]
59    fetch_if_missing: bool,
60    #[arg(long, default_value_t = 100)]
61    healthy_pass_runs: usize,
62    #[arg(long, default_value_t = 5)]
63    drift_window: usize,
64    #[arg(long, default_value_t = 3.0)]
65    envelope_sigma: f64,
66    #[arg(long, default_value_t = 0.5)]
67    boundary_fraction_of_rho: f64,
68    #[arg(long, default_value_t = 2)]
69    state_confirmation_steps: usize,
70    #[arg(long, default_value_t = 2)]
71    persistent_state_steps: usize,
72    #[arg(long, default_value_t = 10)]
73    density_window: usize,
74    #[arg(long, default_value_t = 0.2)]
75    ewma_alpha: f64,
76    #[arg(long, default_value_t = 3.0)]
77    ewma_sigma_multiplier: f64,
78    #[arg(long, default_value_t = 0.5)]
79    cusum_kappa_sigma_multiplier: f64,
80    #[arg(long, default_value_t = 5.0)]
81    cusum_alarm_sigma_multiplier: f64,
82    #[arg(long, default_value_t = 3.0)]
83    run_energy_sigma_multiplier: f64,
84    #[arg(long, default_value_t = 0.95)]
85    pca_variance_explained: f64,
86    #[arg(long, default_value_t = 3.0)]
87    pca_t2_sigma_multiplier: f64,
88    #[arg(long, default_value_t = 3.0)]
89    pca_spe_sigma_multiplier: f64,
90    #[arg(long, default_value_t = 3.0)]
91    drift_sigma_multiplier: f64,
92    #[arg(long, default_value_t = 3.0)]
93    slew_sigma_multiplier: f64,
94    #[arg(long, default_value_t = 10)]
95    grazing_window: usize,
96    #[arg(long, default_value_t = 3)]
97    grazing_min_hits: usize,
98    #[arg(long, default_value_t = 20)]
99    pre_failure_lookback_runs: usize,
100    #[arg(long, default_value_t = 5)]
101    dsa_window: usize,
102    #[arg(long, default_value_t = 2)]
103    dsa_persistence_runs: usize,
104    #[arg(long, default_value_t = 2.0)]
105    dsa_alert_tau: f64,
106    #[arg(long, default_value_t = 2)]
107    dsa_corroborating_feature_count_min: usize,
108}
109
110#[derive(Debug, Args)]
111struct CalibrateSecomArgs {
112    #[command(flatten)]
113    data: DataArgs,
114    #[arg(long)]
115    output_root: Option<PathBuf>,
116    #[arg(long, default_value_t = false)]
117    fetch_if_missing: bool,
118    #[arg(long, value_delimiter = ',', default_value = "100")]
119    healthy_pass_runs_grid: Vec<usize>,
120    #[arg(long, value_delimiter = ',', default_value = "5")]
121    drift_window_grid: Vec<usize>,
122    #[arg(long, value_delimiter = ',', default_value = "3.0")]
123    envelope_sigma_grid: Vec<f64>,
124    #[arg(long, value_delimiter = ',', default_value = "0.5")]
125    boundary_fraction_of_rho_grid: Vec<f64>,
126    #[arg(long, value_delimiter = ',', default_value = "2")]
127    state_confirmation_steps_grid: Vec<usize>,
128    #[arg(long, value_delimiter = ',', default_value = "2")]
129    persistent_state_steps_grid: Vec<usize>,
130    #[arg(long, value_delimiter = ',', default_value = "10")]
131    density_window_grid: Vec<usize>,
132    #[arg(long, value_delimiter = ',', default_value = "0.2")]
133    ewma_alpha_grid: Vec<f64>,
134    #[arg(long, value_delimiter = ',', default_value = "3.0")]
135    ewma_sigma_multiplier_grid: Vec<f64>,
136    #[arg(long, value_delimiter = ',', default_value = "0.5")]
137    cusum_kappa_sigma_multiplier_grid: Vec<f64>,
138    #[arg(long, value_delimiter = ',', default_value = "5.0")]
139    cusum_alarm_sigma_multiplier_grid: Vec<f64>,
140    #[arg(long, value_delimiter = ',', default_value = "3.0")]
141    run_energy_sigma_multiplier_grid: Vec<f64>,
142    #[arg(long, value_delimiter = ',', default_value = "0.95")]
143    pca_variance_explained_grid: Vec<f64>,
144    #[arg(long, value_delimiter = ',', default_value = "3.0")]
145    pca_t2_sigma_multiplier_grid: Vec<f64>,
146    #[arg(long, value_delimiter = ',', default_value = "3.0")]
147    pca_spe_sigma_multiplier_grid: Vec<f64>,
148    #[arg(long, value_delimiter = ',', default_value = "3.0")]
149    drift_sigma_multiplier_grid: Vec<f64>,
150    #[arg(long, value_delimiter = ',', default_value = "3.0")]
151    slew_sigma_multiplier_grid: Vec<f64>,
152    #[arg(long, value_delimiter = ',', default_value = "10")]
153    grazing_window_grid: Vec<usize>,
154    #[arg(long, value_delimiter = ',', default_value = "3")]
155    grazing_min_hits_grid: Vec<usize>,
156    #[arg(long, value_delimiter = ',', default_value = "20")]
157    pre_failure_lookback_runs_grid: Vec<usize>,
158}
159
160#[derive(Debug, Args)]
161struct CalibrateSecomDsaArgs {
162    #[command(flatten)]
163    data: DataArgs,
164    #[arg(long)]
165    output_root: Option<PathBuf>,
166    #[arg(long, default_value_t = false)]
167    fetch_if_missing: bool,
168    #[arg(long, default_value_t = 100)]
169    healthy_pass_runs: usize,
170    #[arg(long, default_value_t = 5)]
171    drift_window: usize,
172    #[arg(long, default_value_t = 3.0)]
173    envelope_sigma: f64,
174    #[arg(long, default_value_t = 0.5)]
175    boundary_fraction_of_rho: f64,
176    #[arg(long, default_value_t = 2)]
177    state_confirmation_steps: usize,
178    #[arg(long, default_value_t = 2)]
179    persistent_state_steps: usize,
180    #[arg(long, default_value_t = 10)]
181    density_window: usize,
182    #[arg(long, default_value_t = 0.2)]
183    ewma_alpha: f64,
184    #[arg(long, default_value_t = 3.0)]
185    ewma_sigma_multiplier: f64,
186    #[arg(long, default_value_t = 0.5)]
187    cusum_kappa_sigma_multiplier: f64,
188    #[arg(long, default_value_t = 5.0)]
189    cusum_alarm_sigma_multiplier: f64,
190    #[arg(long, default_value_t = 3.0)]
191    run_energy_sigma_multiplier: f64,
192    #[arg(long, default_value_t = 0.95)]
193    pca_variance_explained: f64,
194    #[arg(long, default_value_t = 3.0)]
195    pca_t2_sigma_multiplier: f64,
196    #[arg(long, default_value_t = 3.0)]
197    pca_spe_sigma_multiplier: f64,
198    #[arg(long, default_value_t = 3.0)]
199    drift_sigma_multiplier: f64,
200    #[arg(long, default_value_t = 3.0)]
201    slew_sigma_multiplier: f64,
202    #[arg(long, default_value_t = 10)]
203    grazing_window: usize,
204    #[arg(long, default_value_t = 3)]
205    grazing_min_hits: usize,
206    #[arg(long, default_value_t = 20)]
207    pre_failure_lookback_runs: usize,
208    #[arg(long, value_delimiter = ',', default_value = "5,10,15")]
209    dsa_window_grid: Vec<usize>,
210    #[arg(long, value_delimiter = ',', default_value = "2,3,4")]
211    dsa_persistence_runs_grid: Vec<usize>,
212    #[arg(long, value_delimiter = ',', default_value = "2.0,2.5,3.0")]
213    dsa_alert_tau_grid: Vec<f64>,
214    #[arg(long, value_delimiter = ',', default_value = "2,3,5")]
215    dsa_corroborating_feature_count_min_grid: Vec<usize>,
216}
217
218#[derive(Debug, Args)]
219struct ProbePhm2018Args {
220    #[arg(long)]
221    archive: Option<PathBuf>,
222    #[arg(long)]
223    data_root: Option<PathBuf>,
224}
225
226#[derive(Debug, Args)]
227struct RunPhm2018Args {
228    #[arg(long)]
229    data_root: Option<PathBuf>,
230    #[arg(long)]
231    output_root: Option<PathBuf>,
232    #[arg(long)]
233    secom_run_dir: Option<PathBuf>,
234}
235
236#[derive(Debug, Args)]
237struct SbirDemoArgs {
238    /// Optional data root; defaults to the crate-local data directory.
239    #[arg(long)]
240    data_root: Option<PathBuf>,
241    /// Optional output root; defaults to the crate-local output directory.
242    #[arg(long)]
243    output_root: Option<PathBuf>,
244    /// Fetch SECOM dataset automatically if absent (default: true).
245    #[arg(long, default_value_t = true)]
246    fetch_if_missing: bool,
247}
248
249#[derive(Debug, Args)]
250struct PaperLockArgs {
251    /// Optional data root; defaults to the crate-local data directory.
252    #[arg(long)]
253    data_root: Option<PathBuf>,
254    /// Optional output root; defaults to the crate-local output directory.
255    #[arg(long)]
256    output_root: Option<PathBuf>,
257    /// Fetch SECOM dataset automatically if absent (default: true).
258    #[arg(long, default_value_t = true)]
259    fetch_if_missing: bool,
260}
261
262#[derive(Debug, Args)]
263struct RenderNonIntrusiveArtifactsArgs {
264    #[arg(long)]
265    run_dir: PathBuf,
266}
267
268#[derive(Debug, Args)]
269struct RenderUnifiedValueFigureArgs {
270    #[arg(long)]
271    secom_run_dir: Option<PathBuf>,
272    #[arg(long)]
273    phm_run_dir: Option<PathBuf>,
274    #[arg(long)]
275    output_root: Option<PathBuf>,
276    #[arg(long)]
277    paper_tex: Option<PathBuf>,
278}
279
280pub fn run() -> Result<()> {
281    let cli = Cli::parse();
282    match cli.command {
283        Command::FetchSecom(args) => {
284            let data_root = args.data_root.unwrap_or_else(default_data_root);
285            let paths = secom::fetch_if_missing(&data_root)?;
286            println!("SECOM dataset ready at {}", paths.root.display());
287            println!("Archive: {}", paths.archive.display());
288            Ok(())
289        }
290        Command::RunSecom(args) => {
291            let data_root = args.data.data_root.unwrap_or_else(default_data_root);
292            let output_root = args.output_root.unwrap_or_else(default_output_root);
293            let config = PipelineConfig {
294                healthy_pass_runs: args.healthy_pass_runs,
295                drift_window: args.drift_window,
296                envelope_sigma: args.envelope_sigma,
297                boundary_fraction_of_rho: args.boundary_fraction_of_rho,
298                state_confirmation_steps: args.state_confirmation_steps,
299                persistent_state_steps: args.persistent_state_steps,
300                density_window: args.density_window,
301                ewma_alpha: args.ewma_alpha,
302                ewma_sigma_multiplier: args.ewma_sigma_multiplier,
303                cusum_kappa_sigma_multiplier: args.cusum_kappa_sigma_multiplier,
304                cusum_alarm_sigma_multiplier: args.cusum_alarm_sigma_multiplier,
305                run_energy_sigma_multiplier: args.run_energy_sigma_multiplier,
306                pca_variance_explained: args.pca_variance_explained,
307                pca_t2_sigma_multiplier: args.pca_t2_sigma_multiplier,
308                pca_spe_sigma_multiplier: args.pca_spe_sigma_multiplier,
309                drift_sigma_multiplier: args.drift_sigma_multiplier,
310                slew_sigma_multiplier: args.slew_sigma_multiplier,
311                grazing_window: args.grazing_window,
312                grazing_min_hits: args.grazing_min_hits,
313                pre_failure_lookback_runs: args.pre_failure_lookback_runs,
314                dsa: crate::precursor::DsaConfig {
315                    window: args.dsa_window,
316                    persistence_runs: args.dsa_persistence_runs,
317                    alert_tau: args.dsa_alert_tau,
318                    corroborating_feature_count_min: args.dsa_corroborating_feature_count_min,
319                },
320                ..PipelineConfig::default()
321            };
322            let artifacts = run_secom_benchmark(
323                &data_root,
324                Some(&output_root),
325                config,
326                args.fetch_if_missing,
327            )?;
328            println!("Run directory: {}", artifacts.run_dir.display());
329            println!("Metrics: {}", artifacts.metrics_path.display());
330            if let Some(pdf) = artifacts.report.pdf_path {
331                println!("PDF report: {}", pdf.display());
332            } else if let Some(error) = artifacts.report.pdf_error {
333                println!(
334                    "PDF report failed: {}",
335                    error.lines().next().unwrap_or("unknown error")
336                );
337            }
338            println!("ZIP bundle: {}", artifacts.zip_path.display());
339            Ok(())
340        }
341        Command::CalibrateSecom(args) => {
342            let data_root = args.data.data_root.unwrap_or_else(default_data_root);
343            let output_root = args.output_root.unwrap_or_else(default_output_root);
344            let grid = CalibrationGrid {
345                healthy_pass_runs: args.healthy_pass_runs_grid,
346                drift_window: args.drift_window_grid,
347                envelope_sigma: args.envelope_sigma_grid,
348                boundary_fraction_of_rho: args.boundary_fraction_of_rho_grid,
349                state_confirmation_steps: args.state_confirmation_steps_grid,
350                persistent_state_steps: args.persistent_state_steps_grid,
351                density_window: args.density_window_grid,
352                ewma_alpha: args.ewma_alpha_grid,
353                ewma_sigma_multiplier: args.ewma_sigma_multiplier_grid,
354                cusum_kappa_sigma_multiplier: args.cusum_kappa_sigma_multiplier_grid,
355                cusum_alarm_sigma_multiplier: args.cusum_alarm_sigma_multiplier_grid,
356                run_energy_sigma_multiplier: args.run_energy_sigma_multiplier_grid,
357                pca_variance_explained: args.pca_variance_explained_grid,
358                pca_t2_sigma_multiplier: args.pca_t2_sigma_multiplier_grid,
359                pca_spe_sigma_multiplier: args.pca_spe_sigma_multiplier_grid,
360                drift_sigma_multiplier: args.drift_sigma_multiplier_grid,
361                slew_sigma_multiplier: args.slew_sigma_multiplier_grid,
362                grazing_window: args.grazing_window_grid,
363                grazing_min_hits: args.grazing_min_hits_grid,
364                pre_failure_lookback_runs: args.pre_failure_lookback_runs_grid,
365            };
366            let artifacts =
367                run_secom_calibration(&data_root, Some(&output_root), grid, args.fetch_if_missing)?;
368            println!("Calibration run directory: {}", artifacts.run_dir.display());
369            println!(
370                "Calibration grid results: {}",
371                artifacts.grid_results_csv.display()
372            );
373            println!("Calibration summary: {}", artifacts.summary_json.display());
374            println!(
375                "Calibration report: {}",
376                artifacts.report_markdown.display()
377            );
378            Ok(())
379        }
380        Command::CalibrateSecomDsa(args) => {
381            let data_root = args.data.data_root.unwrap_or_else(default_data_root);
382            let output_root = args.output_root.unwrap_or_else(default_output_root);
383            let config = PipelineConfig {
384                healthy_pass_runs: args.healthy_pass_runs,
385                drift_window: args.drift_window,
386                envelope_sigma: args.envelope_sigma,
387                boundary_fraction_of_rho: args.boundary_fraction_of_rho,
388                state_confirmation_steps: args.state_confirmation_steps,
389                persistent_state_steps: args.persistent_state_steps,
390                density_window: args.density_window,
391                ewma_alpha: args.ewma_alpha,
392                ewma_sigma_multiplier: args.ewma_sigma_multiplier,
393                cusum_kappa_sigma_multiplier: args.cusum_kappa_sigma_multiplier,
394                cusum_alarm_sigma_multiplier: args.cusum_alarm_sigma_multiplier,
395                run_energy_sigma_multiplier: args.run_energy_sigma_multiplier,
396                pca_variance_explained: args.pca_variance_explained,
397                pca_t2_sigma_multiplier: args.pca_t2_sigma_multiplier,
398                pca_spe_sigma_multiplier: args.pca_spe_sigma_multiplier,
399                drift_sigma_multiplier: args.drift_sigma_multiplier,
400                slew_sigma_multiplier: args.slew_sigma_multiplier,
401                grazing_window: args.grazing_window,
402                grazing_min_hits: args.grazing_min_hits,
403                pre_failure_lookback_runs: args.pre_failure_lookback_runs,
404                ..PipelineConfig::default()
405            };
406            let artifacts = run_secom_dsa_calibration(
407                &data_root,
408                Some(&output_root),
409                config,
410                crate::precursor::DsaCalibrationGrid {
411                    window: args.dsa_window_grid,
412                    persistence_runs: args.dsa_persistence_runs_grid,
413                    alert_tau: args.dsa_alert_tau_grid,
414                    corroborating_feature_count_min: args.dsa_corroborating_feature_count_min_grid,
415                },
416                args.fetch_if_missing,
417            )?;
418            println!(
419                "DSA calibration run directory: {}",
420                artifacts.run_dir.display()
421            );
422            println!(
423                "DSA calibration grid: {}",
424                artifacts.grid_results_csv.display()
425            );
426            println!(
427                "DSA calibration summary: {}",
428                artifacts.summary_json.display()
429            );
430            println!(
431                "DSA calibration report: {}",
432                artifacts.report_markdown.display()
433            );
434            Ok(())
435        }
436        Command::ProbePhm2018(args) => {
437            let data_root = args.data_root.unwrap_or_else(default_data_root);
438            let status = phm2018::support_status(&data_root);
439            println!(
440                "PHM 2018 manual archive path: {}",
441                status.manual_placement_path.display()
442            );
443            println!("Official page: {}", status.official_page);
444            println!("Official link: {}", status.official_download_link);
445            println!(
446                "Archive summary support implemented: {}",
447                status.archive_summary_supported
448            );
449            println!("Implemented now: {}", status.fully_implemented);
450            println!("Blocker: {}", status.blocker);
451            let archive = args.archive.or_else(|| {
452                status
453                    .manual_placement_path
454                    .exists()
455                    .then_some(status.manual_placement_path.clone())
456            });
457            if let Some(archive) = archive {
458                println!("Inspecting archive: {}", archive.display());
459                let manifest = phm2018::inspect_archive(&archive)?;
460                println!("{}", serde_json::to_string_pretty(&manifest)?);
461            }
462            Ok(())
463        }
464        Command::RunPhm2018(args) => {
465            let data_root = args.data_root.unwrap_or_else(default_data_root);
466            let output_root = args.output_root.unwrap_or_else(default_output_root);
467            let artifacts =
468                run_phm2018_benchmark(&data_root, &output_root, args.secom_run_dir.as_deref())?;
469            println!("Run directory: {}", artifacts.run_dir.display());
470            println!(
471                "PHM lead-time metrics: {}",
472                artifacts.lead_time_metrics_path.display()
473            );
474            println!(
475                "PHM early-warning stats: {}",
476                artifacts.early_warning_stats_path.display()
477            );
478            println!(
479                "Claim alignment report: {}",
480                artifacts.claim_alignment_report_path.display()
481            );
482            println!(
483                "Engineering report (tex): {}",
484                artifacts.tex_report_path.display()
485            );
486            if let Some(pdf) = &artifacts.pdf_path {
487                println!("Engineering report (pdf): {}", pdf.display());
488            }
489            println!("ZIP bundle: {}", artifacts.zip_path.display());
490            Ok(())
491        }
492        Command::RenderNonIntrusiveArtifacts(args) => {
493            let artifacts = materialize_non_intrusive_artifacts(&args.run_dir)?;
494            println!(
495                "Non-intrusive interface spec: {}",
496                artifacts.interface_spec_path.display()
497            );
498            println!(
499                "Non-intrusive architecture PNG: {}",
500                artifacts.architecture_png_path.display()
501            );
502            println!(
503                "Non-intrusive architecture SVG: {}",
504                artifacts.architecture_svg_path.display()
505            );
506            Ok(())
507        }
508        Command::RenderUnifiedValueFigure(args) => {
509            let output_root = args.output_root.unwrap_or_else(default_output_root);
510            let secom_root_candidates = [
511                PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("output-dsfb-semiconductor"),
512                output_root.clone(),
513            ];
514            let secom_run_dir = match args.secom_run_dir {
515                Some(path) => path,
516                None => secom_root_candidates
517                    .iter()
518                    .find_map(|root| {
519                        resolve_latest_completed_run(
520                            root,
521                            "_secom",
522                            "dsa_operator_delta_targets.json",
523                        )
524                    })
525                    .ok_or_else(|| {
526                        crate::error::DsfbSemiconductorError::DatasetFormat(
527                            "could not resolve a completed SECOM run directory".into(),
528                        )
529                    })?,
530            };
531            let phm_run_dir = match args.phm_run_dir {
532                Some(path) => Some(path),
533                None => {
534                    let phm_root_candidates = [
535                        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("output-dsfb-semiconductor"),
536                        output_root.clone(),
537                    ];
538                    phm_root_candidates.iter().find_map(|root| {
539                        resolve_latest_completed_run(
540                            root,
541                            "_phm2018",
542                            "phm2018_early_warning_stats.json",
543                        )
544                    })
545                }
546            };
547            let paper_tex = args.paper_tex.or_else(|| {
548                Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("paper/semiconductor.tex"))
549            });
550            let artifacts = render_unified_value_figure(
551                &secom_run_dir,
552                phm_run_dir.as_deref(),
553                paper_tex.as_deref(),
554            )?;
555            println!("SECOM run: {}", artifacts.secom_run_dir.display());
556            if let Some(phm) = &artifacts.phm_run_dir {
557                println!("PHM run: {}", phm.display());
558            } else {
559                println!("PHM run: unavailable; Panel C rendered as placeholder");
560            }
561            println!("Unified figure: {}", artifacts.figure_path.display());
562            println!("Companion CSV: {}", artifacts.csv_path.display());
563            println!("PHM panel available: {}", artifacts.phm_panel_available);
564            println!("Paper updated: {}", artifacts.paper_updated);
565            Ok(())
566        }
567        Command::SbirDemo(args) => {
568            let data_root = args.data_root.unwrap_or_else(default_data_root);
569            let output_root = args.output_root.unwrap_or_else(default_output_root);
570
571            // 1. Fetch SECOM
572            println!("[sbir-demo] Fetching SECOM dataset...");
573            let secom_paths = secom::fetch_if_missing(&data_root)?;
574            println!("[sbir-demo] SECOM ready: {}", secom_paths.root.display());
575
576            // 2. Run SECOM benchmark
577            println!("[sbir-demo] Running SECOM benchmark...");
578            let secom_artifacts = run_secom_benchmark(
579                &data_root,
580                Some(&output_root),
581                PipelineConfig::default(),
582                args.fetch_if_missing,
583            )?;
584            println!(
585                "[sbir-demo] SECOM run dir:    {}",
586                secom_artifacts.run_dir.display()
587            );
588            if let Some(pdf) = &secom_artifacts.report.pdf_path {
589                println!("[sbir-demo] SECOM PDF:         {}", pdf.display());
590            }
591            println!(
592                "[sbir-demo] SECOM ZIP:         {}",
593                secom_artifacts.zip_path.display()
594            );
595
596            // 3. Calibrate SECOM (single-point default grid)
597            println!("[sbir-demo] Running SECOM calibration...");
598            let cal_grid = CalibrationGrid {
599                healthy_pass_runs: vec![100],
600                drift_window: vec![5],
601                envelope_sigma: vec![3.0],
602                boundary_fraction_of_rho: vec![0.5],
603                state_confirmation_steps: vec![2],
604                persistent_state_steps: vec![2],
605                density_window: vec![10],
606                ewma_alpha: vec![0.2],
607                ewma_sigma_multiplier: vec![3.0],
608                cusum_kappa_sigma_multiplier: vec![0.5],
609                cusum_alarm_sigma_multiplier: vec![5.0],
610                run_energy_sigma_multiplier: vec![3.0],
611                pca_variance_explained: vec![0.95],
612                pca_t2_sigma_multiplier: vec![3.0],
613                pca_spe_sigma_multiplier: vec![3.0],
614                drift_sigma_multiplier: vec![3.0],
615                slew_sigma_multiplier: vec![3.0],
616                grazing_window: vec![10],
617                grazing_min_hits: vec![3],
618                pre_failure_lookback_runs: vec![20],
619            };
620            let cal_artifacts = run_secom_calibration(
621                &data_root,
622                Some(&output_root),
623                cal_grid,
624                args.fetch_if_missing,
625            )?;
626            println!(
627                "[sbir-demo] Calibration dir:   {}",
628                cal_artifacts.run_dir.display()
629            );
630            if let Some(pdf) = &cal_artifacts.pdf_path {
631                println!("[sbir-demo] Calibration PDF:   {}", pdf.display());
632            }
633            println!(
634                "[sbir-demo] Calibration ZIP:   {}",
635                cal_artifacts.zip_path.display()
636            );
637
638            // 4. DSA calibration (minimal default grid)
639            println!("[sbir-demo] Running DSA calibration...");
640            let dsa_artifacts = run_secom_dsa_calibration(
641                &data_root,
642                Some(&output_root),
643                PipelineConfig::default(),
644                crate::precursor::DsaCalibrationGrid {
645                    window: vec![5],
646                    persistence_runs: vec![2],
647                    alert_tau: vec![2.0],
648                    corroborating_feature_count_min: vec![2],
649                },
650                args.fetch_if_missing,
651            )?;
652            println!(
653                "[sbir-demo] DSA cal dir:       {}",
654                dsa_artifacts.run_dir.display()
655            );
656            if let Some(pdf) = &dsa_artifacts.pdf_path {
657                println!("[sbir-demo] DSA cal PDF:       {}", pdf.display());
658            }
659            println!(
660                "[sbir-demo] DSA cal ZIP:       {}",
661                dsa_artifacts.zip_path.display()
662            );
663
664            // 5. PHM 2018 (skip with warning if neither archive nor extracted dataset found)
665            let phm_status = phm2018::support_status(&data_root);
666            if phm_status.fully_implemented || phm_status.manual_placement_path.exists() {
667                println!("[sbir-demo] Running PHM 2018 benchmark...");
668                match run_phm2018_benchmark(
669                    &data_root,
670                    &output_root,
671                    Some(&secom_artifacts.run_dir),
672                ) {
673                    Ok(phm_artifacts) => {
674                        println!(
675                            "[sbir-demo] PHM run dir:       {}",
676                            phm_artifacts.run_dir.display()
677                        );
678                        println!(
679                            "[sbir-demo] PHM tex report:    {}",
680                            phm_artifacts.tex_report_path.display()
681                        );
682                        if let Some(pdf) = &phm_artifacts.pdf_path {
683                            println!("[sbir-demo] PHM PDF:           {}", pdf.display());
684                        }
685                        println!(
686                            "[sbir-demo] PHM ZIP:           {}",
687                            phm_artifacts.zip_path.display()
688                        );
689                    }
690                    Err(e) => {
691                        eprintln!("[sbir-demo] PHM 2018 run failed (skipping): {e}");
692                    }
693                }
694            } else {
695                println!(
696                    "[sbir-demo] PHM 2018 dataset not found. Checked for:"
697                );
698                println!(
699                    "[sbir-demo]   extracted dir: {}",
700                    phm_status.extracted_dataset_path.display()
701                );
702                println!(
703                    "[sbir-demo]   archive:       {}",
704                    phm_status.manual_placement_path.display()
705                );
706                println!("[sbir-demo] Place either and re-run sbir-demo.");
707            }
708
709            println!("[sbir-demo] All artifacts generated.");
710            Ok(())
711        }
712        Command::PaperLock(args) => {
713            let data_root = args.data_root.unwrap_or_else(default_data_root);
714            let output_root = args.output_root.unwrap_or_else(default_output_root);
715
716            println!("[paper-lock] Running SECOM benchmark with fixed paper-lock config...");
717            let artifacts = run_secom_benchmark(
718                &data_root,
719                Some(&output_root),
720                PipelineConfig::default(),
721                args.fetch_if_missing,
722            )?;
723
724            let PaperLockMetrics {
725                episode_count,
726                precision,
727                detected_failures,
728                total_failures,
729            } = artifacts.paper_lock_metrics;
730
731            const EXPECTED_EPISODES: usize = 71;
732            const EXPECTED_MIN_PRECISION: f64 = 0.80;
733            const EXPECTED_RECALL: usize = 104;
734
735            let episode_ok  = episode_count == EXPECTED_EPISODES;
736            let precision_ok = precision >= EXPECTED_MIN_PRECISION;
737            let recall_ok   = detected_failures == EXPECTED_RECALL
738                           && total_failures     == EXPECTED_RECALL;
739
740            println!("[paper-lock] episode count : {episode_count:>4}  (expected {EXPECTED_EPISODES})  {}",
741                if episode_ok  { "OK" } else { "FAIL" });
742            println!("[paper-lock] precision     : {:>7.1}%  (expected >= {:.0}%)  {}",
743                precision * 100.0,
744                EXPECTED_MIN_PRECISION * 100.0,
745                if precision_ok { "OK" } else { "FAIL" });
746            println!("[paper-lock] recall        : {detected_failures}/{total_failures}  (expected {EXPECTED_RECALL}/{EXPECTED_RECALL})  {}",
747                if recall_ok   { "OK" } else { "FAIL" });
748            println!("[paper-lock] run dir       : {}", artifacts.run_dir.display());
749
750            if episode_ok && precision_ok && recall_ok {
751                println!("[paper-lock] PASS — headline numbers reproduced.");
752                Ok(())
753            } else {
754                eprintln!("[paper-lock] FAIL — one or more headline numbers did not match.");
755                std::process::exit(1);
756            }
757        }
758    }
759}