diff --git a/.codecov.yml b/.codecov.yml index 1abbe3cbd..264d2aab9 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -44,9 +44,10 @@ ignore: - "act/pipeline/fuzzing/trace_reader.py" - "act/front_end/vnnlib_loader/cli.py" - "act/pipeline/verification/llm_probe.py" - - "act/back_end/cli.py" - - "act/front_end/cli.py" - - "act/pipeline/cli.py" + - "act/config/backend_cli.py" + - "act/config/frontend_cli.py" + - "act/config/pipeline_cli.py" + - "act/config/check_parity.py" - "act/front_end/torchvision_loader/cli.py" - "act/back_end/serialization/test_serialization.py" - "vnncomp/" diff --git a/.coveragerc b/.coveragerc index 77f52e7bb..9afbf832d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -40,6 +40,7 @@ omit = */__main__.py act/pipeline/verification/llm_probe.py act/wrapper_exts/ext_runner.py + act/config/check_parity.py precision = 2 show_missing = True diff --git a/.github/workflows/act-bab.yml b/.github/workflows/act-bab.yml index 07284b9d1..c3197e63c 100644 --- a/.github/workflows/act-bab.yml +++ b/.github/workflows/act-bab.yml @@ -57,7 +57,7 @@ jobs: uses: actions/cache@v4 with: path: act/back_end/examples/nets - key: act-nets-${{ hashFiles('act/back_end/examples/config_gen_act_net.yaml', 'act/back_end/net_factory.py', 'act/back_end/config.yaml') }} + key: act-nets-${{ hashFiles('act/config/gen_act_net.yaml', 'act/back_end/net_factory.py', 'act/config/backend.yaml') }} restore-keys: | act-nets- diff --git a/.github/workflows/act.config.yml b/.github/workflows/act.config.yml new file mode 100644 index 000000000..d7ecf261d --- /dev/null +++ b/.github/workflows/act.config.yml @@ -0,0 +1,43 @@ +name: ACT Config Parity + +# Enforce CLI <-> YAML <-> dataclass consistency for the three config tiers, so +# an option added on one surface can never be silently missed by the others. +on: + push: + branches: [ main ] + paths: + - 'act/config/**' + - 'act/pipeline/fuzzing/actfuzzer.py' + - '.github/workflows/act.config.yml' + pull_request: + branches: [ main ] + paths: + - 'act/config/**' + - 'act/pipeline/fuzzing/actfuzzer.py' + - '.github/workflows/act.config.yml' + workflow_dispatch: + +env: + PYTHONPATH: ${{ github.workspace }} + +jobs: + config-parity: + name: CLI <-> YAML parity + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu + pip install -r modules/setup/main_requirements.txt + + - name: Check CLI <-> YAML <-> dataclass parity + run: python -m act.config.check_parity diff --git a/act/README.md b/act/README.md index 0c8f0e73a..65afc9e24 100644 --- a/act/README.md +++ b/act/README.md @@ -33,8 +33,17 @@ This directory contains the core verification framework for the Abstract Constra act/ ├── __init__.py # Package initialization │ +├── config/ # Centralized Configuration +│ ├── config.py # Global configuration dataclasses +│ ├── backend.yaml # Back-end specific scalar configuration +│ ├── gen_act_net.yaml # NetFactory architecture-sampling DSL +│ ├── pipeline.yaml # Pipeline specific configuration +│ ├── frontend.yaml # Front-end specific configuration +│ ├── backend_cli.py # Back-end CLI implementation +│ ├── pipeline_cli.py # Pipeline CLI implementation +│ └── frontend_cli.py # Front-end CLI implementation +│ ├── front_end/ # Front-End: User-facing data processing -│ ├── cli.py # Main front-end CLI with unified device/dtype args │ ├── torchvision_loader/ # TorchVision integration │ │ ├── cli.py # TorchVision-specific CLI │ │ ├── create_specs.py # TorchVisionSpecCreator for dataset-model pairs @@ -51,7 +60,6 @@ act/ │ └── README.md # Front-end documentation │ ├── back_end/ # Back-End: Core verification engine -│ ├── cli.py # Back-end CLI (generate, verify, info, test) │ ├── __main__.py # Entry point for python -m act.back_end │ ├── core.py # Net, Layer, Bounds, Con, ConSet data structures │ ├── verifier.py # Spec-free verification: verify_once(), verify_lp_batched() @@ -90,13 +98,11 @@ act/ │ │ ├── serialization.py # NetSerializer with tensor encoding │ │ └── test_serialization.py # Serialization correctness tests │ ├── examples/ # Example networks and configurations -│ │ ├── config_gen_act_net.yaml # YAML network definitions │ │ ├── nets/ # Generated ACT Net JSON files │ │ └── README.md # Examples documentation │ └── README.md # Back-end documentation │ ├── pipeline/ # Pipeline: Testing framework and integration -│ ├── cli.py # Pipeline CLI with unified device/dtype args │ ├── verification/ # Verification utilities submodule │ │ ├── __init__.py # Verification module initialization │ │ ├── torch2act.py # Automatic PyTorch→ACT Net conversion @@ -124,7 +130,7 @@ act/ All ACT modules now have unified CLI architecture with consistent device/dtype handling: #### **Front-End CLIs** -- **`front_end/cli.py`**: Main front-end CLI +- **`act/config/frontend_cli.py`**: Main front-end CLI - Commands: `--list`, `--synthesis`, `--list-creators` - Unified device/dtype arguments via `cli_utils` - Usage: `python -m act.front_end [options]` @@ -138,15 +144,15 @@ All ACT modules now have unified CLI architecture with consistent device/dtype h - Usage: `python -m act.front_end.vnnlib_loader [options]` #### **Pipeline CLI** -- **`pipeline/cli.py`**: Pipeline testing and integration CLI +- **`act/config/pipeline_cli.py`**: Pipeline testing and integration CLI - Commands: Testing, validation, regression, reporting - Unified device/dtype arguments via `cli_utils` - Usage: `python -m act.pipeline [options]` #### **Back-End CLI** -- **`back_end/cli.py`**: Comprehensive back-end verification CLI +- **`act/config/backend_cli.py`**: Comprehensive back-end verification CLI - Commands: - - `--generate`: Generate example networks from YAML config + - `--generate`: Generate example networks from YAML config (default: `backend.yaml`) - `--list-examples`: List all available example networks - `--info`: Display network structure and details - `--verify`: Run verification (single-shot or branch-and-bound) @@ -241,8 +247,8 @@ All ACT modules now have unified CLI architecture with consistent device/dtype h - **`test_serialization.py`**: Serialization correctness validation - **`examples/`**: Example networks and test cases - - **`config_gen_act_net.yaml`**: YAML definitions for example networks - - **`nets/`**: Generated ACT Net JSON files (MNIST, CIFAR, control, reachability) +- **`../config/gen_act_net.yaml`**: network-generation DSL for example networks +- **`nets/`**: Generated ACT Net JSON files (MNIST, CIFAR, control, reachability) - Networks include embedded INPUT_SPEC and ASSERT layers for spec-free verification ### **`pipeline/` - Testing Framework and Integration** @@ -263,7 +269,7 @@ All ACT modules now have unified CLI architecture with consistent device/dtype h - PyTorch model generation from ACT Nets - Integration with VerifiableModel wrapper layers -- **`cli.py`**: Main pipeline CLI (`python -m act.pipeline`) +- **`act/config/pipeline_cli.py`**: Main pipeline CLI (`python -m act.pipeline`) - **`verification/`**: Conversion + validation utilities — `torch2act.py`, `act2torch.py`, `validate_verifier.py`, `model_factory.py`, `per_neuron_bounds.py`, `utils.py` (performance profiling), `llm_probe.py` - **`fuzzing/`**: Whitebox fuzzing framework — `actfuzzer.py`, `tracer.py`, `trace_storage.py`, `trace_reader.py`, `coverage.py`, `mutations.py`, `checker.py`, `corpus.py` - **`log/`**: Centralized execution logs (`act_debug_tf.log`, validation/test output) @@ -283,6 +289,13 @@ All ACT modules now have unified CLI architecture with consistent device/dtype h - `initialize_device(device_str, dtype_str)`: Main initialization function - **`path_config.py`**: Project path configuration and management +- **`config/`**: Centralized configuration and CLIs + - **`config.py`**: PerformanceOptions, BackendConfig, TorchLPConfig, etc. + - **`backend.yaml`**: Back-end runtime and generation scalar defaults + - **`gen_act_net.yaml`**: NetFactory architecture-sampling DSL + - **`pipeline.yaml`**: Pipeline testing and fuzzing defaults + - **`frontend.yaml`**: Loader and spec creation defaults + - **`backend_cli.py`**, **`pipeline_cli.py`**, **`frontend_cli.py`**: Implementation of tier CLIs - `get_project_root()`, `get_data_root()`, `get_config_root()` - `get_pipeline_log_dir()`: Returns absolute path to `act/pipeline/log/` - `ensure_gurobi_license()`: Automatic Gurobi license detection @@ -426,15 +439,16 @@ python -m act.back_end --test-serialization --device cpu --dtype float64 ## Examples and Network Generation Example ACT networks are stored as JSON under `act/back_end/examples/nets/`. -These files are generated from the YAML configuration `act/back_end/examples/config_gen_act_net.yaml` -using the YAML-driven network factory. The test suite and serializer load networks -from the `examples/nets` directory. When authoring new example networks prefer the +These files are generated from the architecture-sampling DSL in `act/config/gen_act_net.yaml` +using the YAML-driven network factory; scalar generation knobs remain in +`act/config/backend.yaml`. The test suite and serializer load networks from the +`examples/nets` directory. When authoring new example networks prefer the YAML configuration and the factory rather than hand-editing the JSON files. ### Using the Back-End CLI for Network Generation The back-end CLI provides comprehensive tools for working with ACT networks: -1. **Generate Networks**: `--generate` creates all networks defined in `config_gen_act_net.yaml` +1. **Generate Networks**: `--generate` creates all networks defined in `gen_act_net.yaml` 2. **List Networks**: `--list-examples` shows all available networks organized by category 3. **Inspect Networks**: `--info` displays structure, use `--verbose` for detailed layer information 4. **Verify Networks**: `--verify` runs verification with optional `--bab` for branch-and-bound diff --git a/act/back_end/README.md b/act/back_end/README.md index 25bce84bd..219b69da7 100644 --- a/act/back_end/README.md +++ b/act/back_end/README.md @@ -286,7 +286,7 @@ Config (`BaBConfig`) / CLI flags: | `llm_probe_model` | `--bab-llm-probe-model` | `""` | | `llm_probe_base_url` | `--bab-llm-probe-base-url` | `""` | | `llm_probe_cadence` | `--bab-llm-probe-cadence` | `1` | -| `llm_probe_decisions` (e.g. `split,frontier,refine,neuron`) / `llm_probe_max_candidates_total` / `llm_probe_api_key_env` / `_temperature` / `_max_candidates` / `_history` / `_max_failures` / `_log` | (config/YAML only) | see `config.py` | +| `llm_probe_decisions` (e.g. `split,frontier,refine,neuron`) / `llm_probe_max_candidates_total` / `llm_probe_api_key_env` / `_temperature` / `_max_candidates` / `_history` / `_max_failures` / `_log` | (config/YAML only) | see `act/config/config.py` | Implementation lives in `act/pipeline/verification/llm_probe.py`. That file has two independent sections: the legacy TinyLlama input/output probe (Section A) and this BaB controller (Section B); diff --git a/act/back_end/__main__.py b/act/back_end/__main__.py index 5a71bcff0..83264f105 100644 --- a/act/back_end/__main__.py +++ b/act/back_end/__main__.py @@ -10,7 +10,7 @@ import sys -from act.back_end.cli import main +from act.config.backend_cli import main if __name__ == "__main__": sys.exit(main()) diff --git a/act/back_end/analyze.py b/act/back_end/analyze.py index 719e4d253..4b8dac46f 100644 --- a/act/back_end/analyze.py +++ b/act/back_end/analyze.py @@ -26,9 +26,9 @@ ) # Initialize default transfer function mode -def initialize_tf_mode(mode: str = "interval"): +def initialize_tf_mode(mode: str = "interval", tf_config=None): """Initialize transfer function mode. Call this before using analyze().""" - set_transfer_function_mode(mode) + set_transfer_function_mode(mode, tf_config) @dataclass diff --git a/act/back_end/bab/__init__.py b/act/back_end/bab/__init__.py index 1298ba3d3..5ce77660e 100644 --- a/act/back_end/bab/__init__.py +++ b/act/back_end/bab/__init__.py @@ -6,7 +6,7 @@ # Distributed without any warranty; see . # ===---------------------------------------------------------------------====# -from act.back_end.config import BaBConfig +from act.config.config import BaBConfig from act.back_end.bab.bab import verify_bab, verify_bab_batched from act.back_end.bab.node import BabNode, SubproblemBatch, split_subproblems from act.back_end.bab.branching.branching import BranchingStrategy diff --git a/act/back_end/bab/bab.py b/act/back_end/bab/bab.py index b15485d2a..23315e811 100644 --- a/act/back_end/bab/bab.py +++ b/act/back_end/bab/bab.py @@ -26,7 +26,7 @@ import torch -from act.back_end.config import BaBConfig, VALID_SOLVER_TIERS +from act.config.config import BaBConfig, DualConfig, VALID_SOLVER_TIERS from act.back_end.bab.node import ( BabNode, SubproblemBatch, @@ -258,6 +258,7 @@ def _gain_tested_decision( net: Net, assert_layer: Layer, config: BaBConfig, + dual_config: DualConfig, keep_rows: Optional[torch.Tensor], root_bounds_dict: Optional[Dict[int, Bounds]], bounds_dict: Optional[Dict[int, Bounds]], @@ -335,6 +336,7 @@ def _rep_state(state): k_actual=n_probe, batch=probe, config=config, + dual_config=dual_config, optimize=False, keep_rows=keep_rows, root_bounds_dict=root_bounds_dict, @@ -799,8 +801,14 @@ def _check_input_specs_batched(x_batch: torch.Tensor, spec_layers: List[Layer]) else: result &= torch.zeros_like(result) continue + positions_raw = layer.params.get("perturbed_positions") + positions = ( + positions_raw + if positions_raw is None or isinstance(positions_raw, (torch.Tensor, list, tuple)) + else None + ) mask = normalize_position_mask( - layer.params.get("perturbed_positions"), + positions, int(center_t.shape[-2]), batch_shape=tuple(center_t.shape[:-2]), device=x_batch.device, @@ -892,6 +900,7 @@ def _dispatch_dual_solve( k_actual: int, batch: SubproblemBatch, config: BaBConfig, + dual_config: DualConfig, optimize: bool, keep_rows: Optional[torch.Tensor] = None, root_bounds_dict: Optional[Dict[int, Bounds]] = None, @@ -1014,22 +1023,22 @@ def _dispatch_dual_solve( getattr(config, "eta_only_children", False) and is_child_batch ), refresh_forward=root_bounds_dict is None, - n_iters=config.dual_n_iters, - lr_alpha=config.lr_alpha, - lr_beta=config.lr_beta, - lr_decay=config.lr_decay, + n_iters=dual_config.n_iters, + lr_alpha=dual_config.lr_alpha, + lr_beta=dual_config.lr_beta, + lr_decay=dual_config.lr_decay, eta=batch.incremental_eta if solver_tier == "dual_alpha_eta" else None, - incremental_alphas=batch.incremental_alpha if getattr(config, "incremental_start_enabled", True) else None, + incremental_alphas=batch.incremental_alpha if dual_config.incremental_start_enabled else None, incremental_etas=( batch.incremental_eta if solver_tier == "dual_alpha_eta" - and getattr(config, "incremental_start_enabled", True) + and dual_config.incremental_start_enabled else None ), split_signs=batch.split_signs if solver_tier == "dual_alpha_eta" else None, return_optimized=True, return_sce=True, - per_class_alpha=config.per_class_alpha, + per_class_alpha=dual_config.per_class_alpha, **({"return_nu_per_layer": True} if return_nu and supports_return_nu else {}), ) margins_flat = dual_result.margins @@ -1128,7 +1137,7 @@ def _dispatch_dual_solve( split_signs=( batch.split_signs if solver_tier == "dual_alpha_eta" else None ), - per_class_alpha=config.per_class_alpha, + per_class_alpha=dual_config.per_class_alpha, ) try: return DualSolveResult( @@ -1250,6 +1259,7 @@ def verify_bab_batched( *, max_batch_size: Optional[Union[int, str]] = None, time_budget_s: Optional[float] = None, + dual_config: Optional[DualConfig] = None, verbose: bool = False, _k_log: Optional[List[int]] = None, ) -> VerifyResult: @@ -1287,6 +1297,8 @@ def verify_bab_batched( """ if config is None: config = BaBConfig() + if dual_config is None: + dual_config = DualConfig() auto_batch = isinstance(max_batch_size, str) and max_batch_size == "auto" if auto_batch: effective_batch = ( @@ -1407,6 +1419,7 @@ def verify_bab_batched( k_actual=root_batch.batch_size, batch=root_batch, config=config, + dual_config=dual_config, optimize=presolve_tier in ("dual_alpha", "dual_alpha_eta"), root_bounds_dict=root_fwd, ) @@ -1518,6 +1531,7 @@ def verify_bab_batched( k_actual=k_actual, batch=batch, config=config, + dual_config=dual_config, optimize=False, keep_rows=spec_keep_rows, root_bounds_dict=node_root_fwd, @@ -1532,6 +1546,7 @@ def verify_bab_batched( k_actual=k_actual, batch=batch, config=config, + dual_config=dual_config, optimize=True, keep_rows=spec_keep_rows, root_bounds_dict=node_root_fwd, @@ -1738,6 +1753,7 @@ def _select_incremental_state( net, assert_layer, config, + dual_config, spec_keep_rows, node_root_fwd, bd_branch, @@ -1890,6 +1906,7 @@ def verify_bab( time_budget_s: Optional[float] = None, timelimit: Optional[float] = None, verbose: bool = False, + dual_config: Optional[DualConfig] = None, ) -> VerifyResult: """Single-solver Branch-and-Bound entry: one subproblem per iteration. @@ -1920,6 +1937,7 @@ def verify_bab( max_batch_size=1, time_budget_s=budget, verbose=verbose, + dual_config=dual_config, ) diff --git a/act/back_end/config.yaml b/act/back_end/config.yaml deleted file mode 100644 index 341dcc11e..000000000 --- a/act/back_end/config.yaml +++ /dev/null @@ -1,162 +0,0 @@ -# ACT Back-End Configuration -# -# Canonical source for ALL back-end settings (runtime, verification, generation). -# CI (.github/workflows/act-bab.yml) relies on these defaults. -# -# Precedence: CLI flag > environment variable > this file > code default -# Env vars: ACT_SOLVER / ACT_DEVICE / ACT_DTYPE - -backend: - - # ── Verification Runtime (global) ──────────────────────────────────────────────────── - - # Solver backend for LP/MILP verification. - # "auto" – try Gurobi first, fall back to TorchLP - # "gurobi" – exact MILP/LP via Gurobi (requires license) - # "torchlp" – PyTorch-tensor LP (Adam + penalty + box projection, - # GPU-capable, no license) - solver: "torchlp" - - # Compute device. - # "cpu" – run on CPU (CI default, always available) - # "cuda" – run on NVIDIA GPU (requires CUDA toolkit) - # "gpu" – alias for "cuda" - device: "cpu" - - # Floating-point precision for all tensors. - # "float64" – double precision (more accurate, slower) - # "float32" – single precision (faster, may cause numerical drift) - dtype: "float64" - - # Print extra diagnostic output across all commands. - verbose: false - - hybridz: - timeout: null - engine: "dense_hz_objbound" - - # ── BaBVerification ──────────────────────────────────────────────────────── - - # Wall-clock budget in seconds for both single execution and BaB verification. - timeout: 60.0 - - bab: - # Enable Branch-and-Bound refinement (true) or single execution (false). - enabled: true - - # Maximum depth of the BaB search tree. - max_depth: 3 - - # Maximum number of subproblems (nodes) to explore. - max_nodes: 10 - - # Maximum pending frontier leaves to retain; 0 disables eviction. - frontier_cap: 0 - - # Uniform fanout for input-domain splits; 2 preserves binary splitting. - input_split_fanout: 2 - - # Branching strategy. - # "random" – uniform random input-dimension split (baseline) - # "babsr" – ReLU neuron-split scored by dual ν + bound slope/intercept - # "fsb" – filtered smart branching (babsr top-k + bound re-evaluation) - # NOTE: "babsr"/"fsb" perform real neuron-split only on a dual tier - # (solver_tier "dual_alpha" or "dual_alpha_eta"); on "lp" they use the - # width-based baseline. - branching_method: "random" - - # How to select the next wave of subproblems when the pool exceeds the - # batch (tensor) size. - # "random" – uniform-random selection (baseline) - # "topk" – keep the top-k by (depth, lower-bound): deeper (more refined) - # and smaller lower-bound (closer to a counterexample) rank higher - bounding_method: "random" - - # TopKBounding order policy: depth_lb (default), greedy, or sa. - bounding_order: "depth_lb" - - # Simulated-annealing cooling rate for bounding_order = "sa". - sa_cooling_rate: 0.99 - - # TopKBounding score weights (only used when bounding_method = "topk"). - # Metrics are min-max normalised within the pool; defaults give depth and - # lower-bound 50% each. - bounding_depth_weight: 0.5 - bounding_bound_weight: 0.5 - - # ── Dual-tier knobs (α/η optimization for DualSolver BaB) ──────────── - # Solver tier for BaB bound computation: - # Source of truth: act.back_end.config.VALID_SOLVER_TIERS - # "lp" – LP/MILP backend - # "dual" – DualSolver (linear-relaxation dual, no iterative optimization) - # "dual_alpha" – DualSolver with Lagrange-relaxed lower-slope optimization (Adam on α ∈ [0,1]) - # "dual_alpha_eta" – DualSolver with joint α + η (split-constraint KKT multipliers) - solver_tier: "lp" - - # Number of Adam iterations for α/η optimization (only used in - # dual_alpha / dual_alpha_eta tiers). - dual_n_iters: 50 - - # Learning rate for α (ReLU lower-bound slope, clamped to [0, 1]). - lr_alpha: 0.1 - - # Learning rate for η (split-constraint KKT multipliers, clamped ≥ 0). - lr_beta: 0.1 - - # Adam ExponentialLR decay per iteration. 1.0 = no decay. - lr_decay: 0.98 - - # Inherit optimized (α, η) from parent BaB node into children (incremental-start). - incremental_start_enabled: true - - # Per-spec α (tighter bounds, M× memory) vs shared α (looser, 1× memory). - per_class_alpha: true - - # Track logical BaB node ids and parent ids in TopKBounding. - provenance_enabled: false - - # ── Network Generation (net_factory) ──────────────────────────────────── - # Controls how many networks to generate, where, and TF filtering. - # Architecture sampling DSL (family definitions, input/output specs) - # lives in gen_config_path — a separate file with its own schema. - - generation: - # Path to the architecture sampling config (families, specs, etc.). - gen_config_path: "act/back_end/examples/config_gen_act_net.yaml" - - # Directory where generated .json network files are written. - output_dir: "act/back_end/examples/nets" - - # Number of networks to generate per run. - num_instances: 15 - - # Random seed for reproducible generation. - base_seed: 42 - - # Prefix prepended to every generated filename. - name_prefix: "cfg_seed" - - # Transfer-function targets for layer filtering. - # null – no filtering, all layer types allowed - # ["interval"] – only layers supported by interval TF - # ["interval", "hybridz"] – layers supported by both TFs - tf_targets: null - - # How to combine layer sets when multiple tf_targets are given. - # "intersection" – keep only layers supported by ALL targets (stricter) - # "union" – keep layers supported by ANY target (more permissive) - registry_mode: "intersection" - - # When to stop generating. - # "basic" – generate exactly num_instances, then stop - # "full" – keep generating until every target layer type is covered - coverage_mode: "basic" - - # Maximum attempts in "full" coverage mode before giving up. - coverage_max_attempts: 1000 - - # Print a layer-coverage summary after generation finishes. - coverage_report: true - - # Write a manifest.json listing all generated network filenames. - write_manifest: true diff --git a/act/back_end/cons_exportor.py b/act/back_end/cons_exportor.py index f8b6e051c..23a9161a1 100644 --- a/act/back_end/cons_exportor.py +++ b/act/back_end/cons_exportor.py @@ -16,7 +16,7 @@ import numpy as np import torch -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, List, Optional, Tuple from act.back_end.core import Bounds, ConSet from act.back_end.solver.solver_base import BatchLPProblem from act.back_end.layer_util import validate_conset_ops @@ -43,6 +43,17 @@ _TANH_BAND_TOL = 1e-6 +def _as_td(x: Any, device: torch.device, dtype: torch.dtype) -> torch.Tensor: + # Exact extraction of the repeated coercion idiom: tensors move via .to + # (no-op when already matching), non-tensors lift via torch.as_tensor. + # Preserves device/dtype/contiguity semantics; adds no .clone(). + return ( + x.to(device=device, dtype=dtype) + if isinstance(x, torch.Tensor) + else torch.as_tensor(x, device=device, dtype=dtype) + ) + + def _coerce_b_tensor( val: Any, expected_b: int, @@ -298,16 +309,8 @@ def _emit_dense( """``y_i - sum_j W[i,j] * x_j = b[i]`` — one eq per output element.""" W = con.meta["W"] b = con.meta["b"] - W_t = ( - W.to(device=device, dtype=dtype) - if isinstance(W, torch.Tensor) - else torch.as_tensor(W, device=device, dtype=dtype) - ) - b_t = ( - b.to(device=device, dtype=dtype) - if isinstance(b, torch.Tensor) - else torch.as_tensor(b, device=device, dtype=dtype) - ) + W_t = _as_td(W, device, dtype) + b_t = _as_td(b, device, dtype) n_out, n_in = W_t.shape var_ids_all = list(con.var_ids) y = var_ids_all[:n_out] @@ -317,14 +320,18 @@ def _emit_dense( f"dense: var_ids length mismatch: {len(x)} input vars vs " f"W.shape[1]={n_in}" ) - for i in range(n_out): - row_vars = [y[i]] + x - # vals[n, 0] = 1.0; vals[n, 1:] = -W[i, :] - vals = torch.empty((N, 1 + n_in), device=device, dtype=dtype) - vals[:, 0] = 1.0 - vals[:, 1:] = -W_t[i].unsqueeze(0).expand(N, -1) - rhs = b_t[i].expand(N).contiguous() - eq.add(row_vars, vals, rhs) + m = n_out + col_block = torch.empty((m, 1 + n_in), device=device, dtype=torch.long) + col_block[:, 0] = torch.tensor(y, device=device, dtype=torch.long) + col_block[:, 1:] = torch.tensor( + x, device=device, dtype=torch.long + ).unsqueeze(0).expand(m, -1) + val_per_row = torch.empty((m, 1 + n_in), device=device, dtype=dtype) + val_per_row[:, 0] = 1.0 + val_per_row[:, 1:] = -W_t + val_block = val_per_row.unsqueeze(0).expand(N, m, 1 + n_in).contiguous() + rhs_block = b_t.reshape(m).unsqueeze(0).expand(N, m).contiguous() + eq.add_block(col_block, val_block, rhs_block) def _emit_bias( @@ -339,19 +346,20 @@ def _emit_bias( y = list(con.var_ids[:n]) x = list(con.var_ids[n:]) c_raw = con.meta["c"] - c_t = ( - c_raw.to(device=device, dtype=dtype) - if isinstance(c_raw, torch.Tensor) - else torch.as_tensor(c_raw, device=device, dtype=dtype) - ).reshape(-1) + c_t = _as_td(c_raw, device, dtype).reshape(-1) if c_t.numel() != n: raise ValueError(f"bias: c length {c_t.numel()} != n {n}") - for i in range(n): - vals = torch.tensor( - [[1.0, -1.0]], device=device, dtype=dtype - ).expand(N, -1).contiguous() - rhs = c_t[i].expand(N).contiguous() - eq.add([y[i], x[i]], vals, rhs) + m = n + col_block = torch.stack([ + torch.tensor(y, device=device, dtype=torch.long), + torch.tensor(x, device=device, dtype=torch.long), + ], dim=1) + val_per_row = torch.tensor( + [1.0, -1.0], device=device, dtype=dtype + ).unsqueeze(0).expand(m, -1) + val_block = val_per_row.unsqueeze(0).expand(N, m, 2).contiguous() + rhs_block = c_t.reshape(m).unsqueeze(0).expand(N, m).contiguous() + eq.add_block(col_block, val_block, rhs_block) def _emit_scale( @@ -366,19 +374,20 @@ def _emit_scale( y = list(con.var_ids[:n]) x = list(con.var_ids[n:]) a_raw = con.meta["a"] - a_t = ( - a_raw.to(device=device, dtype=dtype) - if isinstance(a_raw, torch.Tensor) - else torch.as_tensor(a_raw, device=device, dtype=dtype) - ).reshape(-1) + a_t = _as_td(a_raw, device, dtype).reshape(-1) if a_t.numel() != n: raise ValueError(f"scale: a length {a_t.numel()} != n {n}") - for i in range(n): - vals = torch.empty((N, 2), device=device, dtype=dtype) - vals[:, 0] = 1.0 - vals[:, 1] = -a_t[i] - rhs = torch.zeros((N,), device=device, dtype=dtype) - eq.add([y[i], x[i]], vals, rhs) + m = n + col_block = torch.stack([ + torch.tensor(y, device=device, dtype=torch.long), + torch.tensor(x, device=device, dtype=torch.long), + ], dim=1) + val_per_row = torch.empty((m, 2), device=device, dtype=dtype) + val_per_row[:, 0] = 1.0 + val_per_row[:, 1] = -a_t.reshape(m) + val_block = val_per_row.unsqueeze(0).expand(N, m, 2).contiguous() + rhs_block = torch.zeros((N, m), device=device, dtype=dtype) + eq.add_block(col_block, val_block, rhs_block) def _emit_bn( @@ -394,26 +403,23 @@ def _emit_bn( x = list(con.var_ids[n:]) A_raw = con.meta["A"] c_raw = con.meta["c"] - A_t = ( - A_raw.to(device=device, dtype=dtype) - if isinstance(A_raw, torch.Tensor) - else torch.as_tensor(A_raw, device=device, dtype=dtype) - ).reshape(-1) - c_t = ( - c_raw.to(device=device, dtype=dtype) - if isinstance(c_raw, torch.Tensor) - else torch.as_tensor(c_raw, device=device, dtype=dtype) - ).reshape(-1) + A_t = _as_td(A_raw, device, dtype).reshape(-1) + c_t = _as_td(c_raw, device, dtype).reshape(-1) if A_t.numel() != n or c_t.numel() != n: raise ValueError( f"bn: A.numel={A_t.numel()} c.numel={c_t.numel()} != n {n}" ) - for i in range(n): - vals = torch.empty((N, 2), device=device, dtype=dtype) - vals[:, 0] = 1.0 - vals[:, 1] = -A_t[i] - rhs = c_t[i].expand(N).contiguous() - eq.add([y[i], x[i]], vals, rhs) + m = n + col_block = torch.stack([ + torch.tensor(y, device=device, dtype=torch.long), + torch.tensor(x, device=device, dtype=torch.long), + ], dim=1) + val_per_row = torch.empty((m, 2), device=device, dtype=dtype) + val_per_row[:, 0] = 1.0 + val_per_row[:, 1] = -A_t.reshape(m) + val_block = val_per_row.unsqueeze(0).expand(N, m, 2).contiguous() + rhs_block = c_t.reshape(m).unsqueeze(0).expand(N, m).contiguous() + eq.add_block(col_block, val_block, rhs_block) def _emit_add_sub( @@ -429,12 +435,18 @@ def _emit_add_sub( z = list(con.var_ids[:n]) x = list(con.var_ids[n:2 * n]) y = list(con.var_ids[2 * n:]) - for i in range(n): - vals = torch.tensor( - [[1.0, -1.0, -sign_y]], device=device, dtype=dtype - ).expand(N, -1).contiguous() - rhs = torch.zeros((N,), device=device, dtype=dtype) - eq.add([z[i], x[i], y[i]], vals, rhs) + m = n + col_block = torch.stack([ + torch.tensor(z, device=device, dtype=torch.long), + torch.tensor(x, device=device, dtype=torch.long), + torch.tensor(y, device=device, dtype=torch.long), + ], dim=1) + val_per_row = torch.tensor( + [1.0, -1.0, -sign_y], device=device, dtype=dtype + ).unsqueeze(0).expand(m, -1) + val_block = val_per_row.unsqueeze(0).expand(N, m, 3).contiguous() + rhs_block = torch.zeros((N, m), device=device, dtype=dtype) + eq.add_block(col_block, val_block, rhs_block) def _emit_flatten( @@ -461,12 +473,17 @@ def _emit_flatten( raise ValueError(f"flatten: n_out {n_out} != n_in {n_in}") y = list(con.var_ids[:n_out]) x = list(con.var_ids[n_out:]) - vals = torch.tensor( - [[1.0, -1.0]], device=device, dtype=dtype - ).expand(N, -1).contiguous() - rhs = torch.zeros((N,), device=device, dtype=dtype) - for i in range(n_out): - eq.add([y[i], x[i]], vals, rhs) + m = n_out + col_block = torch.stack([ + torch.tensor(y, device=device, dtype=torch.long), + torch.tensor(x, device=device, dtype=torch.long), + ], dim=1) + val_per_row = torch.tensor( + [1.0, -1.0], device=device, dtype=dtype + ).unsqueeze(0).expand(m, -1) + val_block = val_per_row.unsqueeze(0).expand(N, m, 2).contiguous() + rhs_block = torch.zeros((N, m), device=device, dtype=dtype) + eq.add_block(col_block, val_block, rhs_block) def _emit_mask_add( @@ -541,11 +558,7 @@ def _emit_conv2d( _, C_in, H_in, W_in = (int(v) for v in input_shape) _, C_out, H_out, W_out = (int(v) for v in output_shape) - W_t = ( - weight_raw.to(device=device, dtype=dtype) - if isinstance(weight_raw, torch.Tensor) - else torch.as_tensor(weight_raw, device=device, dtype=dtype) - ) + W_t = _as_td(weight_raw, device, dtype) if W_t.dim() != 4: raise ValueError( f"conv2d: weight must be 4-D [C_out, C_in, K_h, K_w]; " @@ -569,11 +582,7 @@ def _emit_conv2d( y_ids = var_ids_all[:n_out_per_instance] x_ids = var_ids_all[n_out_per_instance:] - b_t = ( - bias_raw.to(device=device, dtype=dtype) - if isinstance(bias_raw, torch.Tensor) - else torch.as_tensor(bias_raw, device=device, dtype=dtype) - ).reshape(-1) + b_t = _as_td(bias_raw, device, dtype).reshape(-1) if b_t.numel() != n_out_per_instance: raise ValueError( f"conv2d: b length {b_t.numel()} != n_out_per_instance " @@ -1634,16 +1643,8 @@ def _emit_in_linpoly( """``in:linpoly``: ``A x <= b`` rows on the input variables.""" A_raw = con.meta["A"] b_raw = con.meta["b"] - A_t = ( - A_raw.to(device=device, dtype=dtype) - if isinstance(A_raw, torch.Tensor) - else torch.as_tensor(A_raw, device=device, dtype=dtype) - ) - b_t = ( - b_raw.to(device=device, dtype=dtype) - if isinstance(b_raw, torch.Tensor) - else torch.as_tensor(b_raw, device=device, dtype=dtype) - ) + A_t = _as_td(A_raw, device, dtype) + b_t = _as_td(b_raw, device, dtype) vids = list(con.var_ids) if A_t.dim() != 2 or A_t.shape[1] != len(vids): raise ValueError( @@ -1701,11 +1702,7 @@ def _emit_assert_canonical( "per row. Use solver_tier in {dual, dual_alpha, dual_alpha_eta}." ) c_b = _coerce_b_tensor(c_raw, N, n_out, device, dtype, "c") - d_b = ( - d_raw.to(device=device, dtype=dtype).reshape(-1) - if isinstance(d_raw, torch.Tensor) - else torch.as_tensor(d_raw, device=device, dtype=dtype).reshape(-1) - ) + d_b = _as_td(d_raw, device, dtype).reshape(-1) if d_b.numel() == 1: d_b = d_b.expand(N).contiguous() elif d_b.numel() != N: @@ -1725,11 +1722,7 @@ def _emit_assert_canonical( if C_raw is None: # Fall back to "c" (3-D or 2-D) C_raw = assert_layer.params["c"] - C_t = ( - C_raw.to(device=device, dtype=dtype) - if isinstance(C_raw, torch.Tensor) - else torch.as_tensor(C_raw, device=device, dtype=dtype) - ) + C_t = _as_td(C_raw, device, dtype) if C_t.dim() == 3: C_t = C_t.reshape(C_t.shape[0] * C_t.shape[1], C_t.shape[2]) if C_t.shape != (N * M, n_out): @@ -1744,11 +1737,7 @@ def _emit_assert_canonical( d_raw = assert_layer.params.get("thresholds") if d_raw is None: d_raw = assert_layer.params["d"] - d_t = ( - d_raw.to(device=device, dtype=dtype) - if isinstance(d_raw, torch.Tensor) - else torch.as_tensor(d_raw, device=device, dtype=dtype) - ) + d_t = _as_td(d_raw, device, dtype) if d_t.dim() == 1 and d_t.numel() == M: d_t = d_t.unsqueeze(0).expand(N, -1) elif d_t.shape == (1, M): diff --git a/act/back_end/core.py b/act/back_end/core.py index c8e3ce81d..7aa7f4b68 100644 --- a/act/back_end/core.py +++ b/act/back_end/core.py @@ -121,7 +121,10 @@ class Net: preds: Dict[int, List[int]] succs: Dict[int, List[int]] by_id: Dict[int, Layer] = field(init=False) - + _topo_cache: Dict[bool, List[int]] = field( + default_factory=dict, init=False, repr=False, compare=False + ) + def __post_init__(self): self.by_id = {L.id: L for L in self.layers} # Validate the graph structure @@ -199,6 +202,26 @@ def topological_sort(net: Net, reverse: bool = False) -> List[int]: return order +def get_topo_order(net: Net, reverse: bool = False) -> List[int]: + """Memoized ``topological_sort(net, reverse)`` keyed on ``reverse``. + + Caches the forward and reverse Kahn orders on the (mutable) ``Net`` in + ``net._topo_cache``; returns the identical list ``topological_sort`` would. + The cache is valid only while the layer set is immutable (as during BaB); a + cheap cache-hit guard asserts the current layer-id set still matches. + """ + cache = net._topo_cache + order = cache.get(reverse) + if order is None: + order = topological_sort(net, reverse) + cache[reverse] = order + return order + assert set(order) == {layer.id for layer in net.layers}, ( + "get_topo_order: net layer set changed since cache was populated" + ) + return order + + @dataclass(eq=True, frozen=True) class Bounds: lb: torch.Tensor diff --git a/act/back_end/dual_tf/tf_cnn.py b/act/back_end/dual_tf/tf_cnn.py index 059e9b5c6..97f1375e5 100644 --- a/act/back_end/dual_tf/tf_cnn.py +++ b/act/back_end/dual_tf/tf_cnn.py @@ -91,7 +91,7 @@ def backward_conv2d(L: Any, nu: torch.Tensor, bounds_dict: Dict[int, Bounds], def dual_conv2d_backward( nu: torch.Tensor, weight: torch.Tensor, bias: Optional[torch.Tensor] = None, stride: int = 1, padding: int = 0, - input_shape: Optional[tuple] = None, output_shape: Optional[tuple] = None, + input_shape: Optional[tuple[int, ...]] = None, output_shape: Optional[tuple[int, ...]] = None, channel_chunk_size: int = _CONV_CHANNEL_CHUNK_SIZE, ) -> Tuple[torch.Tensor, torch.Tensor]: """Conv2D backward via F.conv_transpose2d (naturally batched). diff --git a/act/back_end/dual_tf/tf_mlp.py b/act/back_end/dual_tf/tf_mlp.py index 72572a947..ae6ffd187 100644 --- a/act/back_end/dual_tf/tf_mlp.py +++ b/act/back_end/dual_tf/tf_mlp.py @@ -22,6 +22,7 @@ import torch from typing import Tuple, Optional, Dict, Any, List from act.back_end.core import Bounds +from act.back_end.utils import LRELU_ALPHA_DEFAULT from .tf_forward import ( LinearBound, Frame, _align, @@ -365,7 +366,7 @@ def forward_lrelu( parent_frame = parent_frames[0] x_L, x_U = parent_frame pre_lb, pre_ub = parent_box.lb, parent_box.ub - alpha = L.params.get("alpha", 0.01) + alpha = L.params.get("alpha", LRELU_ALPHA_DEFAULT) lin = _fwd_lrelu(parent_lin, pre_lb, pre_ub, alpha) int_lb, int_ub = _box_lrelu(pre_lb, pre_ub, alpha) if lin is None: diff --git a/act/back_end/examples/README.md b/act/back_end/examples/README.md index bdd8cb2f2..a09cbb31e 100644 --- a/act/back_end/examples/README.md +++ b/act/back_end/examples/README.md @@ -5,7 +5,7 @@ This directory contains example networks generated by the YAML-driven network fa ## Quick Usage ```bash -# Generate all networks from config_gen_act_net.yaml +# Generate all networks from act/config/gen_act_net.yaml python -m act.back_end --generate # This will create JSON network files in the nets/ subfolder @@ -14,14 +14,14 @@ python -m act.back_end --generate ## Files - `../net_factory.py` - Concise factory located in back_end folder -- `config_gen_act_net.yaml` - Complete network specifications with layer definitions +- `../../config/gen_act_net.yaml` - Complete network specifications with layer definitions - `nets/*.json` - Generated network files (saved in nets/ subfolder) ## Architecture The factory is extremely simple: -1. Reads complete network specifications from `config_gen_act_net.yaml` -2. Creates simplified network objects with proper graph structure +1. Reads complete network specifications from `act/config/gen_act_net.yaml` +2. Creates simplified network objects with proper graph structure 3. Saves networks as JSON files in the `nets/` subfolder -No hardcoded templates or complex logic - everything is driven by the YAML configuration. \ No newline at end of file +No hardcoded templates or complex logic - everything is driven by the YAML configuration. diff --git a/act/back_end/examples/config_gen_act_net.yaml b/act/back_end/examples/config_gen_act_net.yaml deleted file mode 100644 index db0e3fed7..000000000 --- a/act/back_end/examples/config_gen_act_net.yaml +++ /dev/null @@ -1,423 +0,0 @@ -# ============================================================================ -# ACT Network Generation Configuration -# ============================================================================ -# -# This file drives the automatic generation of neural networks for -# verification testing. Each generated network is a self-contained JSON -# file with embedded input constraints (INPUT_SPEC) and output properties -# (ASSERT), ready for spec-free verification: -# -# python -m act.back_end --generate # default 15 nets -# python -m act.back_end --generate --num 50 # custom count -# python -m act.back_end --generate --base-seed 42 # reproducible -# python -m act.back_end --generate --tf-targets interval hybridz -# -# Generated networks end up in act/back_end/examples/nets/*.json -# and can be loaded via ModelFactory or NetSerializer. -# -# --------------------------------------------------------------------------- -# HOW THE SAMPLING RULES WORK -# --------------------------------------------------------------------------- -# Every parameter below is either a plain value or a sampling rule. -# The six rule types are: -# -# {choice: [a, b, c]} Pick one uniformly at random -# {range: [lo, hi]} Random integer in [lo, hi] -# {weighted: {a: 0.7, b: 0.3}} Pick with given probability weights -# {repeat: {count: , Build a list: sample count first, -# value: }} then sample each element -# {probability: 0.3} True with 30% chance, False otherwise -# {const: v} Always returns v (explicit constant) -# -# You can nest rules freely. A bare scalar (e.g. 3) is shorthand for -# {const: 3}. -# -# --------------------------------------------------------------------------- -# HOW TF-AWARE FILTERING WORKS -# --------------------------------------------------------------------------- -# When you pass “--tf-targets interval hybridz” on the command line, the -# factory queries each Transfer Function's layer registry and keeps only -# layer types supported by ALL specified TFs (intersection mode, default). -# Families whose required layers are not in that set are excluded -# automatically. This guarantees every generated network can be analyzed -# by the chosen TFs without "unsupported layer" errors. -# -# ============================================================================ - - -# ============================================================================ -# 1. GENERATION CONTROL -# ============================================================================ - -common: - # How many networks to generate per run. - num_instances: 15 - - # Random seed for reproducible generation. - # seed=42 produces a network set with minimal Gurobi soundness issues (1 out of 30). - # Override with --base-seed on command line if needed. - base_seed: 42 - - # Prefix prepended to every generated filename. - name_prefix: cfg_seed - - # Where to write the generated .json files. - output_dir: act/back_end/examples/nets - - # Write a manifest.json listing all generated network names. - # Downstream tools (ModelFactory) use this for discovery. - write_manifest: true - manifest_path: act/back_end/examples/nets/_meta/manifest.json - - # Tensor data type used for all generated weights. - # "torch.float64" gives maximum precision; "torch.float32" is faster. - dtype: torch.float64 - - # Coverage mode controls when generation stops: - # "basic" - generate exactly num_instances networks, then stop. - # "full" - keep generating until every target layer type has appeared - # at least once (up to coverage_max_attempts). Uncovered - # layers get a minimal template network as fallback. - coverage_mode: basic - coverage_max_attempts: 1000 - - # Print a layer coverage summary after generation finishes. - coverage_report: true - - -# ============================================================================ -# 2. ARCHITECTURE FAMILY SELECTION -# ============================================================================ -# Each generated network belongs to one "family". The weights below control -# how often each family is chosen. (Values are relative, not percentages.) - -family_selection: - weighted: - mlp: 0.5 # fully-connected networks - cnn2d: 0.5 # 2-D convolutional networks - rnn: 0.0 # disabled by default curently, only interval, hybridz supports RNNs; bump to 1.0 for RNN self-tests - - -# ============================================================================ -# 3. FAMILY-SPECIFIC PARAMETERS -# ============================================================================ -# Each family has its own set of tunables. Parameters that belong to a -# specific variant (plain / block / residual / stage) are ignored when -# a different variant is sampled. - -families: - - # -------------------------------------------------------------------------- - # 3.1 MLP (fully-connected feedforward) - # -------------------------------------------------------------------------- - # - # Three structural variants ("Activ" = whichever activation is sampled below): - # plain - Dense -> Activ -> Dense -> Activ -> ... -> Dense(num_classes) - # block - projection Dense, then repeated [Dense->Activ->Dense->(Activ?)] - # residual - projection Dense, then repeated [Dense->Activ->Dense + skip->Activ] - # - # The final Dense(num_classes) classifier head is always appended. - - mlp: - - # --- Input --- - # Shape's leading dim is the batch size B. Sequential analysis is the - # B=1 case of the same batched code path. - # [1, 6] = batch of 1 6-D input; [4, 16] = batch of 4 16-D inputs; - # [1, 3, 8] = 3-channel 1-D signal (auto-flattened). - input_shape: - choice: [[1, 6], [1, 16], [1, 3, 8], [4, 6], [4, 16]] - - # Number of output classes (= width of the final Dense layer). - num_classes: - choice: [10] - - # --- Variant selection --- - variant: - weighted: - plain: 0.5 - block: 0.5 - residual: 0.0 - - # --- "plain" variant params --- - # Keep shallow (2-3 layers) to avoid bounds explosion in interval domain. - # Each DENSE+activation layer inflates bounds ~3×, so 3 layers ≈ 27× inflation. - hidden_sizes: # Width of each hidden layer - repeat: - count: { range: [2, 3] } # 2-3 hidden layers (was 2-4) - value: { choice: [32, 64] } # narrower (was 32-128) - - # --- "block" variant params --- - # Each block = 2 Dense layers. num_blocks=2 → 4 Dense + projection + head = 7 layers total. - num_blocks: # How many [Dense->Act->Dense] blocks - range: [1, 2] # was 1-3 - block_width: # All blocks share this width - choice: [32, 64] - post_block_activation: # Add activation after each block? - probability: 0.8 - - # --- "residual" variant params --- - num_residual_blocks: # How many skip-connection blocks - range: [1, 2] # was 1-3 - residual_width: # Width of all residual blocks - choice: [32, 64] # was 32-128 - - # --- Common layer settings --- - use_bias: - const: true # All Dense layers include bias - - # Rare extra layers (low probability) to exercise more operator types - # during coverage-mode testing: - # use_bias_layer/use_scale_layer disabled: standalone SCALE/BIAS are BN - # decomposition artifacts, not real layers. Removed to avoid act2torch issues. - # use_unsqueeze_squeeze disabled: cons_exportor doesn't support unsqueeze constraint tags - # use_unsqueeze_squeeze: - # probability: 0.04 - - # --- Activation function --- - # Only activations registered in ACT's layer_schema REGISTRY. - activation: - choice: [relu, tanh, sigmoid, lrelu, relu6, silu, abs] - - # LeakyReLU negative slope (used only when activation = lrelu). - lrelu_alpha: - choice: [0.01, 0.1, 0.2] - - # -------------------------------------------------------------------------- - # 3.2 CNN2D (2-D convolutional) - # -------------------------------------------------------------------------- - # - # Three structural variants ("Activ" = whichever activation is sampled below): - # plain - [Conv->Activ->(Pool?)] x N -> Flatten -> Dense -> Activ -> Dense - # residual - Conv->Activ, then [Conv->Activ->Conv + skip->Activ] x N -> pool-to-1x1 -> Dense - # stage - Conv->Activ, then S stages each with downsample + B conv blocks -> pool-to-1x1 -> Dense - # (stage resembles simplified ResNet) - - cnn2d: - - # --- Input --- - # [batch, channels, height, width]. Leading dim is the batch B. - # [1,1,8,8] = single-channel 8x8; [4,1,8,8] = batch of 4; - # [1,3,16,16] = RGB 16x16; [4,3,16,16] = batch of 4 RGB. - input_shape: - choice: [[1, 1, 8, 8], [1, 3, 16, 16], [4, 1, 8, 8], [4, 3, 16, 16]] - - num_classes: - choice: [10] - - # --- Variant selection --- - variant: - weighted: - plain: 0.5 - residual: 0.0 - stage: 0.5 - - # --- "plain" variant params --- - conv_channels: # Output channels per conv layer - repeat: - count: { range: [1, 3] } # 1-3 conv layers - value: { choice: [8, 16, 32] } # each 8, 16, or 32 channels - fc_hidden: # Hidden units in FC head - choice: [32, 64, 128] - - # --- "residual" variant params --- - num_residual_blocks: # Blocks with skip connections - range: [1, 2] # was 2-4 - residual_channels: # Channel width for all blocks - choice: [16, 32] # was 16-64 - - # --- "stage" variant params --- - stages: # Number of downsampling stages - range: [1, 2] # was 1-3 - blocks_per_stage: # Conv blocks per stage - range: [1, 2] - base_channels: # Initial channel count (doubles per stage) - choice: [8, 16] # was 8-32 - channel_mult: # Channel multiplier between stages - choice: [2] - double_conv_p: # Probability of double conv in a block - choice: [0.5, 0.7] - head_pool_to_1x1: # Pool spatial dims to 1x1 before FC - const: true - downsample: # How to downsample between stages - choice: [maxpool, avgpool, stride2_conv] - - # --- Extra layers --- - # use_batchnorm and use_scale_layer disabled: SCALE/BIAS are BN decomposition - # artifacts, not real network layers. Generating them requires _ScaleModule/ - # _BiasModule wrappers in act2torch and causes shape mismatch issues. - # use_transpose disabled: cons_exportor doesn't support transpose constraint tags - # use_transpose: - # probability: 0.04 - - # --- Convolution settings --- - kernel_sizes: { choice: [3] } # Kernel size for all conv layers - strides: { choice: [1] } # Stride for all conv layers - paddings: { choice: [1] } # Padding for all conv layers (same-padding with k=3, p=1) - use_bias: { const: true } - - # --- Pooling settings --- - # Always pool after each conv block to prevent bounds explosion. - # Without pooling, 3-layer CONV can inflate bounds to width 11000+. - use_pooling: - const: true - pool_kind: # Type of pooling layer (no 'none' — always pool) - choice: [maxpool, avgpool] - pool_kernel: { const: 2 } - pool_stride: { const: 2 } - - # --- Activation --- - # Restricted to kinds that have both a dual_tf forward handler AND - # a PyTorch builder in ActGraphModule (required by --verify netfactory - # --validate-soundness Level 2 bounds checks against concrete model output). - activation: - choice: [relu, tanh, sigmoid, lrelu] - - lrelu_alpha: - choice: [0.01, 0.1, 0.2] - - # -------------------------------------------------------------------------- - # 3.3 RNN (recurrent: RNN / LSTM / GRU + Flatten + Dense head) - # -------------------------------------------------------------------------- - # - # Single-layer recurrent cell with a flattened classifier head: - # INPUT (1, T, F) -> RNN/LSTM/GRU -> FLATTEN -> DENSE(num_classes) - # - # Multi-layer (num_layers > 1) and bidirectional are supported by the - # serialization / restoration paths, but the interval transfer function - # currently unrolls only layer 0 — so we keep num_layers = 1 here to avoid - # silently coarse bounds. The cell type (RNN / LSTM / GRU) is sampled - # per-instance. - # - # Sequence length and feature width are kept small to control bounds - # explosion: an LSTM cell unrolled T times applies sigmoid/tanh interval - # widening at every step. - - rnn: - - # --- Input --- - # [batch, seq_len, input_size]; batch is always 1 for symbolic verification. - input_shape: - choice: [[1, 4, 3], [1, 6, 4]] - - num_classes: - choice: [10] - - # --- Cell type --- - cell: - weighted: - LSTM: 0.4 - GRU: 0.3 - RNN: 0.3 - - # --- Cell hyper-parameters --- - hidden_size: - choice: [8, 16] - - num_layers: - const: 1 - - bidirectional: - # Disabled: interval_tf raises NotImplementedError on bidirectional RNN - # because the reverse-direction sweep over weight_*_l0_reverse is not - # implemented. Bump back above 0 once that lands. - probability: 0.0 - - batch_first: - const: true - - use_bias: - const: true - - # Vanilla-RNN nonlinearity (ignored for LSTM/GRU). - nonlinearity: - choice: [tanh, relu] - - -# ============================================================================ -# 4. INPUT SPECIFICATION (INPUT_SPEC layer) -# ============================================================================ -# Defines the input region that verification will analyze. -# Every generated network gets exactly one INPUT_SPEC. - -input_spec: - # Which constraint type to embed: - # BOX - axis-aligned box lb[i] <= x[i] <= ub[i] - # LINF_BALL - L-infinity ball ||x - center||_inf <= eps - kind: - weighted: - BOX: 0.5 - LINF_BALL: 0.5 - - # Base value range for the input domain. - # BOX bounds and LINF_BALL center are sampled within this range. - value_range: - choice: [[0.0, 1.0]] - - # BOX only: random shrinkage applied to each side of the box. - # Higher shrinkage = tighter box = less bounds inflation. - # [0.3, 0.45] means the box is shrunk to ~10-40% of full range per side. - box_shrink_range: [0.3, 0.45] - - # LINF_BALL only: perturbation radius. - # Smaller eps = tighter input bounds = less bounds inflation through deep networks. - # 0.1 caused bounds width ~19 at output of 4-layer MLP → degenerate LP. - eps: - choice: [0.01, 0.02, 0.03] - - -# ============================================================================ -# 5. OUTPUT SPECIFICATION (ASSERT layer) -# ============================================================================ -# Defines the verification property the network output must satisfy. -# Every generated network gets exactly one ASSERT. -# -# Property types: -# TOP1_ROBUST - The true class must have the highest logit. -# Params: y_true (class index) -# -# MARGIN_ROBUST - The true class must beat all others by >= margin. -# Params: y_true, margin -# -# LINEAR_LE - A linear inequality c^T y <= d must hold. -# Params: c (coefficient vector), d (threshold) -# -# RANGE - Every output must lie within element-wise bounds. -# Params: lb, ub (vectors same size as output) - -output_spec: - kind: - weighted: - TOP1_ROBUST: 0.7 # Most common: classification robustness (margin=0, easiest for interval domain) - MARGIN_ROBUST: 0.1 # Rare: non-zero margin is hard for interval domain on deep networks - LINEAR_LE: 0.1 # Linear inequality on outputs - RANGE: 0.1 # Element-wise output bounds - - # MARGIN_ROBUST: required separation between true class and runner-up. - # Keep values small — interval domain bounds explode through deep networks, - # making large margins unprovable (false CERTIFIED). - margin: - choice: [0.0, 0.01] - - # LINEAR_LE: coefficient vector c is sampled element-wise from this range, - # threshold d is sampled from linear_le_d_range. - linear_le_c_range: [-1.0, 1.0] - linear_le_d_range: [-1.0, 1.0] - - # RANGE: per-output lower and upper bounds are sampled within this range. - range_bounds: - choice: [[-1.0, 1.0]] - - -# ============================================================================ -# 6. VALIDATE-VERIFIER DEFAULTS -# ============================================================================ -# Defaults consumed by `python -m act.pipeline --verify netfactory --validate-soundness ...` -# when the corresponding CLI flag is NOT explicitly passed. Any CLI flag -# that IS passed overrides the value here. - -validate: - # Batch sizes to validate at. ``null`` = use each network's native B - # from its INPUT shape; positive int = batchify INPUT_SPEC/ASSERT to - # that B (sequential analysis is the B=1 case of the same path). - batch_sizes: [null, 1, 4] diff --git a/act/back_end/hybridz_tf/hybridz_tf.py b/act/back_end/hybridz_tf/hybridz_tf.py index 0353df60d..e332591de 100644 --- a/act/back_end/hybridz_tf/hybridz_tf.py +++ b/act/back_end/hybridz_tf/hybridz_tf.py @@ -20,8 +20,9 @@ import torch from typing import Dict, Optional +from act.config.config import HybridZConfig from act.back_end.core import Bounds, Fact, Layer, Net, ConSet -from act.back_end.transfer_functions import TransferFunction +from act.back_end.transfer_functions import RegistryTF from act.back_end.layer_schema import LayerKind from act.back_end.solver.solver_hz import ( HZono, @@ -39,8 +40,10 @@ import act.back_end.interval_tf.tf_cnn as interval_cnn -class HybridzTF(TransferFunction): - def __init__(self): +class HybridzTF(RegistryTF): + def __init__(self, config: Optional[HybridZConfig] = None): + super().__init__("HybridzTF") + cfg = config or HybridZConfig() self._hz_cache: Dict[int, HZono] = {} self._sparse_hz_cache: Dict[int, SparseHZono] = {} self._sparse_drop_reasons: Dict[int, str] = {} @@ -48,6 +51,7 @@ def __init__(self): self._tanh_K: int = 2 self._sigmoid_K: int = 2 self._var_id_stride: int = 1 + setattr(self, "_HZ_MAX_INPUT_DIM", cfg.max_input_dim) self._sparse_next_frame_id: int = 0 self._sparse_frame_widths: Dict[int, tuple[int, int]] = {} self._sparse_relu_slots: Dict[tuple[int, int, int], tuple[int, int, int]] = {} @@ -152,13 +156,6 @@ def _net_var_id_stride(net: Net) -> int: LayerKind.MASK_ADD.value: lambda L, b, tf: hz_transformer.tf_mask_add(L, b, tf), } - @property - def name(self) -> str: - return "HybridzTF" - - def supports_layer(self, layer_kind: str) -> bool: - return layer_kind.upper() in self._LAYER_REGISTRY - def get_hz(self, layer_id: int) -> Optional[HZono]: return self._hz_cache.get(int(layer_id)) @@ -381,9 +378,7 @@ def apply( before: Dict[int, Fact], after: Dict[int, Fact], ) -> Fact: - k = L.kind.upper() - if k not in self._LAYER_REGISTRY: - raise NotImplementedError(f"HybridzTF: Unsupported layer kind '{k}'") + k = self._check_supported(L.kind) net_id = id(net) if self._cache_net_id != net_id: @@ -396,9 +391,7 @@ def apply( self._var_id_stride = self._net_var_id_stride(net) self._sparse_next_frame_id = 0 - self._net = net - self._before = before - self._after = after + self._set_context(net, before, after) self._seed_sparse_cache(L, input_bounds) if k in ("INPUT", "INPUT_SPEC"): diff --git a/act/back_end/hybridz_tf/tf_mlp.py b/act/back_end/hybridz_tf/tf_mlp.py index 340605bc5..5514be520 100644 --- a/act/back_end/hybridz_tf/tf_mlp.py +++ b/act/back_end/hybridz_tf/tf_mlp.py @@ -21,6 +21,7 @@ np = None sp = None from act.back_end.core import Bounds, Fact +from act.back_end.utils import LRELU_ALPHA_DEFAULT from act.back_end.solver.solver_hz import ( HZono, SparseHZono, @@ -573,7 +574,7 @@ def tf_lrelu(L, bounds, tf): fact = interval.tf_lrelu(L, bounds) if hz_in is not None: hz_out = hz_apply_leaky_relu( - hz_in, float(L.params.get("negative_slope", 0.01)) + hz_in, float(L.params.get("negative_slope", LRELU_ALPHA_DEFAULT)) ) if _hz_exceeds_limit(tf, L, hz_out): tf._hz_cache.pop(L.id, None) diff --git a/act/back_end/interval_tf/interval_tf.py b/act/back_end/interval_tf/interval_tf.py index 6c60125fc..dbee7d95f 100644 --- a/act/back_end/interval_tf/interval_tf.py +++ b/act/back_end/interval_tf/interval_tf.py @@ -16,7 +16,7 @@ import torch from typing import Dict, List from act.back_end.core import Bounds, Fact, Layer, Net, ConSet -from act.back_end.transfer_functions import TransferFunction +from act.back_end.transfer_functions import RegistryTF from act.back_end.layer_schema import LayerKind from act.back_end.interval_tf.tf_mlp import * from act.back_end.interval_tf.tf_cnn import * @@ -24,7 +24,7 @@ from act.back_end.interval_tf.tf_transformer import * -class IntervalTF(TransferFunction): +class IntervalTF(RegistryTF): """Interval-based transfer functions for standard bounds propagation.""" # Layer kind to function mapping @@ -146,25 +146,14 @@ class IntervalTF(TransferFunction): LayerKind.MASK_ADD.value: lambda L, bounds, tf: tf_mask_add(L, bounds), } - @property - def name(self) -> str: - return "IntervalTF" - - def supports_layer(self, layer_kind: str) -> bool: - """Check if this transfer function supports the given layer kind.""" - return layer_kind.upper() in self._LAYER_REGISTRY + def __init__(self) -> None: + super().__init__("IntervalTF") def apply(self, L: Layer, input_bounds: Bounds, net: Net, before: Dict[int, Fact], after: Dict[int, Fact]) -> Fact: """Apply interval transfer function to layer L.""" - k = L.kind.upper() - if k not in self._LAYER_REGISTRY: - raise NotImplementedError(f"IntervalTF: Unsupported layer kind '{k}'") - - # Store context for lambdas - self._net = net - self._before = before - self._after = after + k = self._check_supported(L.kind) + self._set_context(net, before, after) transfer_fn = self._LAYER_REGISTRY[k] return transfer_fn(L, input_bounds, self) \ No newline at end of file diff --git a/act/back_end/interval_tf/tf_mlp.py b/act/back_end/interval_tf/tf_mlp.py index 41205149a..0962f6e5d 100644 --- a/act/back_end/interval_tf/tf_mlp.py +++ b/act/back_end/interval_tf/tf_mlp.py @@ -14,7 +14,6 @@ #===---------------------------------------------------------------------===# import torch -import itertools import math from typing import List from act.back_end.core import Bounds, Con, ConSet, Fact, Layer @@ -454,7 +453,7 @@ def tf_max(L: Layer, By_list: List[Bounds]) -> Fact: ub = torch.stack([b.ub for b in By_list], dim=0).amax(dim=0) B=Bounds(lb,ub) # Flatten list of y_vars_list efficiently - all_y = list(itertools.chain.from_iterable(L.params.get("y_vars_list", []))) + all_y = [v for sub in L.params.get("y_vars_list", []) for v in sub] C=ConSet(); C.replace(Con("INEQ", tuple(L.out_vars+all_y), {"tag":f"max:{L.id}","k":len(By_list),"mode":"convex"})) C.add_box(L.id,L.out_vars,B); return Fact(B,C) @@ -463,7 +462,7 @@ def tf_min(L: Layer, By_list: List[Bounds]) -> Fact: ub = torch.stack([b.ub for b in By_list], dim=0).amin(dim=0) B=Bounds(lb,ub) # Flatten list of y_vars_list efficiently - all_y = list(itertools.chain.from_iterable(L.params.get("y_vars_list", []))) + all_y = [v for sub in L.params.get("y_vars_list", []) for v in sub] C=ConSet(); C.replace(Con("INEQ", tuple(L.out_vars+all_y), {"tag":f"min:{L.id}","k":len(By_list),"mode":"convex"})) C.add_box(L.id,L.out_vars,B); return Fact(B,C) diff --git a/act/back_end/layer_schema.py b/act/back_end/layer_schema.py index 07437951e..980f950f9 100644 --- a/act/back_end/layer_schema.py +++ b/act/back_end/layer_schema.py @@ -42,7 +42,7 @@ "params_optional": [...], # All optional params (tensors + scalars) } If the layer has a PyTorch nn.Module equivalent, also add it to - _ACT_TO_TORCH in act2torch.py for ACT→PyTorch restoration. + ACT_TO_TORCH in layer_schema.py for ACT→PyTorch restoration. 3. Done. The validator will enforce that only those keys are used. Tensor params are auto-detected at runtime - no manual tracking needed. @@ -63,7 +63,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Dict, Any +from typing import Dict, Any, override import enum # Import Layer from core to avoid circular import issues @@ -72,12 +72,10 @@ if TYPE_CHECKING: from .core import Layer -try: - import torch +import torch +import torch.nn as nn - Tensor = torch.Tensor -except Exception: # typing only - Tensor = "torch.Tensor" # type: ignore +Tensor = torch.Tensor # ------------------------------- @@ -221,8 +219,8 @@ class LayerKind(str, enum.Enum): # Tensor params are auto-detected via isinstance(val, torch.Tensor). # Bias existence is determined by checking if "bias" is in params (no separate flag needed). # -# PyTorch restoration mapping (ACT LayerKind → torch.nn.Module) lives in -# act2torch.py (_ACT_TO_TORCH dict), not here. REGISTRY is validation-only. +# ACT_TO_TORCH below defines the PyTorch restoration mapping +# (ACT LayerKind → torch.nn.Module); REGISTRY remains the param-validation schema. REGISTRY: Dict[str, Dict[str, Any]] = { # ===================== @@ -930,6 +928,89 @@ class LayerKind(str, enum.Enum): }, } +# PyTorch restoration mapping (ACT LayerKind -> torch.nn.Module) +class _ErfModule(nn.Module): + @override + def forward(self, x: torch.Tensor) -> torch.Tensor: + return torch.erf(x) + + +class _SqrtModule(nn.Module): + @override + def forward(self, x: torch.Tensor) -> torch.Tensor: + return torch.sqrt(torch.clamp(x, min=0.0)) + + +class _QuantizeModule(nn.Module): + def __init__(self, scale: object = None, zero_point: object = None, qmin: int = 0, qmax: int = 255) -> None: + super().__init__() + self.register_buffer("scale", torch.as_tensor(1.0 if scale is None else scale)) + self.register_buffer("zero_point", torch.as_tensor(0 if zero_point is None else zero_point)) + self.qmin: float = float(qmin) + self.qmax: float = float(qmax) + + @override + def forward(self, x: torch.Tensor) -> torch.Tensor: + scale = self.get_buffer("scale").to(device=x.device, dtype=x.dtype) + zp = self.get_buffer("zero_point").to(device=x.device, dtype=x.dtype) + return scale * torch.clamp(torch.round(x / scale), min=self.qmin - zp, max=self.qmax - zp) + + +class _SinModule(nn.Module): + @override + def forward(self, x: torch.Tensor) -> torch.Tensor: + return torch.sin(x) + + +class _CosModule(nn.Module): + @override + def forward(self, x: torch.Tensor) -> torch.Tensor: + return torch.cos(x) + + +# ACT LayerKind -> PyTorch nn.Module path. +# Layers not listed are skipped during restoration (wrapper, graph ops, functional-only). +ACT_TO_TORCH: dict[str, type[nn.Module]] = { + LayerKind.DENSE.value: nn.Linear, + LayerKind.CONV1D.value: nn.Conv1d, + LayerKind.CONV2D.value: nn.Conv2d, + LayerKind.CONV3D.value: nn.Conv3d, + LayerKind.CONVTRANSPOSE2D.value: nn.ConvTranspose2d, + LayerKind.MAXPOOL1D.value: nn.MaxPool1d, + LayerKind.MAXPOOL2D.value: nn.MaxPool2d, + LayerKind.MAXPOOL3D.value: nn.MaxPool3d, + LayerKind.AVGPOOL1D.value: nn.AvgPool1d, + LayerKind.AVGPOOL2D.value: nn.AvgPool2d, + LayerKind.AVGPOOL3D.value: nn.AvgPool3d, + LayerKind.ADAPTIVEAVGPOOL2D.value: nn.AdaptiveAvgPool2d, + LayerKind.RELU.value: nn.ReLU, + LayerKind.LRELU.value: nn.LeakyReLU, + LayerKind.PRELU.value: nn.PReLU, + LayerKind.SIGMOID.value: nn.Sigmoid, + LayerKind.TANH.value: nn.Tanh, + LayerKind.ERF.value: _ErfModule, + LayerKind.SQRT.value: _SqrtModule, + LayerKind.SIN.value: _SinModule, + LayerKind.COS.value: _CosModule, + LayerKind.QUANTIZE.value: _QuantizeModule, + LayerKind.SOFTPLUS.value: nn.Softplus, + LayerKind.SILU.value: nn.SiLU, + LayerKind.GELU.value: nn.GELU, + LayerKind.RELU6.value: nn.ReLU6, + LayerKind.HARDTANH.value: nn.Hardtanh, + LayerKind.HARDSIGMOID.value: nn.Hardsigmoid, + LayerKind.HARDSWISH.value: nn.Hardswish, + LayerKind.MISH.value: nn.Mish, + LayerKind.SOFTSIGN.value: nn.Softsign, + LayerKind.FLATTEN.value: nn.Flatten, + LayerKind.EMBEDDING.value: nn.Embedding, + LayerKind.RNN.value: nn.RNN, + LayerKind.GRU.value: nn.GRU, + LayerKind.LSTM.value: nn.LSTM, + LayerKind.SOFTMAX.value: nn.Softmax, + LayerKind.MHA.value: nn.MultiheadAttention, +} + # Supported exporter op tags (base name before ":"). SUPPORTED_EXPORT_OPS = { "abs", diff --git a/act/back_end/net_factory.py b/act/back_end/net_factory.py index a384e8e7b..0baf366d1 100644 --- a/act/back_end/net_factory.py +++ b/act/back_end/net_factory.py @@ -6,7 +6,7 @@ # Distributed without any warranty; see . # ===---------------------------------------------------------------------===# # -# Generates ACT networks from a YAML config (config_gen_act_net.yaml). +# Generates ACT networks from the DSL in act/config/gen_act_net.yaml. # # Usage: # python -m act.back_end --generate # default 15 nets @@ -31,13 +31,13 @@ from typing import Any, Dict, FrozenSet, List, Optional, Tuple import torch # pyright: ignore[reportMissingImports] -import yaml from act.back_end.core import Layer, Net from act.back_end.layer_schema import LayerKind, REGISTRY from act.back_end.serialization.serialization import NetSerializer from act.front_end.specs import InKind, OutKind, OutputSpec from act.util.device_manager import get_default_device, get_default_dtype +from act.util.format_utils import rule logger = logging.getLogger(__name__) @@ -222,36 +222,41 @@ def append_pool_nd( # ============================================================================ +def _append_layer(layers, kind: str, *, params: Optional[Dict[str, Any]] = None, **extra) -> None: + # params key order is preserved verbatim (flows into Layer.params via + # dict(ls["params"]) in create_network) -> generated JSON stays byte-identical. + layer: Dict[str, Any] = {"kind": kind, "params": params if params is not None else {}} + layer.update(extra) + layers.append(layer) + + def append_dense( layers, *, in_features: int, out_features: int, use_bias: bool ) -> None: - layers.append( - { - "kind": LayerKind.DENSE.value, - "params": { - "in_features": int(in_features), - "out_features": int(out_features), - "use_bias": bool(use_bias), - }, - } + _append_layer( + layers, + LayerKind.DENSE.value, + params={ + "in_features": int(in_features), + "out_features": int(out_features), + "use_bias": bool(use_bias), + }, ) def append_bias(layers, **meta_kw) -> None: - layers.append( - { - "kind": LayerKind.BIAS.value, - "params": {k: v for k, v in meta_kw.items() if v is not None}, - } + _append_layer( + layers, + LayerKind.BIAS.value, + params={k: v for k, v in meta_kw.items() if v is not None}, ) def append_scale(layers, **meta_kw) -> None: - layers.append( - { - "kind": LayerKind.SCALE.value, - "params": {k: v for k, v in meta_kw.items() if v is not None}, - } + _append_layer( + layers, + LayerKind.SCALE.value, + params={k: v for k, v in meta_kw.items() if v is not None}, ) @@ -270,7 +275,7 @@ def append_act( params["negative_slope"] = float(act_params["lrelu_alpha"]) elif act_kind == LayerKind.POW.value and "power_exponent" in act_params: params["exponent"] = float(act_params["power_exponent"]) - layers.append({"kind": act_kind, "params": params}) + _append_layer(layers, act_kind, params=params) def append_add(layers, *, skip_idx: int, main_idx: int) -> None: @@ -280,28 +285,26 @@ def append_add(layers, *, skip_idx: int, main_idx: int) -> None: def append_binary_op(layers, *, op_kind: str, x_idx: int, y_idx: int) -> None: - layers.append( - { - "kind": op_kind, - "params": {}, - "inputs": {"x": x_idx, "y": y_idx}, - "preds": [x_idx, y_idx], - } + _append_layer( + layers, + op_kind, + params={}, + inputs={"x": x_idx, "y": y_idx}, + preds=[x_idx, y_idx], ) def append_concat(layers, *, input_indices: List[int], concat_dim: int = 0) -> None: - layers.append( - { - "kind": LayerKind.CONCAT.value, - "params": {"concat_dim": concat_dim}, - "preds": input_indices, - } + _append_layer( + layers, + LayerKind.CONCAT.value, + params={"concat_dim": concat_dim}, + preds=input_indices, ) def append_flatten(layers) -> None: - layers.append({"kind": LayerKind.FLATTEN.value, "params": {"start_dim": 1}}) + _append_layer(layers, LayerKind.FLATTEN.value, params={"start_dim": 1}) def append_rnn_family( @@ -417,6 +420,26 @@ def _inject_extra_ops( # ============================================================================ +def _build_residual_block( + layers: List[Dict[str, Any]], + *, + num_blocks: int, + main_op, + act_kind: str, + cfg: Dict[str, Any], +) -> None: + # main_op: zero-arg callable appending one main-path layer (Dense/Conv), + # may mutate enclosing H, W via closure. Append order matches the prior + # inline loops verbatim -> byte-identical generated JSON. + for _ in range(num_blocks): + skip_idx = len(layers) - 1 + main_op() + append_act(layers, act_kind, act_params=cfg) + main_op() + append_add(layers, skip_idx=skip_idx, main_idx=len(layers) - 1) + append_act(layers, act_kind, act_params=cfg) + + def build_mlp_layers(layers: List[Dict[str, Any]], *, cfg: Dict[str, Any]) -> None: shape = tuple(cfg["input_shape"]) in_feat = int(shape[1]) if len(shape) == 2 else math.prod(shape[1:]) @@ -460,18 +483,19 @@ def build_mlp_layers(layers: List[Dict[str, Any]], *, cfg: Dict[str, Any]) -> No ) append_act(layers, act_kind, act_params=cfg) in_feat = width - for _ in range(int(cfg["num_residual_blocks"])): - skip_idx = len(layers) - 1 - append_dense( - layers, in_features=in_feat, out_features=in_feat, use_bias=use_bias - ) - append_act(layers, act_kind, act_params=cfg) + + def _dense_main(): append_dense( layers, in_features=in_feat, out_features=in_feat, use_bias=use_bias ) - main_idx = len(layers) - 1 - append_add(layers, skip_idx=skip_idx, main_idx=main_idx) - append_act(layers, act_kind, act_params=cfg) + + _build_residual_block( + layers, + num_blocks=int(cfg["num_residual_blocks"]), + main_op=_dense_main, + act_kind=act_kind, + cfg=cfg, + ) else: raise ValueError(f"Unsupported MLP variant '{variant}'") @@ -659,13 +683,18 @@ def build_cnn_layers( ch = int(cfg["residual_channels"]) H, W = _conv2d(layers, in_ch=in_ch, out_ch=ch, H=H, W=W) append_act(layers, act_kind, act_params=cfg) - for _ in range(int(cfg["num_residual_blocks"])): - skip_idx = len(layers) - 1 - H, W = _conv2d(layers, in_ch=ch, out_ch=ch, H=H, W=W) - append_act(layers, act_kind, act_params=cfg) + + def _conv_main(): + nonlocal H, W H, W = _conv2d(layers, in_ch=ch, out_ch=ch, H=H, W=W) - append_add(layers, skip_idx=skip_idx, main_idx=len(layers) - 1) - append_act(layers, act_kind, act_params=cfg) + + _build_residual_block( + layers, + num_blocks=int(cfg["num_residual_blocks"]), + main_op=_conv_main, + act_kind=act_kind, + cfg=cfg, + ) while H > 1 or W > 1: H, W = _pool2d(layers, kind=LayerKind.AVGPOOL2D.value, in_ch=ch, H=H, W=W) if H <= 0 or W <= 0: @@ -1087,7 +1116,7 @@ def sample_output_spec( class NetFactory: def __init__( self, - gen_config_path: Optional[str] = None, + config: Dict[str, Any], *, output_dir: Optional[str] = None, base_seed: Optional[int] = None, @@ -1097,12 +1126,8 @@ def __init__( tf_targets: Optional[List[str]] = None, registry_mode: str = "intersection", ): - if gen_config_path is None: - gen_config_path = str( - Path(__file__).parent / "examples" / "config_gen_act_net.yaml" - ) - self.config_path = str(gen_config_path) - self.config = self._load_config(self.config_path) + self.config = config + self.config_path = "gen_act_net.yaml" common = self.config["common"] self.tf_targets = tf_targets @@ -1148,16 +1173,6 @@ def __init__( self._init_coverage() self.total_generated = 0 - @staticmethod - def _load_config(path: str) -> Dict[str, Any]: - p = Path(path) - if not p.exists(): - raise FileNotFoundError(f"Config not found: {p}") - data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - raise ValueError(f"Config must be a mapping: {p}") - return data - def _compute_allowed_layers(self, tf_targets, mode): try: return _get_allowed_layers(tf_targets, mode) @@ -1547,9 +1562,9 @@ def _print_coverage_report(self): covered = sum(1 for c in self.coverage_stats.values() if c > 0) total = len(self.coverage_stats) rate = self._coverage_rate() - print(f"\n{'=' * 60}") + print(f"\n{rule(60)}") print("Layer Coverage Report") - print(f"{'=' * 60}") + print(f"{rule(60)}") if self.tf_targets: print(f"TF Targets: {self.tf_targets} (mode: {self.registry_mode})") print(f"Allowed: {len(self.allowed_layers)} Trackable: {total}") @@ -1563,7 +1578,7 @@ def _print_coverage_report(self): print(f" - {l}") else: print("\nAll target layers covered!") - print(f"{'=' * 60}\n") + print(f"{rule(60)}\n") def _generate_one(self, idx: int, dtype: str, names: List[str]) -> None: inst = self._sample_instance(idx) diff --git a/act/back_end/serialization/test_serialization.py b/act/back_end/serialization/test_serialization.py index e411a2a9f..6ad16272b 100644 --- a/act/back_end/serialization/test_serialization.py +++ b/act/back_end/serialization/test_serialization.py @@ -25,6 +25,7 @@ torch = None from act.back_end.core import Layer, Net +from act.util.format_utils import rule from act.back_end.serialization import ( save_net_to_file, load_net_from_file, save_net_to_string, load_net_from_string, @@ -264,7 +265,7 @@ def test_schema_validation(): def run_all_tests(): """Run all serialization tests.""" print("🚀 Running ACT Serialization Test Suite") - print("=" * 50) + print(rule(50)) tests = [ test_basic_serialization, @@ -287,7 +288,7 @@ def run_all_tests(): failed += 1 print() - print("=" * 50) + print(rule(50)) print(f"Test Results: {passed} passed, {failed} failed") if failed == 0: diff --git a/act/back_end/solver/solver_dual.py b/act/back_end/solver/solver_dual.py index 5c57460b6..f61f154cf 100644 --- a/act/back_end/solver/solver_dual.py +++ b/act/back_end/solver/solver_dual.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast import torch -from act.back_end.core import Bounds, Layer, Net, topological_sort +from act.back_end.core import Bounds, Layer, Net, get_topo_order from act.back_end.layer_schema import LayerKind from act.back_end.dual_tf.tf_forward import _intersect_boxes from act.back_end.solver.solver_base import Solver, SolverCaps @@ -358,7 +358,7 @@ def compute_certified_bound( nu_snapshot: Dict[int, torch.Tensor] = {} obj = torch.zeros(B, dtype=c.dtype, device=c.device) - topo_order = topological_sort(net, reverse=True) + topo_order = get_topo_order(net, reverse=True) registry = self.tf._BACKWARD_REGISTRY for lid in topo_order: diff --git a/act/back_end/solver/solver_gurobi.py b/act/back_end/solver/solver_gurobi.py index 5d8a71860..93bf58f81 100644 --- a/act/back_end/solver/solver_gurobi.py +++ b/act/back_end/solver/solver_gurobi.py @@ -6,10 +6,12 @@ from act.back_end.solver.solver_base import ( Solver, SolverCaps, + SolveStatus, _empty_blockdiag, _problem, _make_problem_n1, ) +from act.config.config import GurobiConfig from act.util.path_config import get_project_root if TYPE_CHECKING: @@ -59,14 +61,15 @@ class GurobiSolver(Solver): def capabilities(self) -> SolverCaps: return SolverCaps(False) - def __init__(self): + def __init__(self, config: Optional[GurobiConfig] = None): + self._cfg: GurobiConfig = config or GurobiConfig() if not GUROBI_AVAILABLE: raise RuntimeError("gurobipy is not available in this environment.") - @staticmethod - def compute_bounds(hz) -> 'Bounds': + def compute_bounds(self, domain_obj) -> 'Bounds': from act.back_end.core import Bounds import torch + hz = domain_obj n = int(hz.c.shape[0]) p = int(hz.Gc.shape[1]) q = int(hz.Gb.shape[1]) @@ -81,7 +84,11 @@ def compute_bounds(hz) -> 'Bounds': UB = np.empty((n,), dtype=np.float64) for i in range(n): m = gp.Model(f"hz_dim_{i}") - m.Params.OutputFlag = 0 + m.Params.OutputFlag = self._cfg.output_flag + m.Params.MIPGap = self._cfg.mip_gap + m.Params.Threads = self._cfg.threads + if self._cfg.time_limit is not None: + m.Params.TimeLimit = float(self._cfg.time_limit) xi_c = m.addMVar(p, lb=-1.0, ub=1.0, name="xi_c") xi_b = m.addMVar(q, vtype=GRB.BINARY, name="xi_b") if q > 0 else None if nc > 0: @@ -137,9 +144,11 @@ def solve_batch( ub = problem.ub[0].cpu().numpy().astype(np.float64) env = gp.Env(empty=True) - env.setParam("OutputFlag", 0) + env.setParam("OutputFlag", self._cfg.output_flag) env.start() m = gp.Model("verify_batch_n1", env=env) + m.Params.MIPGap = self._cfg.mip_gap + m.Params.Threads = self._cfg.threads x = m.addMVar(nvars, lb=lb, ub=ub, name="x") # Decompose block-diagonal sparse: for N=1 the block is the full matrix. @@ -168,23 +177,24 @@ def solve_batch( sense = GRB.MINIMIZE if problem.sense == "min" else GRB.MAXIMIZE m.setObjective(obj_c @ x + obj_const, sense) - if timelimit is not None: - m.Params.TimeLimit = float(timelimit) + active_timelimit = self._cfg.time_limit if self._cfg.time_limit is not None else timelimit + if active_timelimit is not None: + m.Params.TimeLimit = float(active_timelimit) m.optimize() dtype = problem.lb.dtype device = problem.lb.device if m.Status in (GRB.OPTIMAL, GRB.SUBOPTIMAL): - status = "SAT" + status = SolveStatus.SAT x_val = torch.as_tensor(x.X, dtype=dtype, device=device).unsqueeze(0) max_viol = torch.zeros(1, dtype=dtype, device=device) elif m.Status in (GRB.INFEASIBLE, GRB.INF_OR_UNBD): - status = "UNSAT" + status = SolveStatus.UNSAT x_val = torch.zeros_like(problem.lb) max_viol = torch.full((1,), float("inf"), dtype=dtype, device=device) else: - status = "UNKNOWN" + status = SolveStatus.UNKNOWN x_val = torch.zeros_like(problem.lb) max_viol = torch.full((1,), float("nan"), dtype=dtype, device=device) diff --git a/act/back_end/solver/solver_torchlp.py b/act/back_end/solver/solver_torchlp.py index b7845911a..efd130114 100644 --- a/act/back_end/solver/solver_torchlp.py +++ b/act/back_end/solver/solver_torchlp.py @@ -3,6 +3,7 @@ from typing import Optional import torch +from act.config.config import TorchLPConfig from act.back_end.solver.solver_base import ( Solver, SolveStatus, @@ -19,25 +20,26 @@ class TorchLPSolver(Solver): - Supports GPU via device hint in begin(...). - LP-only: no integrality constraints (no binary vars, no SOS2). """ - def __init__(self): + def __init__(self, config: Optional[TorchLPConfig] = None): + cfg = config or TorchLPConfig() self._device = get_default_device() self._dtype = get_default_dtype() # parameters - self.rho_eq = 10.0 - self.rho_ineq = 10.0 - self.max_iter = 2000 # lighter default; see large-n overrides below - self.tol_feas = 1e-4 - self.lr = 1e-2 - self.beta1 = 0.9 - self.beta2 = 0.999 - self.weight_decay = 0.0 - self._large_n_threshold = 20000 - self._large_n_max_iter = 800 - self._large_n_tol = 1e-3 + self.rho_eq = cfg.rho_eq + self.rho_ineq = cfg.rho_ineq + self.max_iter = cfg.max_iter # lighter default; see large-n overrides below + self.tol_feas = cfg.tol_feas + self.lr = cfg.lr + self.beta1 = cfg.beta1 + self.beta2 = cfg.beta2 + self.weight_decay = cfg.weight_decay + self._large_n_threshold = cfg.large_n_threshold + self._large_n_max_iter = cfg.large_n_max_iter + self._large_n_tol = cfg.large_n_tol self._log_every = 200 - self._stagnation_patience = 300 - self._stagnation_tol = 1e-5 - self._feas_check_stride = 5 + self._stagnation_patience = cfg.stagnation_patience + self._stagnation_tol = cfg.stagnation_tol + self._feas_check_stride = cfg.feas_check_stride def capabilities(self) -> SolverCaps: return SolverCaps(supports_gpu=True) @@ -104,9 +106,7 @@ def solve_batch( ) best_max_viol = math.inf - stagnation_steps = 0 - cached_v_eq: Optional[torch.Tensor] = None - cached_v_le: Optional[torch.Tensor] = None + iters_since_improve = 0 for it in range(eff_max_iter): if t_end is not None and time.time() >= t_end: @@ -126,50 +126,49 @@ def solve_batch( Ax_eq = torch.sparse.mm(A_eq, x_flat).squeeze(1) v_eq_mat = Ax_eq.view(N, m_eq) - b_eq obj = obj + self.rho_eq * (v_eq_mat * v_eq_mat).sum() - cached_v_eq = v_eq_mat.detach() if has_le and A_le is not None: Ax_le = torch.sparse.mm(A_le, x_flat).squeeze(1) v_le_mat = Ax_le.view(N, m_le) - b_le obj = obj + self.rho_ineq * (torch.relu(v_le_mat) ** 2).sum() - cached_v_le = v_le_mat.detach() obj.backward() opt.step() with torch.no_grad(): x.data.clamp_(lb, ub) - with torch.no_grad(): - if it % self._feas_check_stride == 0: - x_flat_no = x.reshape(N * nvars).unsqueeze(1) - if has_eq and A_eq is not None: - Ax_eq_no = torch.sparse.mm(A_eq, x_flat_no).squeeze(1) - cached_v_eq = Ax_eq_no.view(N, m_eq) - b_eq - if has_le and A_le is not None: - Ax_le_no = torch.sparse.mm(A_le, x_flat_no).squeeze(1) - cached_v_le = Ax_le_no.view(N, m_le) - b_le + if it % self._feas_check_stride != 0: + continue + with torch.no_grad(): + x_flat_no = x.reshape(N * nvars).unsqueeze(1) max_viol_per_n = torch.zeros(N, device=device, dtype=dtype) - if has_eq and cached_v_eq is not None: + if has_eq and A_eq is not None: + v_eq_no = ( + torch.sparse.mm(A_eq, x_flat_no).squeeze(1).view(N, m_eq) + - b_eq + ) max_viol_per_n = torch.maximum( - max_viol_per_n, - cached_v_eq.abs().max(dim=1).values, + max_viol_per_n, v_eq_no.abs().max(dim=1).values + ) + if has_le and A_le is not None: + v_le_no = ( + torch.sparse.mm(A_le, x_flat_no).squeeze(1).view(N, m_le) + - b_le ) - if has_le and cached_v_le is not None: max_viol_per_n = torch.maximum( - max_viol_per_n, - torch.relu(cached_v_le).max(dim=1).values, + max_viol_per_n, torch.relu(v_le_no).max(dim=1).values ) global_max_viol = float(max_viol_per_n.max().item()) if global_max_viol < best_max_viol - self._stagnation_tol: best_max_viol = global_max_viol - stagnation_steps = 0 + iters_since_improve = 0 else: - stagnation_steps += 1 + iters_since_improve += self._feas_check_stride if ( global_max_viol <= eff_tol_feas - or stagnation_steps >= self._stagnation_patience + or iters_since_improve >= self._stagnation_patience ): break diff --git a/act/back_end/transfer_functions.py b/act/back_end/transfer_functions.py index f127c5b43..fb1294efb 100644 --- a/act/back_end/transfer_functions.py +++ b/act/back_end/transfer_functions.py @@ -26,6 +26,7 @@ from typing import Any, Dict, Optional from act.back_end.core import Bounds, Fact, Layer, Net from act.util.options import PerformanceOptions +from act.util.format_utils import rule class TransferFunction(ABC): @@ -74,8 +75,42 @@ def name(self) -> str: def side_state_signature(self, layer_id: int) -> Any: return None + +class RegistryTF(TransferFunction, ABC): + """Base for transfer functions dispatched through a layer registry.""" + + _LAYER_REGISTRY: Dict[str, Any] = {} + + def __init__(self, name: str) -> None: + self._name: str = name + + @property + def name(self) -> str: + """Implementation name for debugging and logging.""" + return self._name + + def supports_layer(self, layer_kind: str) -> bool: + """Check if this transfer function supports the given layer kind.""" + return layer_kind.upper() in self._LAYER_REGISTRY + + def _check_supported(self, layer_kind: str) -> str: + k = layer_kind.upper() + if k not in self._LAYER_REGISTRY: + raise NotImplementedError(f"{self.name}: Unsupported layer kind '{k}'") + return k + + def _set_context( + self, + net: Net, + before: Dict[int, Fact], + after: Dict[int, Fact], + ) -> None: + self._net: Net = net + self._before: Dict[int, Fact] = before + self._after: Dict[int, Fact] = after + # Global transfer function management -_current_tf: TransferFunction = None +_current_tf: Optional[TransferFunction] = None def set_transfer_function(tf_impl: TransferFunction) -> None: @@ -106,7 +141,7 @@ def ensure_active_tf(default_mode: str = "interval") -> TransferFunction: return get_transfer_function() -def set_transfer_function_mode(mode: str = "interval") -> None: +def set_transfer_function_mode(mode: str = "interval", tf_config: Any = None) -> None: """Set transfer function implementation by mode name. Args: @@ -120,7 +155,7 @@ def set_transfer_function_mode(mode: str = "interval") -> None: set_transfer_function(IntervalTF()) elif mode == "hybridz": from act.back_end.hybridz_tf import HybridzTF - set_transfer_function(HybridzTF()) + set_transfer_function(HybridzTF(config=tf_config)) else: raise ValueError( f"Unknown transfer function mode: {mode!r}. Use 'interval' or " @@ -165,9 +200,9 @@ def dispatch_tf(L: Layer, before: Dict[int, Fact], after: Dict[int, Fact], net: # Debug logging to file (GUARDED) if PerformanceOptions.debug_tf: with open(PerformanceOptions.debug_output_file, 'a') as f: - f.write(f"\n{'='*80}\n") + f.write(f"\n{rule()}\n") f.write(f"Layer {L.id} ({L.kind})\n") - f.write(f"{'='*80}\n") + f.write(f"{rule()}\n") # Input bounds info (single Bounds object) lb_min, lb_max = input_bounds.lb.min().item(), input_bounds.lb.max().item() @@ -188,10 +223,12 @@ def dispatch_tf(L: Layer, before: Dict[int, Fact], after: Dict[int, Fact], net: if L.kind == 'DENSE' and 'W' in L.params: W = L.params['W'] b = L.params['b'] - f.write(f"Parameters: W.shape={W.shape}, b.shape={b.shape}\n") + if isinstance(W, torch.Tensor) and isinstance(b, torch.Tensor): + f.write(f"Parameters: W.shape={W.shape}, b.shape={b.shape}\n") elif L.kind == 'CONV2D' and 'weight' in L.params: weight = L.params['weight'] - f.write(f"Parameters: weight.shape={weight.shape}\n") + if isinstance(weight, torch.Tensor): + f.write(f"Parameters: weight.shape={weight.shape}\n") # Constraint info cons = result.cons diff --git a/act/back_end/utils.py b/act/back_end/utils.py index ffc0a40c9..69411c45a 100644 --- a/act/back_end/utils.py +++ b/act/back_end/utils.py @@ -16,9 +16,13 @@ from typing import Dict, Any, Tuple, Optional from act.back_end.core import Bounds, ConSet from act.util.options import PerformanceOptions +from act.util.format_utils import rule EPS = 1e-12 +# Default LRELU/LeakyReLU negative slope when a layer omits negative_slope. +LRELU_ALPHA_DEFAULT = 0.01 + def split_weight(W): return W.clamp(min=0), W.clamp(max=0) @@ -118,7 +122,7 @@ def scale_interval(cx_lo, cx_hi, inv_lo, inv_hi): return four_corner_envelope(cx_lo, cx_hi, inv_lo, inv_hi) -def validate_constraints(globalC, after: Dict, net) -> bool: +def validate_constraints(globalC, after: Dict[int, Any], net) -> bool: """Validate constraint set for common errors (targeted validation). This function performs targeted validation by: @@ -191,9 +195,9 @@ def validate_constraints(globalC, after: Dict, net) -> bool: # Write to debug file (GUARDED - only if debug_tf is also enabled) if PerformanceOptions.debug_tf: with open(PerformanceOptions.debug_output_file, 'a') as f: - f.write(f"\n{'='*80}\n") + f.write(f"\n{rule()}\n") f.write(f"CONSTRAINT VALIDATION (Targeted)\n") - f.write(f"{'='*80}\n") + f.write(f"{rule()}\n") f.write(f"Total constraints: {len(globalC)}\n") f.write(f"Unique variables referenced: {len(var_ids_used)}\n") f.write(f"Variables with bounds found: {len(var_bounds)}\n") diff --git a/act/back_end/verifier.py b/act/back_end/verifier.py index 426a0b088..f43c7dd26 100644 --- a/act/back_end/verifier.py +++ b/act/back_end/verifier.py @@ -234,6 +234,20 @@ def seed_from_input_specs(spec_layers) -> Bounds: raise ValueError("No valid input specification found for seeding.") + +def _setup_verify_context(net): + spec_layers = gather_input_spec_layers(net) + seed_bounds = seed_from_input_specs(spec_layers) + if seed_bounds.lb.dim() < 2: + message = ( + f"_setup_verify_context: INPUT_SPEC seed must be batched [B, *input_shape], got dim={seed_bounds.lb.dim()} " + f"shape={tuple(seed_bounds.lb.shape)}." + ) + raise ValueError(message) + B = int(seed_bounds.lb.shape[0]) + return spec_layers, seed_bounds, B + + def add_all_input_specs(globalC: ConSet, input_ids: List[int], spec_layers) -> None: """ Add all INPUT_SPEC constraints to constraint set. @@ -354,14 +368,12 @@ def verify_lp_batched( """ import importlib - spec_layers = gather_input_spec_layers(net) - seed_bounds = seed_from_input_specs(spec_layers) - if seed_bounds.lb.dim() < 2 or seed_bounds.ub.dim() < 2: + _, seed_bounds, batch_size = _setup_verify_context(net) + if seed_bounds.ub.dim() < 2: raise ValueError( f"verify_lp_batched: seed bounds must be [B, *input_shape], " f"got lb={tuple(seed_bounds.lb.shape)} ub={tuple(seed_bounds.ub.shape)}" ) - batch_size = int(seed_bounds.lb.shape[0]) solver = solver_factory() solution = setup_and_solve_batch( net, @@ -466,6 +478,7 @@ def verify_once( *, model_fn: Optional[Callable[[torch.Tensor], torch.Tensor]] = None, timelimit: Optional[float] = None, + hybridz_tolerance: Optional[float] = None, ) -> List[VerifyResult]: ... @@ -477,6 +490,7 @@ def verify_once( model_fn: Optional[Callable[[torch.Tensor], torch.Tensor]] = None, collect_facts: Literal[False], timelimit: Optional[float] = None, + hybridz_tolerance: Optional[float] = None, ) -> List[VerifyResult]: ... @@ -488,6 +502,7 @@ def verify_once( model_fn: Optional[Callable[[torch.Tensor], torch.Tensor]] = None, collect_facts: Literal[True], timelimit: Optional[float] = None, + hybridz_tolerance: Optional[float] = None, ) -> Tuple[List[VerifyResult], Optional[Dict[int, Any]]]: ... @@ -499,6 +514,7 @@ def verify_once( model_fn: Optional[Callable[[torch.Tensor], torch.Tensor]] = None, collect_facts: bool = False, timelimit: Optional[float] = None, + hybridz_tolerance: Optional[float] = None, ) -> List[VerifyResult] | Tuple[List[VerifyResult], Optional[Dict[int, Any]]]: """Single-shot, pure-tensor batched verifier. @@ -530,6 +546,7 @@ def verify_once( interval/hybridz path, or dual pre-activation forward bounds for the dual path. timelimit: optional HybridZ verdict-solver wall-clock limit in seconds. + hybridz_tolerance: optional HybridZ MILP feasibility/spec tolerance. Returns: ``List[VerifyResult]`` of length ``B`` (one per input lane), or @@ -545,19 +562,9 @@ def verify_once( entry_id = find_entry_layer_id(net) input_ids = get_input_ids(net) output_ids = get_output_ids(net) - spec_layers = gather_input_spec_layers(net) + spec_layers, seed_bounds, B = _setup_verify_context(net) assert_layer = get_assert_layer(net) - seed_bounds = seed_from_input_specs(spec_layers) - if seed_bounds.lb.dim() < 2: - raise ValueError( - f"verify_once: INPUT_SPEC seed must be batched [B, *input_shape], " - f"got dim={seed_bounds.lb.dim()} shape={tuple(seed_bounds.lb.shape)}. " - f"Use VerifiableModel._merge_specs_to_batch (front-end) or manually " - f"expand INPUT_SPEC lb/ub to [B, ...] before calling verify_once." - ) - B = seed_bounds.lb.shape[0] - # Standalone solver modes own their verdict logic; the interval/LP path # below remains authoritative for non-standalone solver modes. from act.back_end.transfer_functions import ( @@ -656,7 +663,11 @@ def _unbatch(val: Any) -> Any: ): input_hz = candidate break - solver = HZSolver() + solver = HZSolver( + time_limit=30.0 if timelimit is None else timelimit, + tolerance=1e-7 if hybridz_tolerance is None else hybridz_tolerance, + ) + assert out_spec is not None results = solver.evaluate_spec( output_hz, out_spec, diff --git a/act/config/__init__.py b/act/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/act/config/backend.yaml b/act/config/backend.yaml new file mode 100644 index 000000000..1a04933f0 --- /dev/null +++ b/act/config/backend.yaml @@ -0,0 +1,147 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# ACT Back-End Configuration +# ═══════════════════════════════════════════════════════════════════════════ +# Canonical source for ALL back-end settings (runtime, verification, generation). +# CI (.github/workflows/act-bab.yml) relies on these defaults. +# +# Precedence: CLI flag > environment variable > this file > code default +# Env vars: ACT_SOLVER / ACT_DEVICE / ACT_DTYPE +# +# Layout (all under the single top-level `backend:` mapping): +# runtime selectors → verification cascade → torchlp → tf → hybridz +# → bab (branch-and-bound) + +backend: + + # ───────────────────────────────────────────────────────────────────────── + # Runtime selectors (apply to every back-end command) + # ───────────────────────────────────────────────────────────────────────── + + # Solver backend for LP/MILP verification. + # "auto" – try Gurobi first, fall back to TorchLP + # "gurobi" – exact MILP/LP via Gurobi (requires license) + # "torchlp" – PyTorch-tensor LP (Adam + penalty + box projection, + # GPU-capable, no license) + # "dual" – linear-relaxation dual certified bounds + # "hybridz" – HybridZ zonotope solver + # This selects WHICH solver runs; each solver's own tuning lives in its block + # below (torchlp:/gurobi:/hybridz:/dual:). "auto" reuses gurobi + torchlp. + solver: "torchlp" + + # Compute device. + # "cpu" – run on CPU (CI default, always available) + # "cuda" – run on NVIDIA GPU (requires CUDA toolkit) + # "gpu" – alias for "cuda" + device: "cpu" + + # Floating-point precision for all tensors. + # "float64" – double precision (more accurate, slower) + # "float32" – single precision (faster, may cause numerical drift) + dtype: "float64" + + # Print extra diagnostic output across all commands. + verbose: false + + # ───────────────────────────────────────────────────────────────────────── + # Verification cascade (single-shot → LP-batched → BaB) + # ───────────────────────────────────────────────────────────────────────── + + # Wall-clock budget in seconds for both single execution and BaB verification. + timeout: 60.0 + + # Enable the batched LP cascade tier (tier 2) before BaB. + # Must be false when solver = "gurobi" (Gurobi solve_batch is N=1 only). + lp_enabled: true + + # Maximum BaB subproblems solved in one batch (tier 3). + # Must be 1 when solver = "gurobi" (same N=1 restriction as lp_enabled). + bab_max_batch_size: 8 + + # ───────────────────────────────────────────────────────────────────────── + # TorchLP solver tuning (torchlp) + # ───────────────────────────────────────────────────────────────────────── + torchlp: + # -- Penalty weights -- + rho_eq: 10.0 # equality-constraint penalty weight + rho_ineq: 10.0 # inequality-constraint penalty weight + # -- Adam optimizer -- + max_iter: 2000 # Adam iteration cap + lr: 0.01 # Adam learning rate + beta1: 0.9 # Adam beta1 + beta2: 0.999 # Adam beta2 + weight_decay: 0.0 # Adam weight decay + tol_feas: 0.0001 # feasibility tolerance for SAT reporting + # -- Large-N overrides (kick in above large_n_threshold variables) -- + large_n_threshold: 20000 # variable-count threshold for large-N mode + large_n_max_iter: 800 # large-N iteration cap + large_n_tol: 0.001 # large-N feasibility tolerance + # -- Stagnation early-stop -- + stagnation_patience: 300 # stop after this many stagnant feasibility checks + stagnation_tol: 0.00001 # min feasibility improvement to reset stagnation + feas_check_stride: 5 # iteration stride between feasibility checks + + # ───────────────────────────────────────────────────────────────────────── + # Gurobi solver tuning (gurobi) + # ───────────────────────────────────────────────────────────────────────── + gurobi: + time_limit: null # per-solve wall-clock cap; null = caller timeout + mip_gap: 0.0001 # relative MIP optimality gap + threads: 0 # 0 = Gurobi automatic thread selection + output_flag: 0 # 0 = silent Gurobi output + + # ───────────────────────────────────────────────────────────────────────── + # HybridZ (hybridz) + # ───────────────────────────────────────────────────────────────────────── + hybridz: + # Per-call wall-clock budget in seconds; null = no HybridZ-specific limit. + timeout: null + # MILP feasibility/spec tolerance for HybridZ verdict decisions. + tolerance: 0.0000001 + # HybridZ generator/input dimension fallback limit (used by the HybridZ TF). + max_input_dim: 1024 + + # ───────────────────────────────────────────────────────────────────────── + # Dual + # ───────────────────────────────────────────────────────────────────────── + dual: + n_iters: 50 + lr_alpha: 0.1 + per_class_alpha: true + incremental_start_enabled: true + + # ═════════════════════════════════════════════════════════════════════════ + # Branch-and-Bound refinement (bab) + # ═════════════════════════════════════════════════════════════════════════ + bab: + + # -- General BaB -------------------------------------------------------- + enabled: true + max_depth: 3 + max_nodes: 10 + frontier_cap: 0 + input_split_fanout: 2 + branching_method: "random" + bounding_method: "random" + bounding_order: "depth_lb" + sa_cooling_rate: 0.99 + intermediate_refine: "none" + reuse_root_bounds: false + multi_split_levels: 1 + provenance_enabled: false + + # -- Dual-tier selector (solver_tier != lp) ---------------------------- + solver_tier: "lp" + + # -- LLM-probe controller ---------------------------------------------- + llm_probe_enabled: false + llm_probe_backend: "mock" + llm_probe_cadence: 1 + llm_probe_decisions: "split,frontier,refine" + llm_probe_log: false + + # -- BERT/text method --------------------------------------------------- + method: null + p: 2.0 + eps: 0.00001 + max_eps: 0.01 + k: 1 diff --git a/act/back_end/cli.py b/act/config/backend_cli.py similarity index 92% rename from act/back_end/cli.py rename to act/config/backend_cli.py index 677b6cf41..887d48d11 100644 --- a/act/back_end/cli.py +++ b/act/config/backend_cli.py @@ -17,6 +17,7 @@ import datetime import glob import json +import logging import os import statistics import sys @@ -24,14 +25,16 @@ from pathlib import Path from typing import Any, Dict, List, NamedTuple, Optional, Union, cast, get_args, get_origin, get_type_hints -from act.back_end.config import VALID_BERT_METHODS, _VALID_SOLVERS +from act.config.config import DualConfig, GurobiConfig, TorchLPConfig, VALID_BERT_METHODS, _VALID_SOLVERS from act.back_end.layer_schema import LayerKind from act.front_end.specs import OutKind from act.util.cli_utils import add_device_args, initialize_from_args +from act.util.format_utils import rule _TF_MODES: tuple[str, ...] = ("interval", "hybridz") _SOLVERS: tuple[str, ...] = tuple(sorted(_VALID_SOLVERS)) +logger = logging.getLogger(__name__) def _strip_optional(tp: Any) -> Any: @@ -62,7 +65,7 @@ def _parse_list(raw: str) -> list[Any]: def _add_dataclass_config_args(parser: argparse.ArgumentParser) -> None: """Expose all BackendConfig dataclass fields without hand-maintained drift.""" - from act.back_end.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig + from act.config.config import BackendConfig, BaBConfig, DualConfig, GenerationConfig, GurobiConfig, HybridZConfig, TorchLPConfig existing_options = { option @@ -118,24 +121,43 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk "Backend Config Overrides (generated)", "", "", - {"bab", "generation", "hybridz"}, + {"bab", "generation", "hybridz", "gurobi", "torchlp", "dual"}, ) add_group(BaBConfig, "BaB Config Overrides (generated)", "bab-", "bab_", set()) - add_group(GenerationConfig, "Generation Config Overrides (generated)", "gen-", "gen_", set()) + add_group(DualConfig, "Dual Config Overrides (generated)", "dual-", "dual_", set()) + add_group(GenerationConfig, "Generation Config Overrides (generated)", "gen-", "gen_", {"net_factory"}) add_group(HybridZConfig, "HybridZ Config Overrides (generated)", "hz-", "hybridz_", set()) + add_group(GurobiConfig, "Gurobi Config Overrides (generated)", "gurobi-", "gurobi_", set()) + add_group(TorchLPConfig, "TorchLP Config Overrides (generated)", "torchlp-", "torchlp_", set()) -def _backend_override_keys_from_dataclasses() -> set[str]: - from act.back_end.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig +# Backend YAML sub-section (== the BackendConfig nested-dataclass field name) -> +# the flat prefix its fields carry in the CLI/override namespace. Single source of +# truth, shared by the override-key derivation below AND the config-parity check +# (act/config/check_parity.py). Adding a field to an existing sub-config needs no +# change here; only a brand-new sub-config does. +_BACKEND_SUBCONFIG_PREFIX: dict[str, str] = { + "bab": "bab_", + "generation": "gen_", + "hybridz": "hybridz_", + "gurobi": "gurobi_", + "torchlp": "torchlp_", + "dual": "dual_", +} - keys = { - fld.name - for fld in fields(BackendConfig) - if fld.name not in {"bab", "generation", "hybridz"} - } - keys.update(f"bab_{fld.name}" for fld in fields(BaBConfig)) - keys.update(f"gen_{fld.name}" for fld in fields(GenerationConfig)) - keys.update(f"hybridz_{fld.name}" for fld in fields(HybridZConfig)) + +def _backend_override_keys_from_dataclasses() -> set[str]: + from act.config.config import BackendConfig + + hints = get_type_hints(BackendConfig) + keys: set[str] = set() + for fld in fields(BackendConfig): + prefix = _BACKEND_SUBCONFIG_PREFIX.get(fld.name) + if prefix is None: + keys.add(fld.name) + else: + sub_type = _strip_optional(hints.get(fld.name, fld.type)) + keys.update(f"{prefix}{sub.name}" for sub in fields(sub_type)) return keys @@ -151,7 +173,11 @@ class _SkipUnsupported(NamedTuple): kinds: tuple[str, ...] -def _make_solver(solver_name: str): +def _make_solver( + solver_name: str, + torchlp_config: Optional[TorchLPConfig] = None, + gurobi_config: Optional[GurobiConfig] = None, +): """LP-cascade solver factory (gurobi / torchlp / auto). Dual is routed separately via ``is_dual_solver_active`` since it implements ``compute_certified_bound``, not ``solve_batch``. @@ -161,16 +187,16 @@ def _make_solver(solver_name: str): if solver_name == "gurobi": from act.back_end.solver.solver_gurobi import GurobiSolver - return GurobiSolver() + return GurobiSolver(config=gurobi_config) if solver_name == "torchlp": - return TorchLPSolver() + return TorchLPSolver(config=torchlp_config) # "auto": try Gurobi, fall back to TorchLP try: from act.back_end.solver.solver_gurobi import GurobiSolver - return GurobiSolver() + return GurobiSolver(config=gurobi_config) except Exception: - return TorchLPSolver() + return TorchLPSolver(config=torchlp_config) def _verify_one_net( @@ -248,9 +274,17 @@ def _verify_one_net( return [], _SkipUnsupported(tf_name=authority_name, kinds=blocking), n_layers hz_timeout = None + hz_tolerance = None if is_hybridz: hz_timeout = backend_cfg.hybridz.timeout or backend_cfg.timeout - results: List[Any] = list(verify_once(net=net, timelimit=hz_timeout)) + hz_tolerance = backend_cfg.hybridz.tolerance + results: List[Any] = list( + verify_once( + net=net, + timelimit=hz_timeout, + hybridz_tolerance=hz_tolerance, + ) + ) any_unknown = any(r.status == VerifyStatus.UNKNOWN for r in results) @@ -260,7 +294,9 @@ def _verify_one_net( try: lp_results = verify_lp_batched( net, - solver_factory=lambda: _make_solver(backend_cfg.solver), + solver_factory=lambda: _make_solver( + backend_cfg.solver, backend_cfg.torchlp, backend_cfg.gurobi + ), timelimit=backend_cfg.timeout, ) results = [ @@ -305,10 +341,13 @@ def _verify_one_net( results = [ _vbb( slice_net_to_sample(net, i), - solver_factory=lambda: _make_solver(backend_cfg.solver), + solver_factory=lambda: _make_solver( + backend_cfg.solver, backend_cfg.torchlp, backend_cfg.gurobi + ), config=bab_cfg, max_batch_size=backend_cfg.bab_max_batch_size, time_budget_s=backend_cfg.timeout, + dual_config=backend_cfg.dual, ) if results[i].status == VerifyStatus.UNKNOWN else results[i] @@ -362,9 +401,9 @@ def run_verification(args, backend_cfg): def run_network_factory(args, backend_cfg): """Generate example networks using TF-aware NetFactory.""" - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"ACT NETWORK FACTORY") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") from act.back_end.net_factory import NetFactory @@ -372,14 +411,13 @@ def run_network_factory(args, backend_cfg): if gen.tf_targets: print(f"TF targets: {gen.tf_targets} (mode: {gen.registry_mode})") - print(f"Config: {gen.gen_config_path}") print(f"Output: {gen.output_dir}") print(f"Instances: {gen.num_instances}, Seed: {gen.base_seed}") print() try: factory = NetFactory( - gen_config_path=gen.gen_config_path, + config=gen.net_factory, output_dir=gen.output_dir, base_seed=gen.base_seed, num_instances=gen.num_instances, @@ -389,9 +427,9 @@ def run_network_factory(args, backend_cfg): write_manifest=gen.write_manifest, ) factory.generate() - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"✓ Network generation complete") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") return 0 except Exception as e: @@ -405,9 +443,9 @@ def run_network_factory(args, backend_cfg): def run_network_info(args): """Display information about a network.""" - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"NETWORK INFORMATION") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") from act.back_end.serialization.serialization import load_net_from_file from act.back_end.layer_schema import LayerKind @@ -433,9 +471,9 @@ def run_network_info(args): # Detailed layer info if verbose if args.verbose: - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"DETAILED LAYER INFORMATION") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") for layer in net.layers: print(f"Layer {layer.id}: {layer.kind}") @@ -455,36 +493,36 @@ def run_network_info(args): print(f" Successors: {succs}") print() - print(f"{'=' * 80}\n") + print(f"{rule()}\n") return 0 def run_serialization_test(args): """Test network serialization (save/load round-trip).""" - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"SERIALIZATION TEST") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") from act.back_end.serialization.test_serialization import main as test_main print("Running serialization tests...\n") result = test_main() - print(f"\n{'=' * 80}") + print(f"\n{rule()}") if result == 0: print("✓ All serialization tests passed") else: print("❌ Some serialization tests failed") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") return result def list_examples(args): """List available example networks.""" - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"AVAILABLE EXAMPLE NETWORKS") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") from act.pipeline.verification.model_factory import ModelFactory @@ -511,16 +549,16 @@ def list_examples(args): for cat, nets in sorted(categories.items()): print(f"{cat} ({len(nets)} networks):") - print("-" * 70) + print(rule(70, "-")) for name, info in sorted(nets): shape = info.get("input_shape", "?") layers = info.get("num_layers", "?") print(f" {name:40s} shape={shape} layers={layers}") print() - print(f"{'=' * 80}") + print(f"{rule()}") print("To generate networks: python -m act.back_end --generate") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") return 0 @@ -676,9 +714,9 @@ def run_bench(args) -> int: kind = args.bench bench_out = getattr(args, "bench_out", None) - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"ACT BENCH: {kind.upper()}") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") if kind in ("cnn", "all"): out_path = bench_out if (bench_out and kind == "cnn") else _bench_default_path("cnn") @@ -694,9 +732,9 @@ def run_bench(args) -> int: if rc != 0: return rc - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"Bench complete") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") return 0 @@ -718,11 +756,11 @@ def run_diff_nets(args) -> int: print(f"Error loading {path_b}: {e}") return 1 - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"NET DIFF") print(f" A: {path_a}") print(f" B: {path_b}") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") la, lb = len(net_a.layers), len(net_b.layers) marker = " " if la == lb else "!" @@ -760,7 +798,7 @@ def run_diff_nets(args) -> int: lyr = extra_net.layers[i] print(f"+ Layer {i:2d} ({lyr.kind:20s}): only in {extra_side}") - print(f"\n{'=' * 80}\n") + print(f"\n{rule()}\n") return 0 @@ -965,11 +1003,13 @@ def main(): default=None, dest="solver", help=( - "Solver backend. Three alternative families:\n" + "Solver backend:\n" " 'gurobi' — commercial MILP/LP (license required). LP cascade.\n" " 'torchlp' — PyTorch-tensor LP (Adam + penalty + box projection,\n" " GPU-capable). LP cascade.\n" - " 'dual' — DualSolver, linear-relaxation dual certified bounds via\n" + " 'hybridz' — Hybrid Zonotope propagation with a standalone open-source\n" + " MILP verdict; automatically selects HybridzTF.\n" + " 'dual' — DualSolver, linear-relaxation dual certified bounds via\n" " backward propagation. No LP cascade (DualSolver is\n" " its own verification pipeline).\n" " 'auto' — try gurobi, fall back to torchlp.\n" @@ -993,8 +1033,8 @@ def main(): "Forward-bounds transfer function: 'interval' or 'hybridz'. Selects " "the abstract interpretation used during analyze() to seed bounds " "for the LP cascade. Default: configured default (typically " - "'interval'). For dual certified bounds, use --solver dual instead " - "(dual is a solver, not a TF — see --solver help)." + "'interval'). The standalone hybridz solver selects HybridzTF " + "automatically; dual does not use this option." ), ) verify_group.add_argument( @@ -1248,7 +1288,7 @@ def main(): type=str, default=None, dest="backend_config", - help="Path to backend YAML config (default: act/back_end/config.yaml)", + help="Path to backend YAML config (default: act/config/backend.yaml)", ) # Common options @@ -1281,7 +1321,7 @@ def main(): # ── Build BackendConfig ────────────────────────────────────────────── # Load YAML as baseline, then overlay env vars and CLI flags on top. # Precedence: CLI flag > env var > config.yaml > dataclass default - from act.back_end.config import BackendConfig + from act.config.config import BackendConfig backend_cfg = BackendConfig.from_yaml( config_path=args.backend_config, @@ -1295,10 +1335,20 @@ def main(): _ap.Namespace(device=backend_cfg.device, dtype=backend_cfg.dtype) ) - if args.tf_mode is not None: + tf_mode = args.tf_mode + if backend_cfg.solver == "hybridz": + if tf_mode is not None and tf_mode != "hybridz": + logger.warning( + "--solver hybridz requires the hybridz transformer; overriding " + "--tf-mode %s", + tf_mode, + ) + tf_mode = "hybridz" + + if tf_mode is not None: from act.back_end.analyze import initialize_tf_mode - initialize_tf_mode(args.tf_mode) + initialize_tf_mode(tf_mode, backend_cfg.hybridz) # Set the solver-mode global so verify_once / _verify_one_net can dispatch # dual ↔ LP-cascade without consulting the TF mode (refactor decoupled @@ -1376,7 +1426,6 @@ def main(): ("bab_llm_probe_decisions", "bab_llm_probe_decisions", None, None, "not_none"), ("bab_llm_probe_log", "bab_llm_probe_log", None, None, "user_set"), ("bab_multi_split_levels", "bab_multi_split_levels", None, int, "not_none"), - ("gen_gen_config_path", "config", None, None, "not_none"), ("gen_output_dir", "output", "ACT_GEN_OUTPUT", None, "not_none"), ("gen_num_instances", "num", "ACT_GEN_NUM", int, "not_none"), ("gen_base_seed", "base_seed", "ACT_GEN_SEED", int, "not_none"), @@ -1424,7 +1473,7 @@ def _run_cli_cascade_smoke() -> int: from act.back_end.core import Layer, Net, Bounds, Fact, ConSet from act.back_end.layer_schema import LayerKind from act.front_end.specs import OutputSpec - from act.back_end.config import BackendConfig + from act.config.config import BackendConfig from act.util.stats import VerifyStatus passed = 0 @@ -1496,7 +1545,7 @@ def _t_cascade_default_config(): if results[0].status == VerifyStatus.UNKNOWN and cfg.lp_enabled: lp_results = verify_lp_batched( net, - solver_factory=lambda: _make_solver(cfg.solver), + solver_factory=lambda: _make_solver(cfg.solver, cfg.torchlp, cfg.gurobi), timelimit=cfg.timeout, ) assert len(lp_results) == 1 diff --git a/act/config/check_parity.py b/act/config/check_parity.py new file mode 100644 index 000000000..f00eaa787 --- /dev/null +++ b/act/config/check_parity.py @@ -0,0 +1,216 @@ +"""Consistency check: CLI options <-> YAML keys <-> config.py dataclass fields. + +Run in CI (``.github/workflows/act.config.yml``) or locally +(``python -m act.config.check_parity``). Exits non-zero on any drift, so an +option added to one surface can never be silently missed by the others. + +The config.py dataclasses are the single source of truth. Two documented +asymmetries are allowed: + +* pipeline ``verification.bab`` is a SPARSE override of ``backend.yaml`` + (its YAML lists only non-default keys), so there the YAML keys must be a + subset of the CLI surface rather than equal to it. +* fields declared ``metadata={"in_yaml": False}`` (BackendConfig's text-verify + scalars mirrored into ``bab.*``) are CLI/dataclass-only and absent from YAML. +""" +from __future__ import annotations + +import sys +from dataclasses import fields +from pathlib import Path +from typing import Any + +import yaml + + +def _field_names(dataclass_type) -> set[str]: + return {field.name for field in fields(dataclass_type)} + + +def _load_yaml(path: Path) -> dict[str, Any]: + with open(path) as handle: + return yaml.safe_load(handle) or {} + + +def _fmt(names: set[str]) -> str: + return "{" + ", ".join(sorted(names)) + "}" if names else "{}" + + +class ParityReport: + """Collects per-check results and prints a readable pass/fail line for each.""" + + def __init__(self) -> None: + self.failures: list[str] = [] + + def require(self, label: str, passed: bool, detail: str = "") -> None: + line = f" [{'OK ' if passed else 'FAIL'}] {label}" + if detail and not passed: + line += f" -> {detail}" + print(line) + if not passed: + self.failures.append(label) + + +def check_backend(report: ParityReport) -> None: + from act.config.config import _BACKEND_YAML, _NETGEN_YAML, BaBConfig, BackendConfig, DualConfig + from act.config.backend_cli import ( + _BACKEND_OVERRIDE_SPEC, + _BACKEND_SUBCONFIG_PREFIX, + _backend_override_keys_from_dataclasses, + ) + + # Single source of truth: the CLI/override key set is DERIVED from the config + # dataclasses (auto-includes any new field or sub-config). No hand-coded list. + dataclass_fields = _backend_override_keys_from_dataclasses() + cli_options = {key for key, *_ in _BACKEND_OVERRIDE_SPEC} + + yaml_keys: set[str] = set() + backend_yaml = _load_yaml(_BACKEND_YAML).get("backend", {}) + for key, value in backend_yaml.items(): + prefix = _BACKEND_SUBCONFIG_PREFIX.get(key) + if prefix is None: + yaml_keys.add(key) + continue + for sub_key in (value or {}): + # backend.bab.enabled is surfaced as the top-level bab_enabled flag. + if key == "bab" and sub_key == "enabled": + yaml_keys.add("bab_enabled") + else: + yaml_keys.add(f"{prefix}{sub_key}") + + if _NETGEN_YAML.exists(): + for key in _load_yaml(_NETGEN_YAML): + yaml_keys.add(f"gen_{key}") + + # A backend option may be CLI-only (settable but absent from the YAML) only if + # its dataclass field is declared metadata={"in_yaml": False}. No hand-coded + # list here -- the field declares it, and a normal new field defaults to + # requiring a YAML entry. + cli_only_allowed = { + f.name for f in fields(BackendConfig) if not f.metadata.get("in_yaml", True) + } + bab_cli_only_allowed = { + f"bab_{f.name}" + for f in fields(BaBConfig) + if not f.metadata.get("in_yaml", True) + } + dual_cli_only_allowed = { + f"dual_{f.name}" + for f in fields(DualConfig) + if not f.metadata.get("in_yaml", True) + } + cli_only_allowed |= bab_cli_only_allowed | dual_cli_only_allowed + + print("[backend]") + report.require("CLI options are backed by a dataclass field", + cli_options <= dataclass_fields, _fmt(cli_options - dataclass_fields)) + report.require("every dataclass field is CLI-exposed", + dataclass_fields <= cli_options, _fmt(dataclass_fields - cli_options)) + report.require("YAML keys are backed by a dataclass field", + yaml_keys <= dataclass_fields, _fmt(yaml_keys - dataclass_fields)) + report.require("YAML keys are all CLI-settable", + yaml_keys <= cli_options, _fmt(yaml_keys - cli_options)) + report.require("CLI options absent from YAML are in_yaml=False fields", + (cli_options - yaml_keys) <= cli_only_allowed, + _fmt((cli_options - yaml_keys) - cli_only_allowed)) + + +def check_pipeline(report: ParityReport) -> None: + from act.config.config import _PIPELINE_YAML, BaBConfig, DualConfig, ValidationConfig + from act.pipeline.fuzzing.actfuzzer import FuzzingConfig + from act.config.pipeline_cli import ( + _FUZZ_OVERRIDE_SPEC, _PIPELINE_BAB_OVERRIDE_FIELDS, _PIPELINE_DUAL_OVERRIDE_FIELDS, _PIPELINE_VAL_ATTR_MAP, + ) + pipeline_yaml = _load_yaml(_PIPELINE_YAML) + + fuzz_fields = _field_names(FuzzingConfig) + fuzz_cli = {key for key, *_ in _FUZZ_OVERRIDE_SPEC} + fuzz_yaml = set((pipeline_yaml.get("fuzzing") or {}).keys()) + print("[pipeline.fuzzing]") + report.require("CLI options are backed by a dataclass field", + fuzz_cli <= fuzz_fields, _fmt(fuzz_cli - fuzz_fields)) + report.require("YAML keys are backed by a dataclass field", + fuzz_yaml <= fuzz_fields, _fmt(fuzz_yaml - fuzz_fields)) + report.require("CLI and YAML are 1-to-1", + fuzz_cli == fuzz_yaml, + f"cli-only={_fmt(fuzz_cli - fuzz_yaml)} yaml-only={_fmt(fuzz_yaml - fuzz_cli)}") + + val_fields = _field_names(ValidationConfig) + val_cli = set(_PIPELINE_VAL_ATTR_MAP) + val_yaml = set((pipeline_yaml.get("validation") or {}).keys()) + print("[pipeline.validation]") + report.require("CLI options are backed by a dataclass field", + val_cli <= val_fields, _fmt(val_cli - val_fields)) + report.require("YAML keys are backed by a dataclass field", + val_yaml <= val_fields, _fmt(val_yaml - val_fields)) + report.require("CLI and YAML are 1-to-1", + val_cli == val_yaml, + f"cli-only={_fmt(val_cli - val_yaml)} yaml-only={_fmt(val_yaml - val_cli)}") + + bab_fields = _field_names(BaBConfig) + bab_cli = set(_PIPELINE_BAB_OVERRIDE_FIELDS) + verification_yaml = pipeline_yaml.get("verification") or {} + bab_yaml = set((verification_yaml.get("bab") or {}).keys()) + print("[pipeline.verification.bab] (sparse override of backend.yaml)") + report.require("CLI options are backed by a dataclass field", + bab_cli <= bab_fields, _fmt(bab_cli - bab_fields)) + report.require("YAML keys are backed by a dataclass field", + bab_yaml <= bab_fields, _fmt(bab_yaml - bab_fields)) + report.require("YAML keys are a subset of the CLI surface", + bab_yaml <= bab_cli, _fmt(bab_yaml - bab_cli)) + + dual_fields = _field_names(DualConfig) + dual_cli = set(_PIPELINE_DUAL_OVERRIDE_FIELDS) + dual_yaml = set((verification_yaml.get("dual") or {}).keys()) + print("[pipeline.verification.dual] (sparse override of backend.yaml)") + report.require("CLI options are backed by a dataclass field", + dual_cli <= dual_fields, _fmt(dual_cli - dual_fields)) + report.require("YAML keys are backed by a dataclass field", + dual_yaml <= dual_fields, _fmt(dual_yaml - dual_fields)) + report.require("YAML keys are a subset of the CLI surface", + dual_yaml <= dual_cli, _fmt(dual_yaml - dual_cli)) + + +def check_frontend(report: ParityReport) -> None: + from act.config.config import _FRONTEND_YAML + from act.config.frontend_cli import ( + _FRONTEND_SPEC_OVERRIDE_KEYS, _FRONTEND_TEXTVERIFY_OVERRIDE_KEYS, + ) + frontend_yaml = _load_yaml(_FRONTEND_YAML) + + text_cli = set(_FRONTEND_TEXTVERIFY_OVERRIDE_KEYS) + text_yaml = set((frontend_yaml.get("text_verification") or {}).keys()) + print("[frontend.text_verification]") + report.require("CLI and YAML are 1-to-1", + text_cli == text_yaml, + f"cli-only={_fmt(text_cli - text_yaml)} yaml-only={_fmt(text_yaml - text_cli)}") + + # specs are per-benchmark preset data, not scalar knobs; the CLI exposes a few + # override knobs that must exist in every preset for the override to apply. + spec_knobs = set(_FRONTEND_SPEC_OVERRIDE_KEYS) + print("[frontend.specs] (CLI knobs override preset values)") + for preset_name, preset in (frontend_yaml.get("specs") or {}).items(): + missing = spec_knobs - set((preset or {}).keys()) + report.require(f"preset '{preset_name}' exposes every CLI override knob", + not missing, _fmt(missing)) + + +def main() -> int: + report = ParityReport() + check_backend(report) + check_pipeline(report) + check_frontend(report) + print() + if report.failures: + print(f"CONFIG PARITY: {len(report.failures)} violation(s) - CLI/YAML/dataclass out of sync:") + for failure in report.failures: + print(f" - {failure}") + print("\nAdd the option in all three places: the CLI (act/config/*_cli.py), the " + "YAML (act/config/*_config.yaml), and the dataclass (act/config/config.py).") + return 1 + print("CONFIG PARITY: OK - CLI options, YAML keys, and dataclass fields are in sync.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/act/back_end/config.py b/act/config/config.py similarity index 63% rename from act/back_end/config.py rename to act/config/config.py index 351114f26..8fff89e18 100644 --- a/act/back_end/config.py +++ b/act/config/config.py @@ -1,20 +1,17 @@ -# ===- act/back_end/config.py - Backend Configuration ---------------------====# -# ACT: Abstract Constraint Transformer -# Copyright (C) 2025– ACT Team -# -# Licensed under the GNU Affero General Public License v3.0 or later (AGPLv3+). -# Distributed without any warranty; see . -# ===---------------------------------------------------------------------====# - from __future__ import annotations +from copy import deepcopy from dataclasses import asdict, dataclass, field, fields +from importlib import import_module from pathlib import Path from typing import Any, Final, List, Optional, Union import yaml -_DEFAULT_YAML = Path(__file__).parent / "config.yaml" +_BACKEND_YAML = Path(__file__).parent / "backend.yaml" +_NETGEN_YAML = Path(__file__).parent / "gen_act_net.yaml" +_PIPELINE_YAML = Path(__file__).parent / "pipeline.yaml" +_FRONTEND_YAML = Path(__file__).parent / "frontend.yaml" _VALID_SOLVERS = {"auto", "gurobi", "torchlp", "dual", "hybridz"} _VALID_DEVICES = {"cpu", "cuda", "gpu"} @@ -31,6 +28,11 @@ ) +def _load_yaml(path: Path) -> dict[str, Any]: + with open(path) as handle: + return yaml.safe_load(handle) or {} + + @dataclass(frozen=True) class BertMethodSelection: """Resolved attention-relaxation BERT verification method.""" @@ -82,7 +84,7 @@ class BaBConfig: Construction:: BaBConfig() # programmatic defaults - BaBConfig.from_yaml() # load from act/back_end/config.yaml + BaBConfig.from_yaml() # load from act/config/backend.yaml BaBConfig.from_yaml(path, **kw) # custom YAML + overrides """ @@ -94,8 +96,8 @@ class BaBConfig: branching_method: str = "random" bounding_method: str = "random" bounding_order: str = "depth_lb" - bounding_depth_weight: float = 0.5 - bounding_bound_weight: float = 0.5 + bounding_depth_weight: float = field(default=0.5, metadata={"in_yaml": False}) + bounding_bound_weight: float = field(default=0.5, metadata={"in_yaml": False}) sa_cooling_rate: float = 0.99 # Dual-tier solver knobs — support solver_tier="dual_alpha_eta" with @@ -103,34 +105,16 @@ class BaBConfig: solver_tier: str = "lp" f"""Solver tier for BaB bound computation. Valid: {VALID_SOLVER_TIERS}.""" - dual_n_iters: int = 50 - """Number of Adam iterations for α/η optimization (only used in ``dual_alpha`` / ``dual_alpha_eta`` tiers).""" - - lr_alpha: float = 0.1 - """Adam learning rate for α (slope) variables.""" - - lr_beta: float = 0.1 - """Adam learning rate for η (split-constraint KKT multipliers). 0.1 default; tune per network.""" - - lr_decay: float = 0.98 - """Multiplicative learning-rate decay applied each Adam iteration.""" - - incremental_start_enabled: bool = True - """Reuse α/η tensors from the parent subproblem as the initial point for child optimization.""" - - per_class_alpha: bool = True - """Allocate separate α tensors per output class (tighter bounds) rather than sharing one α.""" - provenance_enabled: bool = False """Track logical BaB node ids and parent ids in TopKBounding.""" - eta_only_children: bool = False + eta_only_children: bool = field(default=False, metadata={"in_yaml": False}) """Freeze alpha in child subproblems (depth > 0): children inherit the parent's optimized alpha and refine only the split multipliers (eta). Cuts the per-node Adam graph and, combined with reuse_root_bounds, removes the per-iteration forward pass entirely.""" - presplit_levels: int = 0 + presplit_levels: int = field(default=0, metadata={"in_yaml": False}) """Pre-split the root's top-k scored unstable neurons into all 2^k sign combinations before the main loop (LEAPS-style leap: descendants are materialized directly, intermediate tree levels are never bounded). The @@ -143,7 +127,7 @@ class BaBConfig: intermediate_refine_ratio x the median - targets wide fan-in concretization loss), 'all' (every unstable activation layer).""" - intermediate_refine_ratio: float = 10.0 + intermediate_refine_ratio: float = field(default=10.0, metadata={"in_yaml": False}) """Width-blowup threshold multiplier for intermediate_refine='auto'.""" reuse_root_bounds: bool = False @@ -156,7 +140,7 @@ class BaBConfig: the input-term concretization and the eta split multipliers. Eliminates the per-node forward pass (the dominant time and memory cost).""" - per_subproblem_refine: str = "none" + per_subproblem_refine: str = field(default="none", metadata={"in_yaml": False}) """Per-subproblem sparse backward refinement of intermediate bounds in the BaB loop (requires reuse_root_bounds): 'none' (off), 'tail' (last two unstable activation layers), 'all' (every unstable activation layer). For @@ -165,23 +149,23 @@ class BaBConfig: exact, so refining them gains nothing), so splits propagate relationally downstream instead of only through the interval refresh.""" - per_subproblem_refine_iters: int = 0 + per_subproblem_refine_iters: int = field(default=0, metadata={"in_yaml": False}) """Adam iterations for per-subproblem refine rows (0 = single fixed-slope backward, cheapest).""" - per_subproblem_refine_rows_cap: int = 64 + per_subproblem_refine_rows_cap: int = field(default=64, metadata={"in_yaml": False}) """Max refined neurons per layer per batch (top-cap by interval width); bounds the K x 2*cap backward cost.""" - auto_batch_safety: float = 0.55 + auto_batch_safety: float = field(default=0.55, metadata={"in_yaml": False}) """Fraction of GPU memory the auto batch sizer (max_batch_size='auto') may target; lowered on a shared GPU. The sizer also never exceeds 90% of the currently-reclaimable memory (free + this process's reserved cache).""" - auto_batch_cap: int = 2048 + auto_batch_cap: int = field(default=2048, metadata={"in_yaml": False}) """Hard upper bound on the auto-sized batch (also the CPU fallback).""" - auto_batch_floor: int = 8 + auto_batch_floor: int = field(default=8, metadata={"in_yaml": False}) """Lower bound on the auto-sized batch.""" multi_split_levels: int = 1 @@ -194,17 +178,17 @@ class BaBConfig: llm_probe_enabled: bool = False llm_probe_backend: str = "mock" - llm_probe_model: str = "" - llm_probe_base_url: str = "" - llm_probe_api_key_env: str = "" - llm_probe_temperature: float = 0.0 - llm_probe_timeout: float = 30.0 - llm_probe_max_candidates: int = 8 - llm_probe_max_candidates_total: int = 1024 - llm_probe_neuron_topk: int = 512 + llm_probe_model: str = field(default="", metadata={"in_yaml": False}) + llm_probe_base_url: str = field(default="", metadata={"in_yaml": False}) + llm_probe_api_key_env: str = field(default="", metadata={"in_yaml": False}) + llm_probe_temperature: float = field(default=0.0, metadata={"in_yaml": False}) + llm_probe_timeout: float = field(default=30.0, metadata={"in_yaml": False}) + llm_probe_max_candidates: int = field(default=8, metadata={"in_yaml": False}) + llm_probe_max_candidates_total: int = field(default=1024, metadata={"in_yaml": False}) + llm_probe_neuron_topk: int = field(default=512, metadata={"in_yaml": False}) llm_probe_cadence: int = 1 - llm_probe_history: int = 8 - llm_probe_max_failures: int = 3 + llm_probe_history: int = field(default=8, metadata={"in_yaml": False}) + llm_probe_max_failures: int = field(default=3, metadata={"in_yaml": False}) llm_probe_decisions: str = "split,frontier,refine" """Comma-separated decision types the LLM may steer: 'split' (joint neuron split depth), 'frontier' (wave width), 'refine' (per-subproblem refinement), @@ -212,18 +196,18 @@ class BaBConfig: dimension to bisect and its fanout, input-domain-splitting BaB only).""" llm_probe_log: bool = False - verbose: bool = False + verbose: bool = field(default=False, metadata={"in_yaml": False}) method: Optional[str] = None - baf: bool = True - alpha_mode: str = "fixed" + baf: bool = field(default=True, metadata={"in_yaml": False}) + alpha_mode: str = field(default="fixed", metadata={"in_yaml": False}) p: float = 2.0 - perturbed_words: int = 1 + perturbed_words: int = field(default=1, metadata={"in_yaml": False}) eps: float = 1e-5 max_eps: float = 0.01 - num_verify_iters: int = 5 + num_verify_iters: int = field(default=5, metadata={"in_yaml": False}) k: int = 1 - alpha_opt_steps: int = 1000 + alpha_opt_steps: int = field(default=1000, metadata={"in_yaml": False}) def __post_init__(self) -> None: if self.solver_tier not in VALID_SOLVER_TIERS: @@ -255,11 +239,11 @@ def from_yaml( Reads from ``backend.bab`` in the unified backend config, falling back to a top-level ``bab`` key for standalone BaB YAML files. """ - path = Path(config_path) if config_path else _DEFAULT_YAML + path = Path(config_path) if config_path else _BACKEND_YAML if not path.exists(): raise FileNotFoundError( - f"Backend config not found: {path}\nExpected: act/back_end/config.yaml" + f"Backend config not found: {path}\nExpected: act/config/backend.yaml" ) with open(path) as f: @@ -292,21 +276,14 @@ def to_yaml(self, path: Union[str, Path]) -> Path: # GenerationConfig — network generation (net_factory) parameters # --------------------------------------------------------------------------- -_DEFAULT_GEN_CONFIG = str( - Path(__file__).parent / "examples" / "config_gen_act_net.yaml" -) - - @dataclass class GenerationConfig: """Configuration for network generation via ``NetFactory``. - Controls the simple knobs (how many, where, seed, TF filtering). - The architecture sampling DSL lives in a separate file referenced - by ``gen_config_path``. + Controls network generation knobs and the architecture-sampling DSL loaded + from ``act/config/gen_act_net.yaml``. """ - gen_config_path: str = _DEFAULT_GEN_CONFIG output_dir: str = "act/back_end/examples/nets" num_instances: int = 15 base_seed: int = 42 @@ -318,6 +295,8 @@ class GenerationConfig: coverage_report: bool = True write_manifest: bool = True + net_factory: dict[str, Any] = field(default_factory=dict) + def __post_init__(self) -> None: if self.registry_mode not in _VALID_REGISTRY_MODES: raise ValueError( @@ -333,7 +312,56 @@ def __post_init__(self) -> None: @dataclass class HybridZConfig: timeout: Optional[float] = None - engine: str = "dense_hz_objbound" + tolerance: float = 1e-7 + max_input_dim: int = 1024 + + +@dataclass +class GurobiConfig: + time_limit: Optional[float] = None + mip_gap: float = 1e-4 + threads: int = 0 + output_flag: int = 0 + + +@dataclass +class DualConfig: + n_iters: int = 50 + """Number of Adam iterations for α/η optimization in BaB dual tiers.""" + + lr_alpha: float = 0.1 + """Adam learning rate for α (slope) variables.""" + + lr_beta: float = field(default=0.1, metadata={"in_yaml": False}) + """Adam learning rate for η (split-constraint KKT multipliers).""" + + lr_decay: float = field(default=0.98, metadata={"in_yaml": False}) + """Multiplicative learning-rate decay applied each Adam iteration.""" + + per_class_alpha: bool = True + """Allocate separate α tensors per output class rather than sharing one α.""" + + incremental_start_enabled: bool = True + """Reuse α/η tensors from the parent subproblem as the child initialization.""" + + +@dataclass +class TorchLPConfig: + rho_eq: float = 10.0 + rho_ineq: float = 10.0 + max_iter: int = 2000 + tol_feas: float = 1e-4 + lr: float = 1e-2 + beta1: float = 0.9 + beta2: float = 0.999 + weight_decay: float = 0.0 + large_n_threshold: int = 20000 + large_n_max_iter: int = 800 + large_n_tol: float = 1e-3 + stagnation_patience: int = 300 + stagnation_tol: float = 1e-5 + feas_check_stride: int = 5 + # --------------------------------------------------------------------------- # BackendConfig — unified back-end configuration @@ -345,7 +373,7 @@ class BackendConfig: """Unified configuration for the ACT back-end. Covers runtime selectors (solver / device / dtype), verification timeout, - and nested BaB settings. The canonical source is ``act/back_end/config.yaml``; + and nested BaB settings. The canonical source is ``act/config/backend.yaml``; CLI flags and environment variables override it at load time. Construction:: @@ -383,15 +411,18 @@ class BackendConfig: generation: GenerationConfig = field(default_factory=GenerationConfig) hybridz: HybridZConfig = field(default_factory=HybridZConfig) - - method: Optional[str] = None - p: float = 2.0 - perturbed_words: int = 1 - eps: float = 1e-5 - max_eps: float = 0.01 - num_verify_iters: int = 5 - k: int = 1 - alpha_opt_steps: int = 1000 + gurobi: GurobiConfig = field(default_factory=GurobiConfig) + torchlp: TorchLPConfig = field(default_factory=TorchLPConfig) + dual: DualConfig = field(default_factory=DualConfig) + + method: Optional[str] = field(default=None, metadata={"in_yaml": False}) + p: float = field(default=2.0, metadata={"in_yaml": False}) + perturbed_words: int = field(default=1, metadata={"in_yaml": False}) + eps: float = field(default=1e-5, metadata={"in_yaml": False}) + max_eps: float = field(default=0.01, metadata={"in_yaml": False}) + num_verify_iters: int = field(default=5, metadata={"in_yaml": False}) + k: int = field(default=1, metadata={"in_yaml": False}) + alpha_opt_steps: int = field(default=1000, metadata={"in_yaml": False}) # -- validation --------------------------------------------------------- @@ -459,27 +490,30 @@ def from_yaml( bab: enabled: true ... - generation: - num_instances: 15 - ... + generation settings are loaded from act/config/gen_act_net.yaml Override naming: - ``bab_`` → ``BaBConfig.`` - ``gen_`` → ``GenerationConfig.`` - ``hybridz_`` → ``HybridZConfig.`` + - ``gurobi_`` → ``GurobiConfig.`` + - ``torchlp_`` → ``TorchLPConfig.`` + - ``dual_`` → ``DualConfig.`` - ``bab_enabled`` → top-level ``bab_enabled`` """ - path = Path(config_path) if config_path else _DEFAULT_YAML + path = Path(config_path) if config_path else _BACKEND_YAML if not path.exists(): raise FileNotFoundError(f"Backend config not found: {path}") - with open(path) as f: - raw = yaml.safe_load(f) or {} + raw = _load_yaml(path) backend_raw: dict[str, Any] = raw.get("backend", {}) bab_raw: dict[str, Any] = backend_raw.pop("bab", {}) - gen_raw: dict[str, Any] = backend_raw.pop("generation", {}) + gen_raw: dict[str, Any] = _load_yaml(_NETGEN_YAML) if _NETGEN_YAML.exists() else {} hz_raw: dict[str, Any] = backend_raw.pop("hybridz", {}) + gurobi_raw: dict[str, Any] = backend_raw.pop("gurobi", {}) + torchlp_raw: dict[str, Any] = backend_raw.pop("torchlp", {}) + dual_raw: dict[str, Any] = backend_raw.pop("dual", {}) # Extract "enabled" from bab section → top-level bab_enabled bab_enabled = bab_raw.pop("enabled", None) @@ -488,9 +522,15 @@ def from_yaml( bab_fields = {fld.name for fld in fields(BaBConfig)} gen_fields = {fld.name for fld in fields(GenerationConfig)} hz_fields = {fld.name for fld in fields(HybridZConfig)} + gurobi_fields = {fld.name for fld in fields(GurobiConfig)} + torchlp_fields = {fld.name for fld in fields(TorchLPConfig)} + dual_fields = {fld.name for fld in fields(DualConfig)} bab_overrides: dict[str, Any] = {} gen_overrides: dict[str, Any] = {} hz_overrides: dict[str, Any] = {} + gurobi_overrides: dict[str, Any] = {} + torchlp_overrides: dict[str, Any] = {} + dual_overrides: dict[str, Any] = {} top_overrides: dict[str, Any] = {} for k, v in overrides.items(): if k.startswith("bab_") and k[4:] in bab_fields: @@ -499,11 +539,22 @@ def from_yaml( gen_overrides[k[4:]] = v elif k.startswith("hybridz_") and k[8:] in hz_fields: hz_overrides[k[8:]] = v + elif k.startswith("gurobi_") and k[7:] in gurobi_fields: + gurobi_overrides[k[7:]] = v + elif k.startswith("torchlp_") and k[8:] in torchlp_fields: + torchlp_overrides[k[8:]] = v + elif k.startswith("dual_") and k[5:] in dual_fields: + dual_overrides[k[5:]] = v else: top_overrides[k] = v # Build BaBConfig - bab_merged = {k: v for k, v in bab_raw.items() if k in bab_fields} + bab_in_yaml = { + fld.name for fld in fields(BaBConfig) if fld.metadata.get("in_yaml", True) + } + bab_merged = { + k: v for k, v in bab_raw.items() if k in bab_fields and k in bab_in_yaml + } bab_merged.update(bab_overrides) bab_config = BaBConfig(**bab_merged) @@ -511,13 +562,37 @@ def from_yaml( gen_merged = {k: v for k, v in gen_raw.items() if k in gen_fields} gen_merged.update(gen_overrides) gen_config = GenerationConfig(**gen_merged) - + hz_merged = {k: v for k, v in hz_raw.items() if k in hz_fields} hz_merged.update(hz_overrides) hz_config = HybridZConfig(**hz_merged) + gurobi_config = GurobiConfig( + **{k: v for k, v in gurobi_raw.items() if k in gurobi_fields} | gurobi_overrides + ) + + torchlp_merged = {k: v for k, v in torchlp_raw.items() if k in torchlp_fields} + torchlp_merged.update(torchlp_overrides) + torchlp_config = TorchLPConfig(**torchlp_merged) + + dual_in_yaml = { + fld.name for fld in fields(DualConfig) if fld.metadata.get("in_yaml", True) + } + dual_merged = { + k: v for k, v in dual_raw.items() if k in dual_fields and k in dual_in_yaml + } + dual_merged.update(dual_overrides) + dual_config = DualConfig(**dual_merged) + # Build top-level config - top_fields = {fld.name for fld in fields(cls)} - {"bab", "generation", "hybridz"} + top_fields = {fld.name for fld in fields(cls)} - { + "bab", + "generation", + "hybridz", + "gurobi", + "torchlp", + "dual", + } top_merged: dict[str, Any] = {} for k, v in backend_raw.items(): if k in top_fields: @@ -528,7 +603,15 @@ def from_yaml( top_merged.update({k: v for k, v in top_overrides.items() if k in top_fields}) - return cls(bab=bab_config, generation=gen_config, hybridz=hz_config, **top_merged) + return cls( + bab=bab_config, + generation=gen_config, + hybridz=hz_config, + gurobi=gurobi_config, + torchlp=torchlp_config, + dual=dual_config, + **top_merged, + ) def to_yaml(self, path: Union[str, Path]) -> Path: path = Path(path) @@ -536,14 +619,26 @@ def to_yaml(self, path: Union[str, Path]) -> Path: d = asdict(self) bab_d = d.pop("bab") - gen_d = d.pop("generation") + d.pop("generation") hz_d = d.pop("hybridz") + gurobi_d = d.pop("gurobi") + torchlp_d = d.pop("torchlp") + dual_d = d.pop("dual") bab_enabled = d.pop("bab_enabled") bab_d["enabled"] = bab_enabled with open(path, "w") as f: yaml.dump( - {"backend": {**d, "bab": bab_d, "generation": gen_d, "hybridz": hz_d}}, + { + "backend": { + **d, + "bab": bab_d, + "hybridz": hz_d, + "gurobi": gurobi_d, + "torchlp": torchlp_d, + "dual": dual_d, + } + }, f, default_flow_style=False, sort_keys=False, @@ -618,7 +713,7 @@ def build_vnncomp_bab_config( max_nodes: int = 1_000_000_000, solver_tier: str = "dual_alpha_eta", dual_n_iters: int = 100, -) -> BaBConfig: +) -> tuple[BaBConfig, DualConfig]: """BaBConfig for real VNNLIB instances (the VNN-COMP runner profile): ``fsb``/``babsr`` keep single-neuron splits, ``gain``/``gain+llm`` use joint-split depth, and only ``gain+llm`` enables the LLM probe.""" @@ -631,20 +726,22 @@ def build_vnncomp_bab_config( frontier_cap=25000, max_depth=max_depth, max_nodes=max_nodes, - dual_n_iters=dual_n_iters, - lr_alpha=0.25, - lr_beta=0.1, - lr_decay=0.98, - incremental_start_enabled=True, - per_class_alpha=True, reuse_root_bounds=True, intermediate_refine="all", presplit_levels=0, eta_only_children=False, multi_split_levels=1 if branching_method != "gain" else max(1, int(multi_split_levels)), ) + dual_cfg = DualConfig( + n_iters=dual_n_iters, + lr_alpha=0.25, + lr_beta=0.1, + lr_decay=0.98, + incremental_start_enabled=True, + per_class_alpha=True, + ) if config_label != "gain+llm": - return BaBConfig(**common) + return BaBConfig(**common), dual_cfg cfg = BaBConfig( llm_probe_enabled=True, llm_probe_backend=llm_backend, @@ -657,4 +754,139 @@ def build_vnncomp_bab_config( ) if llm_model: cfg.llm_probe_model = llm_model - return cfg + return cfg, dual_cfg + + +# --------------------------------------------------------------------------- +# Pipeline configuration +# --------------------------------------------------------------------------- + + +FuzzingConfig = Any + + +@dataclass +class ValidationConfig: + solvers: list[str] + tf_modes: list[str] + samples: int + per_neuron_topk: int + bounds_tolerance: str + batch_sizes: Optional[list[Optional[int]]] + + +@dataclass +class PipelineConfig: + fuzzing: FuzzingConfig + bab: BaBConfig + dual: DualConfig + validation: ValidationConfig + + @classmethod + def from_yaml( + cls, + config_path: Optional[str | Path] = None, + **overrides: Any, + ) -> "PipelineConfig": + path = Path(config_path) if config_path else _PIPELINE_YAML + if not path.exists(): + raise FileNotFoundError( + f"Pipeline config not found: {path}\nExpected: act/config/pipeline.yaml" + ) + + FuzzingConfig = import_module("act.pipeline.fuzzing.actfuzzer").FuzzingConfig + + with open(path) as f: + yaml_data = yaml.safe_load(f) or {} + + fuzz_overrides = _strip_prefixed_overrides(overrides, "fuzz_") + bab_overrides = _strip_prefixed_overrides(overrides, "bab_") + dual_overrides = _strip_prefixed_overrides(overrides, "dual_") + val_overrides = _strip_prefixed_overrides(overrides, "val_") + + fuzzing = FuzzingConfig.from_mapping( + yaml_data.get("fuzzing") or {}, **fuzz_overrides + ) + verification_data = yaml_data.get("verification") or {} + bab_data = verification_data.get("bab") or {} + dual_data = verification_data.get("dual") or {} + validation_data = yaml_data.get("validation") or {} + + bab = BaBConfig(**_merge_dataclass_fields(BaBConfig, bab_data, bab_overrides)) + dual = DualConfig(**_merge_dataclass_fields(DualConfig, dual_data, dual_overrides)) + validation = ValidationConfig( + **_merge_dataclass_fields(ValidationConfig, validation_data, val_overrides) + ) + return cls(fuzzing=fuzzing, bab=bab, dual=dual, validation=validation) + + +def _strip_prefixed_overrides(overrides: dict[str, Any], prefix: str) -> dict[str, Any]: + return { + key[len(prefix) :]: value + for key, value in overrides.items() + if key.startswith(prefix) and value is not None + } + + +def _merge_dataclass_fields( + dataclass_type: type, + yaml_values: dict[str, Any], + overrides: dict[str, Any], +) -> dict[str, Any]: + valid_keys = {field.name for field in fields(dataclass_type)} + merged = {key: value for key, value in yaml_values.items() if key in valid_keys} + merged.update({key: value for key, value in overrides.items() if key in valid_keys}) + return merged + + +def read_fuzzing_section(config_path: Optional[str | Path] = None) -> dict[str, Any]: + """Read the ``fuzzing`` section of the pipeline YAML. + + config.py is the single reader of the config YAML files; FuzzingConfig (in + act.pipeline.fuzzing.actfuzzer) routes its YAML access through here. + """ + path = Path(config_path) if config_path else _PIPELINE_YAML + if not path.exists(): + raise FileNotFoundError( + f"Pipeline config not found: {path}\nExpected: act/config/pipeline.yaml" + ) + with open(path) as f: + yaml_data = yaml.safe_load(f) or {} + return yaml_data.get("fuzzing") or {} + + +# --------------------------------------------------------------------------- +# Front-end configuration loading +# --------------------------------------------------------------------------- + + +@dataclass +class FrontEndConfig: + specs: dict[str, dict[str, Any]] = field(default_factory=dict) + text_verification: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_yaml( + cls, + config_path: Optional[Union[str, Path]] = None, + **overrides: Any, + ) -> "FrontEndConfig": + path = Path(config_path) if config_path else _FRONTEND_YAML + if not path.exists(): + raise FileNotFoundError(f"Front-end config not found: {path}") + + with open(path) as f: + raw = yaml.safe_load(f) or {} + + specs = deepcopy(raw.get("specs", {})) + text_verification = deepcopy(raw.get("text_verification", {})) + text_verification.update( + {k: v for k, v in overrides.items() if k in text_verification and v is not None} + ) + return cls(specs=specs, text_verification=text_verification) + + def spec_config(self, name: Optional[str]) -> dict[str, Any]: + key = name or "default" + if key not in self.specs: + raise KeyError(f"Unknown front-end spec config: {key}") + return deepcopy(self.specs[key]) diff --git a/act/config/frontend.yaml b/act/config/frontend.yaml new file mode 100644 index 000000000..8bc2a54f9 --- /dev/null +++ b/act/config/frontend.yaml @@ -0,0 +1,189 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# ACT Front-End Configuration +# ═══════════════════════════════════════════════════════════════════════════ +# Data/model/spec-creation settings for the front-end tier. +# +# Precedence: CLI flag > this file > code default +# +# Sections: +# specs – named presets that drive InputSpec/OutputSpec synthesis +# text_verification – BERT/text robustness-verification defaults + +# ───────────────────────────────────────────────────────────────────────────── +# Specification presets +# ───────────────────────────────────────────────────────────────────────────── +# Select a preset by name (falls back to "default"). Fields shared by presets: +# epsilons – list of L∞ perturbation radii to sweep +# margins – list of robustness margins +# input_kinds – BOX | LINF_BALL | LIN_POLY +# output_kinds – MARGIN_ROBUST | TOP1_ROBUST | LINEAR_LE | RANGE | UNSAFE_LINEAR +# combination_strategy – full | minimal | balanced (how many input×output pairs) +# balanced_params – caps used when combination_strategy = balanced +# Each preset's `notes:` block (below) documents its intended usage. +specs: + + # -- TorchVision image classifiers (MNIST / CIFAR10 / ImageNet) -- + torchvision_classification: + epsilons: + - 0.01 + - 0.03 + - 0.05 + - 0.1 + margins: + - 0.0 + - 0.5 + input_kinds: + - BOX + - LINF_BALL + output_kinds: + - MARGIN_ROBUST + - TOP1_ROBUST + combination_strategy: full + balanced_params: + max_input_specs: 5 + max_output_specs: 3 + max_total_combinations: 15 + validation: + validate_shapes: true + skip_invalid: true + torchvision: + num_samples: 20 + start_index: 0 + split: test + preprocessing_info: + normalize: true + resize: true + grayscale: false + image_classification: + clamp_range: + min: 0.0 + max: 1.0 + center_perturbations: true + logging: + level: INFO + show_progress: true + verbose_samples: false + performance: + batch_validate: false + cache_shapes: true + notes: 'TorchVision Classification Preset: + + - Designed for MNIST, CIFAR10, ImageNet models + + - Uses standard epsilon values (0.01, 0.03, 0.05, 0.1) + + - Generates both BOX and LINF_BALL input specs + + - Tests both strict (TOP1_ROBUST) and margin-based robustness + + - Full combination strategy creates many spec pairs for thorough testing + + ' + # -- VNN-COMP benchmark instances (ONNX + VNNLIB, minimal specs per instance) -- + vnnlib_default: + epsilons: + - 0.01 + margins: + - 0.0 + input_kinds: + - BOX + - LIN_POLY + output_kinds: + - LINEAR_LE + - RANGE + combination_strategy: minimal + balanced_params: + max_input_specs: 1 + max_output_specs: 1 + max_total_combinations: 1 + validation: + validate_shapes: true + skip_invalid: true + vnnlib: + max_instances: null + simplify_onnx: true + onnx_conversion: + force_conversion: false + test_conversion: true + onnx: + shape_inference: auto + handle_batch_dim: true + vnnlib_parsing: + input_tensor_method: center + fallback_output_spec: RANGE + category_overrides: + acasxu: + max_instances: 50 + simplify_onnx: false + mnist_fc: + max_instances: 100 + test_conversion: true + cifar10_resnet: + max_instances: 20 + simplify_onnx: true + logging: + level: INFO + show_progress: true + verbose_conversion: false + verbose_parsing: false + performance: + cache_models: true + parallel: false + num_workers: 1 + download: + vnncomp_repo: https://raw.githubusercontent.com/ChristopherBrix/vnncomp_benchmarks/main + retry_failed: true + max_retries: 3 + notes: "VNNLIB Benchmark Preset:\n- Designed for VNN-COMP benchmark instances\n\ + - Minimal combination strategy (1 input spec, 1 output spec per instance)\n\ + - Automatically downloads from VNN-COMP GitHub repository\n- Converts ONNX models\ + \ to PyTorch for unified verification interface\n- Parses VNNLIB SMT-LIB format\ + \ to extract constraints\n- Handles common VNN-COMP categories (MNIST, CIFAR10,\ + \ ACAS Xu, etc.)\n\nUsage:\n 1. Download category: download_vnnlib_category(\"\ + mnist_fc\")\n 2. Create specs: creator = VNNLibSpecCreator(); results = creator.create_specs_for_data_model_pairs()\n\ + \ 3. Each result contains: (category, instance_id, pytorch_model, [input_tensor],\ + \ [(input_spec, output_spec)])\n" + # -- Generic fallback preset (used when no named preset is requested) -- + default: + epsilons: + - 0.01 + - 0.03 + - 0.05 + margins: + - 0.0 + input_kinds: + - BOX + - LINF_BALL + output_kinds: + - MARGIN_ROBUST + combination_strategy: balanced + balanced_params: + max_input_specs: 3 + max_output_specs: 2 + max_total_combinations: 6 + validation: + validate_shapes: true + skip_invalid: true + torchvision: + num_samples: 10 + start_index: 0 + split: test + vnnlib: + max_instances: null + simplify_onnx: true + logging: + level: INFO + show_progress: true +# ───────────────────────────────────────────────────────────────────────────── +# Text verification (BERT / NLP robustness defaults) +# ───────────────────────────────────────────────────────────────────────────── +text_verification: + # method: null (off) or "planar" | "rule" | "alpha" | "ibp" | "discrete" + method: null + p: 2.0 # perturbation norm p + perturbed_words: 1 # number of perturbed words (1 or 2) + eps: 1.0e-05 # initial epsilon + max_eps: 0.01 # maximum epsilon + num_verify_iters: 5 # verification iterations + k: 1 # top-k value + alpha_opt_steps: 1000 # alpha optimization steps diff --git a/act/front_end/cli.py b/act/config/frontend_cli.py similarity index 87% rename from act/front_end/cli.py rename to act/config/frontend_cli.py index f8590ec6b..c22ba98c0 100644 --- a/act/front_end/cli.py +++ b/act/config/frontend_cli.py @@ -14,14 +14,65 @@ from act.front_end.creator_registry import detect_creator, list_creators, get_creator from act.util.cli_utils import add_device_args, initialize_from_args +from act.util.format_utils import rule # Import domain-specific CLIs for delegation from act.front_end.torchvision_loader import data_model_mapping as tv_mapping from act.front_end.torchvision_loader import data_model_loader as tv_loader from act.front_end.vnnlib_loader import category_mapping as vnnlib_mapping from act.front_end.bert_loader import data_loader as bert_loader -from act.back_end.config import VALID_BERT_METHODS - +from act.config.config import VALID_BERT_METHODS + + +def _parse_list_arg(value: Optional[str], item_type: type = str) -> Optional[list[Any]]: + if value is None: + return None + parts = value.replace(',', ' ').split() + return [item_type(part) for part in parts] + + +_FRONTEND_SPEC_OVERRIDE_KEYS: tuple[str, ...] = ( + "epsilons", + "margins", + "input_kinds", + "output_kinds", + "combination_strategy", +) +_FRONTEND_TEXTVERIFY_OVERRIDE_KEYS: tuple[str, ...] = ( + "method", + "p", + "perturbed_words", + "eps", + "max_eps", + "num_verify_iters", + "k", + "alpha_opt_steps", +) + + +def _build_spec_overrides(args: argparse.Namespace) -> dict[str, Any] | None: + overrides: dict[str, Any] = {} + parsed = { + 'epsilons': _parse_list_arg(args.epsilons, float), + 'margins': _parse_list_arg(args.margins, float), + 'input_kinds': _parse_list_arg(args.input_kinds, str), + 'output_kinds': _parse_list_arg(args.output_kinds, str), + } + overrides.update({key: value for key, value in parsed.items() if value is not None}) + if args.combination_strategy is not None: + overrides['combination_strategy'] = args.combination_strategy + return overrides or None + + +def _build_text_verification_overrides(args: argparse.Namespace) -> dict[str, Any] | None: + overrides = { + key: getattr(args, key) + for key in _FRONTEND_TEXTVERIFY_OVERRIDE_KEYS + if getattr(args, key) is not None + } + if 'method' in overrides: + overrides['method'] = overrides['method'].replace('-', '_') + return overrides or None def print_unified_list(creator: Optional[str] = None): """ @@ -30,15 +81,15 @@ def print_unified_list(creator: Optional[str] = None): Args: creator: If provided, only show items from this creator """ - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"ACT FRONT-END UNIFIED CATALOG") - print(f"{'='*100}") + print(f"{rule(100)}") if creator is None or creator == 'torchvision': # List TorchVision datasets datasets = sorted(tv_mapping.DATASET_MODEL_MAPPING.keys()) print(f"\nTorchVision Datasets ({len(datasets)}):") - print('-' * 100) + print(rule(100, "-")) for ds_name in datasets: info = tv_mapping.DATASET_MODEL_MAPPING[ds_name] category = info.get('category', 'N/A') @@ -49,7 +100,7 @@ def print_unified_list(creator: Optional[str] = None): # List VNNLIB categories categories = vnnlib_mapping.list_categories() print(f"\nVNNLIB Categories ({len(categories)}):") - print('-' * 100) + print(rule(100, "-")) for cat_name in sorted(categories): info = vnnlib_mapping.get_category_info(cat_name) print(f" {cat_name:30s} ({info['type']}) - {info['description']}") @@ -57,11 +108,11 @@ def print_unified_list(creator: Optional[str] = None): if creator is None or creator == 'bert': datasets = bert_loader.list_bert_datasets() print(f"\nBERT Datasets ({len(datasets)}):") - print('-' * 100) + print(rule(100, "-")) for ds_name in datasets: print(f" {ds_name:30s} [sentiment] - {bert_loader.BERT_DATASETS[ds_name]}") - print(f"\n{'='*100}\n") + print(f"\n{rule(100)}\n") def print_unified_search(query: str, creator: Optional[str] = None): @@ -72,9 +123,9 @@ def print_unified_search(query: str, creator: Optional[str] = None): query: Search query string creator: If provided, only search this creator """ - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"SEARCH RESULTS: '{query}'") - print(f"{'='*100}") + print(f"{rule(100)}") found_any = False @@ -83,7 +134,7 @@ def print_unified_search(query: str, creator: Optional[str] = None): if tv_matches: found_any = True print(f"\nTorchVision Datasets ({len(tv_matches)}):") - print('-' * 100) + print(rule(100, "-")) for ds_name in sorted(tv_matches): info = tv_mapping.DATASET_MODEL_MAPPING[ds_name] category = info.get('category', 'N/A') @@ -94,7 +145,7 @@ def print_unified_search(query: str, creator: Optional[str] = None): if vnnlib_matches: found_any = True print(f"\nVNNLIB Categories ({len(vnnlib_matches)}):") - print('-' * 100) + print(rule(100, "-")) for cat_name in sorted(vnnlib_matches): info = vnnlib_mapping.get_category_info(cat_name) print(f" {cat_name:30s} ({info['type']}) - {info['description']}") @@ -107,14 +158,14 @@ def print_unified_search(query: str, creator: Optional[str] = None): if bert_matches: found_any = True print(f"\nBERT Datasets ({len(bert_matches)}):") - print('-' * 100) + print(rule(100, "-")) for ds_name in sorted(bert_matches): print(f" {ds_name:30s} [sentiment] - {bert_loader.BERT_DATASETS[ds_name]}") if not found_any: print(f"\nNo results found for '{query}'") - print(f"\n{'='*100}\n") + print(f"\n{rule(100)}\n") def print_unified_info(name: str, explicit_creator: Optional[str] = None): @@ -128,9 +179,9 @@ def print_unified_info(name: str, explicit_creator: Optional[str] = None): try: creator_name, normalized_name = detect_creator(name, explicit_creator) - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"DETECTED CREATOR: {creator_name.upper()}") - print(f"{'='*100}") + print(f"{rule(100)}") if creator_name == 'torchvision': info = tv_mapping.get_dataset_info(normalized_name) @@ -165,7 +216,7 @@ def print_unified_info(name: str, explicit_creator: Optional[str] = None): print("Input: clean embeddings [B, L, D]") print("Specs: LP_EMBEDDING + MARGIN_ROBUST") - print(f"\n{'='*100}\n") + print(f"\n{rule(100)}\n") except ValueError as e: print(f"Error: {e}") @@ -183,9 +234,9 @@ def handle_unified_download(name: str, explicit_creator: Optional[str] = None): try: creator_name, normalized_name = detect_creator(name, explicit_creator) - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"DOWNLOADING: {normalized_name} (creator: {creator_name})") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") if creator_name == 'torchvision': # For TorchVision, download dataset + all recommended models @@ -210,9 +261,9 @@ def handle_unified_download(name: str, explicit_creator: Optional[str] = None): except Exception as e: print(f"✗ {normalized_name} + {model} - Error: {e}") - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"Downloaded {success_count}/{len(models)} model pairs") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") elif creator_name == 'vnnlib': # Import VNNLIB loader @@ -228,13 +279,13 @@ def handle_unified_download(name: str, explicit_creator: Optional[str] = None): result = vnnlib_loader.download_vnnlib_category(normalized_name) if result['status'] == 'success': - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"✓ Successfully downloaded category: {normalized_name}") print(f" Location: {result['category_path']}") print(f" Instances: {result['num_instances']}") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") else: - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"✗ Download failed: {result['message']}") print(f"\nNote: VNNLIB benchmarks must be downloaded manually from VNN-COMP.") print(f"Expected location: data/vnnlib/{normalized_name}/") @@ -246,14 +297,14 @@ def handle_unified_download(name: str, explicit_creator: Optional[str] = None): print(f" - onnx/ (ONNX model files)") print(f" - vnnlib/ (VNNLIB property files)") print(f" - instances.csv (benchmark instances)") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") except Exception as e: - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"✗ Download error: {e}") print(f"\nNote: VNNLIB benchmarks must be downloaded manually from VNN-COMP.") print(f"Expected location: data/vnnlib/{normalized_name}/") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") elif creator_name == 'bert': print("BERT datasets are file-based.") print(f"Expected raw files under data/{normalized_name}/") @@ -270,15 +321,15 @@ def print_list_downloads(creator: Optional[str] = None): Args: creator: If provided, only show downloads from this creator """ - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"DOWNLOADED ITEMS") - print(f"{'='*100}") + print(f"{rule(100)}") if creator is None or creator == 'torchvision': tv_downloads = tv_loader.list_downloaded_pairs() if tv_downloads: print(f"\nTorchVision Downloads ({len(tv_downloads)}):") - print('-' * 100) + print(rule(100, "-")) for item in sorted(tv_downloads, key=lambda x: (x['dataset'], x['model'])): print(f" {item['dataset']:30s} + {item['model']}") else: @@ -290,7 +341,7 @@ def print_list_downloads(creator: Optional[str] = None): vnnlib_downloads = vnnlib_loader.list_downloaded_pairs() if vnnlib_downloads: print(f"\nVNNLIB Downloads ({len(vnnlib_downloads)} instances):") - print('-' * 100) + print(rule(100, "-")) # Group by category categories = {} @@ -308,24 +359,24 @@ def print_list_downloads(creator: Optional[str] = None): if creator is None or creator == 'bert': print(f"\nBERT Downloads:") - print('-' * 100) + print(rule(100, "-")) print(" File-based loader; place SST/Yelp raw files under data/sst or data/yelp") - print(f"\n{'='*100}\n") + print(f"\n{rule(100)}\n") def print_creators(): """Print information about all available creators.""" creators = list_creators() - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"AVAILABLE CREATORS") - print(f"{'='*100}") + print(f"{rule(100)}") for creator_name in sorted(creators): creator = get_creator(creator_name) print(f"\n{creator_name.upper()}") - print('-' * 100) + print(rule(100, "-")) print(f" Class: {type(creator).__name__}") if creator_name == 'torchvision': @@ -347,7 +398,7 @@ def print_creators(): print(f" Items: {len(datasets)} dataset names/aliases") print(f" Kinds: binary sentiment robustness") - print(f"\n{'='*100}\n") + print(f"\n{rule(100)}\n") def print_creator_info(name: str) -> None: @@ -359,9 +410,9 @@ def print_creator_info(name: str) -> None: print(f"Available creators: {list_creators()}") return - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"CREATOR: {name.upper()}") - print(f"{'='*100}") + print(f"{rule(100)}") print(f" Class: {type(creator).__name__}") print(f" Module: {type(creator).__module__}") print(f" All creators: {creators}") @@ -388,7 +439,7 @@ def print_creator_info(name: str) -> None: print(f" Kinds: binary sentiment robustness") print(f" Spec types: LP_EMBEDDING, MARGIN_ROBUST") - print(f"\n{'='*100}\n") + print(f"\n{rule(100)}\n") def main(): @@ -599,6 +650,40 @@ def main(): action="store_true", help="Run inference on synthesized models to validate correctness (defaults to TorchVision, use --creator to specify)" ) + parser.add_argument( + "--epsilons", + type=str, + default=None, + help="Override spec epsilon values as a comma or space separated list." + ) + parser.add_argument( + "--margins", + type=str, + default=None, + help="Override spec margin values as a comma or space separated list." + ) + parser.add_argument( + "--combination-strategy", + type=str, + choices=["full", "minimal", "balanced"], + default=None, + dest="combination_strategy", + help="Override spec input/output combination strategy." + ) + parser.add_argument( + "--input-kinds", + type=str, + default=None, + dest="input_kinds", + help="Override input spec kinds as a comma or space separated list." + ) + parser.add_argument( + "--output-kinds", + type=str, + default=None, + dest="output_kinds", + help="Override output spec kinds as a comma or space separated list." + ) parser.add_argument( "--method", type=str, @@ -665,21 +750,25 @@ def main(): elif args.synthesis: creator_name = args.creator if args.creator else 'torchvision' - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"MODEL SYNTHESIS - {creator_name.upper()}") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") try: from act.front_end.model_synthesis import model_synthesis from act.util.model_inference import model_inference - wrapped_models = model_synthesis(creator=creator_name) + wrapped_models = model_synthesis( + creator=creator_name, + spec_overrides=_build_spec_overrides(args), + text_verification_overrides=_build_text_verification_overrides(args), + ) print(f"\n✓ Successfully synthesized {len(wrapped_models)} models") # Automatically run inference after synthesis - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"MODEL INFERENCE - {creator_name.upper()}") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") # model_inference extracts input from InputLayer successful_models = model_inference(cast(Any, wrapped_models)) @@ -690,9 +779,9 @@ def main(): elif args.inference: creator_name = args.creator if args.creator else 'torchvision' - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"MODEL INFERENCE - {creator_name.upper()}") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") try: # Get downloaded pairs for the creator @@ -761,13 +850,13 @@ def main(): successful = sum(1 for r in results if r['status'] == 'success') failed = len(results) - successful - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"INFERENCE SUMMARY") - print(f"{'='*100}") + print(f"{rule(100)}") print(f"✓ Successful: {successful}/{len(results)}") if failed > 0: print(f"✗ Failed: {failed}/{len(results)}") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") except Exception as e: import traceback diff --git a/act/config/gen_act_net.yaml b/act/config/gen_act_net.yaml new file mode 100644 index 000000000..2e8375072 --- /dev/null +++ b/act/config/gen_act_net.yaml @@ -0,0 +1,472 @@ +# ============================================================================ +# ACT Network Generation Configuration +# ============================================================================ +# Scalar generation knobs and the NetFactory architecture-sampling DSL live here. + +# Directory where generated .json network files are written. +output_dir: "act/back_end/examples/nets" + +# Number of networks to generate per run. +num_instances: 15 + +# Random seed for reproducible generation. +base_seed: 42 + +# Prefix prepended to every generated filename. +name_prefix: "cfg_seed" + +# Transfer-function targets for layer filtering. +# null – no filtering, all layer types allowed +# ["interval"] – only layers supported by interval TF +# ["interval", "hybridz"] – layers supported by both TFs +tf_targets: null + +# How to combine layer sets when multiple tf_targets are given. +# "intersection" – keep only layers supported by ALL targets (stricter) +# "union" – keep layers supported by ANY target (more permissive) +registry_mode: "intersection" + +# When to stop generating. +# "basic" – generate exactly num_instances, then stop +# "full" – keep generating until every target layer type is covered +coverage_mode: "basic" + +# Maximum attempts in "full" coverage mode before giving up. +coverage_max_attempts: 1000 + +# Print a layer-coverage summary after generation finishes. +coverage_report: true + +# Write a manifest.json listing all generated network filenames. +write_manifest: true + +net_factory: + # ============================================================================ + # NetFactory architecture-sampling DSL ("the different networks") + # ============================================================================ + # Consumed via GenerationConfig.net_factory and loaded by + # BackendConfig.from_yaml from act/config/gen_act_net.yaml. + + # ============================================================================ + # ACT Network Generation Configuration + # ============================================================================ + # + # This file drives the automatic generation of neural networks for + # verification testing. Each generated network is a self-contained JSON + # file with embedded input constraints (INPUT_SPEC) and output properties + # (ASSERT), ready for spec-free verification: + # + # python -m act.back_end --generate # default 15 nets + # python -m act.back_end --generate --num 50 # custom count + # python -m act.back_end --generate --base-seed 42 # reproducible + # python -m act.back_end --generate --tf-targets interval hybridz + # + # Generated networks end up in act/back_end/examples/nets/*.json + # and can be loaded via ModelFactory or NetSerializer. + # + # --------------------------------------------------------------------------- + # HOW THE SAMPLING RULES WORK + # --------------------------------------------------------------------------- + # Every parameter below is either a plain value or a sampling rule. + # The six rule types are: + # + # {choice: [a, b, c]} Pick one uniformly at random + # {range: [lo, hi]} Random integer in [lo, hi] + # {weighted: {a: 0.7, b: 0.3}} Pick with given probability weights + # {repeat: {count: , Build a list: sample count first, + # value: }} then sample each element + # {probability: 0.3} True with 30% chance, False otherwise + # {const: v} Always returns v (explicit constant) + # + # You can nest rules freely. A bare scalar (e.g. 3) is shorthand for + # {const: 3}. + # + # --------------------------------------------------------------------------- + # HOW TF-AWARE FILTERING WORKS + # --------------------------------------------------------------------------- + # When you pass “--tf-targets interval hybridz” on the command line, the + # factory queries each Transfer Function's layer registry and keeps only + # layer types supported by ALL specified TFs (intersection mode, default). + # Families whose required layers are not in that set are excluded + # automatically. This guarantees every generated network can be analyzed + # by the chosen TFs without "unsupported layer" errors. + # + # ============================================================================ + + + # ============================================================================ + # 1. GENERATION CONTROL + # ============================================================================ + + common: + # How many networks to generate per run. + num_instances: 15 + + # Random seed for reproducible generation. + # seed=42 produces a network set with minimal Gurobi soundness issues (1 out of 30). + # Override with --base-seed on command line if needed. + base_seed: 42 + + # Prefix prepended to every generated filename. + name_prefix: cfg_seed + + # Where to write the generated .json files. + output_dir: act/back_end/examples/nets + + # Write a manifest.json listing all generated network names. + # Downstream tools (ModelFactory) use this for discovery. + write_manifest: true + manifest_path: act/back_end/examples/nets/_meta/manifest.json + + # Tensor data type used for all generated weights. + # "torch.float64" gives maximum precision; "torch.float32" is faster. + dtype: torch.float64 + + # Coverage mode controls when generation stops: + # "basic" - generate exactly num_instances networks, then stop. + # "full" - keep generating until every target layer type has appeared + # at least once (up to coverage_max_attempts). Uncovered + # layers get a minimal template network as fallback. + coverage_mode: basic + coverage_max_attempts: 1000 + + # Print a layer coverage summary after generation finishes. + coverage_report: true + + + # ============================================================================ + # 2. ARCHITECTURE FAMILY SELECTION + # ============================================================================ + # Each generated network belongs to one "family". The weights below control + # how often each family is chosen. (Values are relative, not percentages.) + + family_selection: + weighted: + mlp: 0.5 # fully-connected networks + cnn2d: 0.5 # 2-D convolutional networks + rnn: 0.0 # disabled by default curently, only interval, hybridz supports RNNs; bump to 1.0 for RNN self-tests + + + # ============================================================================ + # 3. FAMILY-SPECIFIC PARAMETERS + # ============================================================================ + # Each family has its own set of tunables. Parameters that belong to a + # specific variant (plain / block / residual / stage) are ignored when + # a different variant is sampled. + + families: + + # -------------------------------------------------------------------------- + # 3.1 MLP (fully-connected feedforward) + # -------------------------------------------------------------------------- + # + # Three structural variants ("Activ" = whichever activation is sampled below): + # plain - Dense -> Activ -> Dense -> Activ -> ... -> Dense(num_classes) + # block - projection Dense, then repeated [Dense->Activ->Dense->(Activ?)] + # residual - projection Dense, then repeated [Dense->Activ->Dense + skip->Activ] + # + # The final Dense(num_classes) classifier head is always appended. + + mlp: + + # --- Input --- + # Shape's leading dim is the batch size B. Sequential analysis is the + # B=1 case of the same batched code path. + # [1, 6] = batch of 1 6-D input; [4, 16] = batch of 4 16-D inputs; + # [1, 3, 8] = 3-channel 1-D signal (auto-flattened). + input_shape: + choice: [[1, 6], [1, 16], [1, 3, 8], [4, 6], [4, 16]] + + # Number of output classes (= width of the final Dense layer). + num_classes: + choice: [10] + + # --- Variant selection --- + variant: + weighted: + plain: 0.5 + block: 0.5 + residual: 0.0 + + # --- "plain" variant params --- + # Keep shallow (2-3 layers) to avoid bounds explosion in interval domain. + # Each DENSE+activation layer inflates bounds ~3×, so 3 layers ≈ 27× inflation. + hidden_sizes: # Width of each hidden layer + repeat: + count: { range: [2, 3] } # 2-3 hidden layers (was 2-4) + value: { choice: [32, 64] } # narrower (was 32-128) + + # --- "block" variant params --- + # Each block = 2 Dense layers. num_blocks=2 → 4 Dense + projection + head = 7 layers total. + num_blocks: # How many [Dense->Act->Dense] blocks + range: [1, 2] # was 1-3 + block_width: # All blocks share this width + choice: [32, 64] + post_block_activation: # Add activation after each block? + probability: 0.8 + + # --- "residual" variant params --- + num_residual_blocks: # How many skip-connection blocks + range: [1, 2] # was 1-3 + residual_width: # Width of all residual blocks + choice: [32, 64] # was 32-128 + + # --- Common layer settings --- + use_bias: + const: true # All Dense layers include bias + + # Rare extra layers (low probability) to exercise more operator types + # during coverage-mode testing: + # use_bias_layer/use_scale_layer disabled: standalone SCALE/BIAS are BN + # decomposition artifacts, not real layers. Removed to avoid act2torch issues. + # use_unsqueeze_squeeze disabled: cons_exportor doesn't support unsqueeze constraint tags + # use_unsqueeze_squeeze: + # probability: 0.04 + + # --- Activation function --- + # Only activations registered in ACT's layer_schema REGISTRY. + activation: + choice: [relu, tanh, sigmoid, lrelu, relu6, silu, abs] + + # LeakyReLU negative slope (used only when activation = lrelu). + lrelu_alpha: + choice: [0.01, 0.1, 0.2] + + # -------------------------------------------------------------------------- + # 3.2 CNN2D (2-D convolutional) + # -------------------------------------------------------------------------- + # + # Three structural variants ("Activ" = whichever activation is sampled below): + # plain - [Conv->Activ->(Pool?)] x N -> Flatten -> Dense -> Activ -> Dense + # residual - Conv->Activ, then [Conv->Activ->Conv + skip->Activ] x N -> pool-to-1x1 -> Dense + # stage - Conv->Activ, then S stages each with downsample + B conv blocks -> pool-to-1x1 -> Dense + # (stage resembles simplified ResNet) + + cnn2d: + + # --- Input --- + # [batch, channels, height, width]. Leading dim is the batch B. + # [1,1,8,8] = single-channel 8x8; [4,1,8,8] = batch of 4; + # [1,3,16,16] = RGB 16x16; [4,3,16,16] = batch of 4 RGB. + input_shape: + choice: [[1, 1, 8, 8], [1, 3, 16, 16], [4, 1, 8, 8], [4, 3, 16, 16]] + + num_classes: + choice: [10] + + # --- Variant selection --- + variant: + weighted: + plain: 0.5 + residual: 0.0 + stage: 0.5 + + # --- "plain" variant params --- + conv_channels: # Output channels per conv layer + repeat: + count: { range: [1, 3] } # 1-3 conv layers + value: { choice: [8, 16, 32] } # each 8, 16, or 32 channels + fc_hidden: # Hidden units in FC head + choice: [32, 64, 128] + + # --- "residual" variant params --- + num_residual_blocks: # Blocks with skip connections + range: [1, 2] # was 2-4 + residual_channels: # Channel width for all blocks + choice: [16, 32] # was 16-64 + + # --- "stage" variant params --- + stages: # Number of downsampling stages + range: [1, 2] # was 1-3 + blocks_per_stage: # Conv blocks per stage + range: [1, 2] + base_channels: # Initial channel count (doubles per stage) + choice: [8, 16] # was 8-32 + channel_mult: # Channel multiplier between stages + choice: [2] + double_conv_p: # Probability of double conv in a block + choice: [0.5, 0.7] + head_pool_to_1x1: # Pool spatial dims to 1x1 before FC + const: true + downsample: # How to downsample between stages + choice: [maxpool, avgpool, stride2_conv] + + # --- Extra layers --- + # use_batchnorm and use_scale_layer disabled: SCALE/BIAS are BN decomposition + # artifacts, not real network layers. Generating them requires _ScaleModule/ + # _BiasModule wrappers in act2torch and causes shape mismatch issues. + # use_transpose disabled: cons_exportor doesn't support transpose constraint tags + # use_transpose: + # probability: 0.04 + + # --- Convolution settings --- + kernel_sizes: { choice: [3] } # Kernel size for all conv layers + strides: { choice: [1] } # Stride for all conv layers + paddings: { choice: [1] } # Padding for all conv layers (same-padding with k=3, p=1) + use_bias: { const: true } + + # --- Pooling settings --- + # Always pool after each conv block to prevent bounds explosion. + # Without pooling, 3-layer CONV can inflate bounds to width 11000+. + use_pooling: + const: true + pool_kind: # Type of pooling layer (no 'none' — always pool) + choice: [maxpool, avgpool] + pool_kernel: { const: 2 } + pool_stride: { const: 2 } + + # --- Activation --- + # Restricted to kinds that have both a dual_tf forward handler AND + # a PyTorch builder in ActGraphModule (required by --verify netfactory + # --validate-soundness Level 2 bounds checks against concrete model output). + activation: + choice: [relu, tanh, sigmoid, lrelu] + + lrelu_alpha: + choice: [0.01, 0.1, 0.2] + + # -------------------------------------------------------------------------- + # 3.3 RNN (recurrent: RNN / LSTM / GRU + Flatten + Dense head) + # -------------------------------------------------------------------------- + # + # Single-layer recurrent cell with a flattened classifier head: + # INPUT (1, T, F) -> RNN/LSTM/GRU -> FLATTEN -> DENSE(num_classes) + # + # Multi-layer (num_layers > 1) and bidirectional are supported by the + # serialization / restoration paths, but the interval transfer function + # currently unrolls only layer 0 — so we keep num_layers = 1 here to avoid + # silently coarse bounds. The cell type (RNN / LSTM / GRU) is sampled + # per-instance. + # + # Sequence length and feature width are kept small to control bounds + # explosion: an LSTM cell unrolled T times applies sigmoid/tanh interval + # widening at every step. + + rnn: + + # --- Input --- + # [batch, seq_len, input_size]; batch is always 1 for symbolic verification. + input_shape: + choice: [[1, 4, 3], [1, 6, 4]] + + num_classes: + choice: [10] + + # --- Cell type --- + cell: + weighted: + LSTM: 0.4 + GRU: 0.3 + RNN: 0.3 + + # --- Cell hyper-parameters --- + hidden_size: + choice: [8, 16] + + num_layers: + const: 1 + + bidirectional: + # Disabled: interval_tf raises NotImplementedError on bidirectional RNN + # because the reverse-direction sweep over weight_*_l0_reverse is not + # implemented. Bump back above 0 once that lands. + probability: 0.0 + + batch_first: + const: true + + use_bias: + const: true + + # Vanilla-RNN nonlinearity (ignored for LSTM/GRU). + nonlinearity: + choice: [tanh, relu] + + + # ============================================================================ + # 4. INPUT SPECIFICATION (INPUT_SPEC layer) + # ============================================================================ + # Defines the input region that verification will analyze. + # Every generated network gets exactly one INPUT_SPEC. + + input_spec: + # Which constraint type to embed: + # BOX - axis-aligned box lb[i] <= x[i] <= ub[i] + # LINF_BALL - L-infinity ball ||x - center||_inf <= eps + kind: + weighted: + BOX: 0.5 + LINF_BALL: 0.5 + + # Base value range for the input domain. + # BOX bounds and LINF_BALL center are sampled within this range. + value_range: + choice: [[0.0, 1.0]] + + # BOX only: random shrinkage applied to each side of the box. + # Higher shrinkage = tighter box = less bounds inflation. + # [0.3, 0.45] means the box is shrunk to ~10-40% of full range per side. + box_shrink_range: [0.3, 0.45] + + # LINF_BALL only: perturbation radius. + # Smaller eps = tighter input bounds = less bounds inflation through deep networks. + # 0.1 caused bounds width ~19 at output of 4-layer MLP → degenerate LP. + eps: + choice: [0.01, 0.02, 0.03] + + + # ============================================================================ + # 5. OUTPUT SPECIFICATION (ASSERT layer) + # ============================================================================ + # Defines the verification property the network output must satisfy. + # Every generated network gets exactly one ASSERT. + # + # Property types: + # TOP1_ROBUST - The true class must have the highest logit. + # Params: y_true (class index) + # + # MARGIN_ROBUST - The true class must beat all others by >= margin. + # Params: y_true, margin + # + # LINEAR_LE - A linear inequality c^T y <= d must hold. + # Params: c (coefficient vector), d (threshold) + # + # RANGE - Every output must lie within element-wise bounds. + # Params: lb, ub (vectors same size as output) + + output_spec: + kind: + weighted: + TOP1_ROBUST: 0.7 # Most common: classification robustness (margin=0, easiest for interval domain) + MARGIN_ROBUST: 0.1 # Rare: non-zero margin is hard for interval domain on deep networks + LINEAR_LE: 0.1 # Linear inequality on outputs + RANGE: 0.1 # Element-wise output bounds + + # MARGIN_ROBUST: required separation between true class and runner-up. + # Keep values small — interval domain bounds explode through deep networks, + # making large margins unprovable (false CERTIFIED). + margin: + choice: [0.0, 0.01] + + # LINEAR_LE: coefficient vector c is sampled element-wise from this range, + # threshold d is sampled from linear_le_d_range. + linear_le_c_range: [-1.0, 1.0] + linear_le_d_range: [-1.0, 1.0] + + # RANGE: per-output lower and upper bounds are sampled within this range. + range_bounds: + choice: [[-1.0, 1.0]] + + + # ============================================================================ + # 6. VALIDATE-VERIFIER DEFAULTS + # ============================================================================ + # Defaults consumed by `python -m act.pipeline --verify netfactory --validate-soundness ...` + # when the corresponding CLI flag is NOT explicitly passed. Any CLI flag + # that IS passed overrides the value here. + + validate: + # Batch sizes to validate at. ``null`` = use each network's native B + # from its INPUT shape; positive int = batchify INPUT_SPEC/ASSERT to + # that B (sequential analysis is the B=1 case of the same path). + batch_sizes: [null, 1, 4] diff --git a/act/config/pipeline.yaml b/act/config/pipeline.yaml new file mode 100644 index 000000000..da8c8c2bf --- /dev/null +++ b/act/config/pipeline.yaml @@ -0,0 +1,94 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# ACT Pipeline Configuration +# ═══════════════════════════════════════════════════════════════════════════ +# Settings for the pipeline tier: whitebox fuzzing, BaB verification, and the +# verifier-validation harness. +# +# Precedence: CLI flag > this file > code default +# +# Sections: +# fuzzing – ACTFuzzer counterexample search +# verification – Branch-and-Bound overrides (see backend.yaml for the +# full BaB surface; only non-default keys are set here) +# validation – soundness / numerical-precision harness + +# ───────────────────────────────────────────────────────────────────────────── +# Fuzzing (ACTFuzzer — gradient/coverage-guided counterexample search) +# ───────────────────────────────────────────────────────────────────────────── +fuzzing: + + # -- Budget -- + max_iterations: 10000 # maximum fuzzing iterations + timeout_seconds: 3600.0 # total wall-clock time budget (seconds) + + # -- Coverage tracking -- + # coverage_strategy: "GlobalCov" – monotonic union coverage over all inputs + # "BestInputCov" – best per-input coverage over time + coverage_strategy: "GlobalCov" + activation_threshold: 0.0001 # a neuron "fires" when activation > threshold + + # -- Mutation strategy -- + # mutation_weights: relative selection weights per mutation operator (need not + # sum to 1). Set an operator to 0 to disable it. + mutation_weights: + gradient: 0 # single-step FGSM-style perturbation + pgd: 0.4 # iterative PGD-style perturbation (projected each step) + activation: 0.3 # DeepXplore-style: target low-activation neurons + boundary: 0.2 # sample near InputSpec boundaries + random: 0.1 # Gaussian-noise baseline + # perturb_mode: how mutation perturbation size is computed. + # "adaptive_scalar" – single size from mean(ub-lb) * perturb_scale (default) + # "adaptive_perdim" – per-dimension size from (ub-lb) * perturb_scale + # "fixed" – legacy hardcoded values + perturb_mode: "adaptive_scalar" + # Fraction of the feasible range each perturbation covers (steps ≈ 1/scale; + # 0.1 ⇒ ~10 steps to traverse lb→ub). + perturb_scale: 0.1 + + # -- Seed scheduling -- + # seed_selection_strategy: "energy" (AFL-style prioritization) | "random" + seed_selection_strategy: "energy" + + # -- Output / reporting -- + save_counterexamples: true # write counterexamples incrementally + output_dir: "fuzzing_results" # relative to act/pipeline/log/ + report_interval: 500 # print progress every N iterations + # verbose: 0 = silent | 1 = report violations in progress | 2 = print each + verbose: 1 + + # -- Execution tracing (replay/debugging; 0 = off, no overhead) -- + trace_level: 0 # 0 = disabled | 1 = default | 2 = full | 3 = debug + trace_sample_rate: 1 # capture every Nth iteration (1 = all) + trace_storage: "json" # "json" (human-readable) | "hdf5" (compressed, needs h5py) + trace_output: null # output path; null = auto-generate under output_dir + + # -- Early stop -- + # Stop at the first counterexample (fast pre-attack: total time then measures + # time-to-first-counterexample). + stop_on_first_violation: false + +# ───────────────────────────────────────────────────────────────────────────── +# Verification (Branch-and-Bound) +# ───────────────────────────────────────────────────────────────────────────── +verification: + bab: + # Sparse override: only keys that differ from BaBConfig defaults are set + # here; every other BaBConfig field is inherited from its dataclass default. + # The full, documented BaB surface lives in backend.yaml (backend.bab). + solver_tier: "dual_alpha_eta" # "lp" | "dual" | "dual_alpha" | "dual_alpha_eta" + max_depth: 8 # maximum BaB search-tree depth + max_nodes: 100 # maximum subproblems (nodes) to explore + dual: {} # Sparse DualConfig overrides for BaB dual tiers. + +# ───────────────────────────────────────────────────────────────────────────── +# Validation (verifier soundness + numerical-precision harness) +# ───────────────────────────────────────────────────────────────────────────── +validation: + # Solvers to validate: any of "gurobi" | "torchlp" | "dual" | "hybridz" | "auto". + solvers: ["gurobi", "torchlp"] + # Transfer-function modes to validate (e.g. "interval", "hybridz"). + tf_modes: ["interval"] + samples: 10 # concrete samples drawn per network for the soundness check + per_neuron_topk: 10 # per-neuron bounds: top-K widest neurons to cross-check + bounds_tolerance: "auto" # "auto" (dtype-derived) or a numeric tolerance + batch_sizes: null # null = auto; or a list of ints to sweep batch sizes diff --git a/act/pipeline/cli.py b/act/config/pipeline_cli.py similarity index 86% rename from act/pipeline/cli.py rename to act/config/pipeline_cli.py index 69d114b38..0c5915e85 100644 --- a/act/pipeline/cli.py +++ b/act/config/pipeline_cli.py @@ -20,7 +20,8 @@ import torch from act.util.cli_utils import add_device_args, initialize_from_args -from act.back_end.config import VALID_SOLVER_TIERS +from act.util.format_utils import rule +from act.config.config import VALID_SOLVER_TIERS logger = logging.getLogger(__name__) from act.front_end.specs import OutputSpec @@ -33,6 +34,7 @@ from act.front_end.model_synthesis import synthesize_models_from_specs from act.pipeline.fuzzing.actfuzzer import ACTFuzzer, FuzzingConfig from act.pipeline.verification.per_neuron_bounds import PerNeuronCheckConfig +from act.config.config import PipelineConfig _FUZZ_MUTATION_WEIGHT_KEYS = frozenset( @@ -169,13 +171,94 @@ def _collect_fuzzing_overrides(args: Any) -> dict[str, Any]: return overrides +_PIPELINE_BAB_ATTR_MAP: dict[str, str] = { + "solver_tier": "bab_solver_tier", + "max_depth": "bab_max_depth", + "max_nodes": "bab_max_nodes", + "branching_method": "bab_branching_method", + "bounding_method": "bab_bounding_method", + "bounding_order": "bab_bounding_order", + "sa_cooling_rate": "bab_sa_cooling_rate", + "frontier_cap": "bab_frontier_cap", + "input_split_fanout": "bab_input_split_fanout", + "provenance_enabled": "bab_provenance", +} +_PIPELINE_VAL_ATTR_MAP: dict[str, str] = { + "solvers": "solvers", + "tf_modes": "tf_modes", + "samples": "samples", + "per_neuron_topk": "per_neuron_topk", + "bounds_tolerance": "bounds_tolerance", + "batch_sizes": "batch_sizes", +} +# BaBConfig fields the pipeline CLI can override: the attr-map keys plus the two +# special-cased flags (--bab-per-class-alpha, --bab-no-incremental-start). Kept +# module-level so the CLI<->YAML parity checker can introspect the surface. +_PIPELINE_BAB_OVERRIDE_FIELDS: tuple[str, ...] = tuple(_PIPELINE_BAB_ATTR_MAP) +_PIPELINE_DUAL_OVERRIDE_FIELDS: tuple[str, ...] = ( + "per_class_alpha", + "incremental_start_enabled", +) + + +def _collect_pipeline_config_overrides(args: Any) -> dict[str, Any]: + overrides: dict[str, Any] = {} + for key, _flag, attr, _cast_fn in _FUZZ_OVERRIDE_SPEC: + value = getattr(args, attr, None) + if value is None: + continue + if key == "save_counterexamples": + value = False + elif key == "mutation_weights": + value = _parse_mutation_weights(value) + overrides[f"fuzz_{key}"] = value + + for key, attr in _PIPELINE_BAB_ATTR_MAP.items(): + value = getattr(args, attr, None) + if value is not None: + overrides[f"bab_{key}"] = value + if getattr(args, "bab_per_class_alpha", None) is not None: + overrides["dual_per_class_alpha"] = str(args.bab_per_class_alpha).lower() == "true" + if getattr(args, "bab_no_incremental_start", None) is not None: + overrides["dual_incremental_start_enabled"] = not args.bab_no_incremental_start + + for key, attr in _PIPELINE_VAL_ATTR_MAP.items(): + value = getattr(args, attr, None) + if value is not None: + overrides[f"val_{key}"] = value + return overrides + + +def _apply_pipeline_config_defaults(args: Any) -> PipelineConfig: + config = PipelineConfig.from_yaml(**_collect_pipeline_config_overrides(args)) + args.bab_solver_tier = config.bab.solver_tier + args.bab_max_depth = config.bab.max_depth + args.bab_max_nodes = config.bab.max_nodes + args.bab_branching_method = config.bab.branching_method + args.bab_bounding_method = config.bab.bounding_method + args.bab_bounding_order = config.bab.bounding_order + args.bab_sa_cooling_rate = config.bab.sa_cooling_rate + args.bab_frontier_cap = config.bab.frontier_cap + args.bab_input_split_fanout = config.bab.input_split_fanout + args.bab_per_class_alpha = "true" if config.dual.per_class_alpha else "false" + args.bab_no_incremental_start = not config.dual.incremental_start_enabled + args.bab_provenance = config.bab.provenance_enabled + args.solvers = config.validation.solvers + args.tf_modes = config.validation.tf_modes + args.samples = config.validation.samples + args.per_neuron_topk = config.validation.per_neuron_topk + args.bounds_tolerance = config.validation.bounds_tolerance + args.batch_sizes = config.validation.batch_sizes + return config + + def print_header(): """Print simple header.""" - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"ACT: Abstract Constraint Transformer") print(f"Inference-based whitebox fuzzing for neural network verification") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") # ============================================================================ @@ -185,14 +268,14 @@ def print_header(): def cmd_list_available(creator: str): """List available datasets/categories.""" - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"AVAILABLE DATA-MODEL PAIRS ({creator.upper()})") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") if creator == "vnnlib": categories = vnnlib_mapping.list_categories() print(f"VNNLIB Categories ({len(categories)}):") - print("-" * 80) + print(rule(80, "-")) for cat_name in sorted(categories): info = vnnlib_mapping.get_category_info(cat_name) print(f" {cat_name:30s} ({info['type']}) - {info['description']}") @@ -201,7 +284,7 @@ def cmd_list_available(creator: str): elif creator == "torchvision": datasets = sorted(tv_mapping.DATASET_MODEL_MAPPING.keys()) print(f"TorchVision Datasets ({len(datasets)}):") - print("-" * 80) + print(rule(80, "-")) for ds_name in datasets: info = tv_mapping.DATASET_MODEL_MAPPING[ds_name] models = info.get("models", []) @@ -211,20 +294,20 @@ def cmd_list_available(creator: str): f" └─ Models: {', '.join(models[:5])}{'...' if len(models) > 5 else ''}" ) - print(f"\n{'=' * 80}\n") + print(f"\n{rule()}\n") def cmd_search(query: str, creator: str): """Search for datasets/categories.""" - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"SEARCH RESULTS: '{query}' ({creator.upper()})") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") if creator == "vnnlib": matches = vnnlib_mapping.search_categories(query) if matches: print(f"Found {len(matches)} VNNLIB categories:") - print("-" * 80) + print(rule(80, "-")) for cat_name in sorted(matches): info = vnnlib_mapping.get_category_info(cat_name) print(f" {cat_name:30s} ({info['type']}) - {info['description']}") @@ -235,21 +318,21 @@ def cmd_search(query: str, creator: str): matches = tv_mapping.search_datasets(query) if matches: print(f"Found {len(matches)} TorchVision datasets:") - print("-" * 80) + print(rule(80, "-")) for ds_name in sorted(matches): info = tv_mapping.DATASET_MODEL_MAPPING[ds_name] print(f" {ds_name:30s} [{info.get('category', 'N/A')}]") else: print(f"No TorchVision datasets found for '{query}'") - print(f"\n{'=' * 80}\n") + print(f"\n{rule()}\n") def cmd_info(name: str, creator: str): """Show detailed information about dataset/category.""" - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"INFO: {name} ({creator.upper()})") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") if creator == "vnnlib": try: @@ -300,14 +383,14 @@ def cmd_info(name: str, creator: str): except ValueError as e: print(f"Error: {e}") - print(f"\n{'=' * 80}\n") + print(f"\n{rule()}\n") def cmd_download(name: str, creator: str): """Download dataset/category.""" - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"DOWNLOADING: {name} ({creator.upper()})") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") if creator == "vnnlib": try: @@ -356,9 +439,9 @@ def cmd_download(name: str, creator: str): else: print(f"✗ {name} + {model} - {result['message']}") - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"Downloaded {success_count}/{len(models)} model pairs") - print(f"{'=' * 80}") + print(f"{rule()}") except Exception as e: print(f"✗ Download error: {e}") @@ -367,9 +450,9 @@ def cmd_download(name: str, creator: str): def cmd_list_downloaded(creator: str): """List downloaded data-model pairs.""" - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"DOWNLOADED DATA-MODEL PAIRS ({creator.upper()})") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") if creator == "vnnlib": downloaded = vnnlib_loader.list_downloaded_pairs() @@ -383,7 +466,7 @@ def cmd_list_downloaded(creator: str): categories[cat].append(item) print(f"VNNLIB Downloads ({len(downloaded)} instances):") - print("-" * 80) + print(rule(80, "-")) for cat in sorted(categories.keys()): instances = categories[cat] print(f" {cat:30s} ({len(instances)} instances)") @@ -408,7 +491,7 @@ def cmd_list_downloaded(creator: str): datasets[ds].append(item["model"]) print(f"TorchVision Downloads ({len(downloaded)} pairs):") - print("-" * 80) + print(rule(80, "-")) for ds in sorted(datasets.keys()): models = datasets[ds] print(f" {ds:30s} ({len(models)} models)") @@ -420,7 +503,7 @@ def cmd_list_downloaded(creator: str): "Use --download --creator torchvision to download data-model pairs" ) - print(f"\n{'=' * 80}\n") + print(f"\n{rule()}\n") # ============================================================================ @@ -442,12 +525,13 @@ def cmd_fuzz(args): # Load configuration from YAML with CLI overrides. Unset CLI options remain # None sentinels and therefore do not clobber config.yaml values. overrides = _collect_fuzzing_overrides(args) - config = FuzzingConfig.from_yaml(**overrides) + pipeline_overrides = {f"fuzz_{key}": value for key, value in overrides.items()} + config = PipelineConfig.from_yaml(**pipeline_overrides).fuzzing # Create spec creator and load data-model pairs - print(f"{'=' * 80}") + print(f"{rule()}") print(f"STEP 1: Loading Data-Model Pairs") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") spec_results = [] initial_seeds = [] @@ -552,9 +636,9 @@ def cmd_fuzz(args): print(f"✓ Generated {len(spec_results)} spec result(s)\n") # Synthesize models - print(f"{'=' * 80}") + print(f"{rule()}") print(f"STEP 2: Model Synthesis") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") # Set strict mode for all VerifiableModel instances from act.front_end.verifiable_model import VerifiableModel @@ -577,9 +661,9 @@ def cmd_fuzz(args): print(f"✓ Synthesized {len(wrapped_models)} wrapped model(s)\n") # Extract initial seeds - print(f"{'=' * 80}") + print(f"{rule()}") print(f"STEP 3: Seed Extraction") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") # Single model only; mixing seeds across spec_results breaks SeedCorpus(torch.cat). _, _, _, labeled_tensors, _ = spec_results[0] @@ -592,9 +676,9 @@ def cmd_fuzz(args): print(f"✓ Extracted {len(initial_seeds)} initial seeds\n") # Run fuzzing on first model - print(f"{'=' * 80}") + print(f"{rule()}") print(f"STEP 4: Fuzzing") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") model_id = list(wrapped_models.keys())[0] wrapped_model = wrapped_models[model_id] @@ -609,15 +693,15 @@ def cmd_fuzz(args): report = fuzzer.fuzz() # Print final results - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"FUZZING COMPLETE") - print(f"{'=' * 80}") + print(f"{rule()}") print(f"Iterations: {report.total_iterations}") print(f"Time: {report.total_time:.1f}s") print(f"Counterexamples: {len(report.counterexamples)}") print(f"Coverage: {report.neuron_coverage:.2%}") print(f"Seeds explored: {report.seeds_explored}") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") if report.counterexamples and config.save_counterexamples: import os @@ -652,6 +736,11 @@ def cmd_fuzz(args): # ============================================================================ +def _effective_tf_modes(solver: str, requested_modes) -> list[str]: + modes = list(requested_modes or ["interval"]) + return ["hybridz"] if solver == "hybridz" else modes + + def _build_validator(args): from act.pipeline.verification.validate_verifier import VerificationValidator @@ -703,9 +792,24 @@ def _verify_and_validate_cell( from act.back_end.verifier import verify_once if args.validate_soundness: - results, facts = verify_once(net, collect_facts=True) + if solver == "hybridz": + results, facts = verify_once( + net, + collect_facts=True, + timelimit=getattr(args, "timeout", None), + hybridz_tolerance=1e-7, + ) + else: + results, facts = verify_once(net, collect_facts=True) else: - results = verify_once(net) + if solver == "hybridz": + results = verify_once( + net, + timelimit=getattr(args, "timeout", None), + hybridz_tolerance=1e-7, + ) + else: + results = verify_once(net) facts = None statuses = [r.status.name for r in results] print(f" {cell_label if cell_label is not None else tag}: {statuses}") @@ -733,10 +837,8 @@ def _run_vnnlib_verify(args) -> bool: ``synthesize_models_from_specs`` → ``TorchToACT`` → ``verify_once``. Single-mode per invocation, matching the ``act.back_end --verify`` CLI - contract: uses the first element of ``--tf-modes`` (default - ``"interval"``) and ``--solvers`` (default ``"torchlp"``). Multi-mode - sweeps are the caller's job — invoke once per (tf-mode, solver) cell. - Dual ignores ``--tf-modes`` because it's a backward Solver. + contract. Multi-mode sweeps are the caller's job. Dual ignores + ``--tf-modes``; the standalone HybridZ solver selects HybridzTF. """ from act.front_end.vnnlib_loader.create_specs import VNNLibSpecCreator from act.front_end.model_synthesis import synthesize_models_from_specs @@ -749,8 +851,8 @@ def _run_vnnlib_verify(args) -> bool: if not args.category: raise ValueError("--verify vnnlib requires --category (e.g. --category acasxu_2023)") - tf_mode = (args.tf_modes or ["interval"])[0] solver = (args.solvers or ["torchlp"])[0] + tf_mode = _effective_tf_modes(solver, args.tf_modes)[0] set_solver_mode(solver) if solver != "dual": @@ -878,29 +980,15 @@ def _run_bab_on_net(net, args, bab_first_sample_only: bool = False): returning a list of status strings. """ from act.back_end.bab.bab import verify_bab_batched - from act.back_end.config import BaBConfig from act.back_end.solver.solver_torchlp import TorchLPSolver from act.back_end.verifier import ( gather_input_spec_layers, seed_from_input_specs, ) - config = BaBConfig( - solver_tier=args.bab_solver_tier, - max_depth=args.bab_max_depth, - max_nodes=args.bab_max_nodes, - branching_method=getattr(args, "bab_branching_method", "random"), - bounding_method=getattr(args, "bab_bounding_method", "random"), - bounding_order=getattr(args, "bab_bounding_order", "depth_lb"), - sa_cooling_rate=getattr(args, "bab_sa_cooling_rate", 0.99), - frontier_cap=getattr(args, "bab_frontier_cap", 0), - input_split_fanout=getattr(args, "bab_input_split_fanout", 2), - per_class_alpha=( - str(getattr(args, "bab_per_class_alpha", "true")).lower() == "true" - ), - incremental_start_enabled=not getattr(args, "bab_no_incremental_start", False), - provenance_enabled=getattr(args, "bab_provenance", False), - ) + pipeline_config = PipelineConfig.from_yaml(**_collect_pipeline_config_overrides(args)) + config = pipeline_config.bab + dual_config = pipeline_config.dual budget = float(getattr(args, "timeout", 60.0) or 60.0) spec_layers = gather_input_spec_layers(net) @@ -914,6 +1002,7 @@ def _run_bab_on_net(net, args, bab_first_sample_only: bool = False): config=config, max_batch_size=None, time_budget_s=budget, + dual_config=dual_config, ) return result.status.name @@ -928,6 +1017,7 @@ def _run_bab_on_net(net, args, bab_first_sample_only: bool = False): config=config, max_batch_size=None, time_budget_s=budget, + dual_config=dual_config, ) statuses.append(result.status.name) return statuses[0] if bab_first_sample_only and statuses else statuses @@ -959,8 +1049,8 @@ def _run_torchvision_verify(args) -> bool: if not args.dataset: raise ValueError("--verify torchvision requires --dataset (e.g. --dataset MNIST)") - tf_mode = (args.tf_modes or ["interval"])[0] solver = (args.solvers or ["torchlp"])[0] + tf_mode = _effective_tf_modes(solver, args.tf_modes)[0] set_solver_mode(solver) if solver != "dual": @@ -1036,14 +1126,13 @@ def _run_netfactory_verify(args) -> bool: if "gurobi" in solvers and not is_gurobi_available(): logger.warning("Skipping gurobi solver: gurobipy is not available.") solvers = [s for s in solvers if s != "gurobi"] - tf_modes = args.tf_modes or ["interval"] batch_sizes = _resolve_batch_sizes(getattr(args, "batch_sizes", None)) per_neuron_config = _per_neuron_config(args) errors_seen = False for name in networks: for solver in solvers: - for tf_mode in tf_modes: + for tf_mode in _effective_tf_modes(solver, args.tf_modes): for batch_size in batch_sizes: try: set_solver_mode(solver) @@ -1116,10 +1205,10 @@ def cmd_verify(target: str, args): results = {} for test_name in tests_to_run: - print(f"\n{'=' * 80}") + print(f"\n{rule()}") if test_name == "act2torch": print(f"VERIFICATION TEST: ACT→PyTorch Conversion") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") try: model_factory.main() results[test_name] = "PASSED" @@ -1132,7 +1221,7 @@ def cmd_verify(target: str, args): elif test_name == "torch2act": print(f"VERIFICATION TEST: PyTorch→ACT Conversion") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") try: torch2act.main() results[test_name] = "PASSED" @@ -1145,7 +1234,7 @@ def cmd_verify(target: str, args): elif test_name == "netfactory": print(f"VERIFICATION TEST: ModelFactory → verify_once") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") try: validation_failed = _run_netfactory_verify(args) results[test_name] = "FAILED" if validation_failed else "PASSED" @@ -1158,7 +1247,7 @@ def cmd_verify(target: str, args): elif test_name == "vnnlib": print(f"VERIFICATION TEST: VNNLIB → VerifiableModel → verify_once") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") try: soundness_failed = _run_vnnlib_verify(args) results[test_name] = "FAILED" if soundness_failed else "PASSED" @@ -1171,7 +1260,7 @@ def cmd_verify(target: str, args): elif test_name == "torchvision": print(f"VERIFICATION TEST: TorchVision → VerifiableModel → verify_once") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") try: soundness_failed = _run_torchvision_verify(args) results[test_name] = "FAILED" if soundness_failed else "PASSED" @@ -1183,13 +1272,13 @@ def cmd_verify(target: str, args): results[test_name] = "FAILED" # Print summary - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"VERIFICATION TEST SUMMARY") - print(f"{'=' * 80}") + print(f"{rule()}") for test_name, result in results.items(): status = "✅" if result == "PASSED" else "❌" print(f" {status} {test_name:25s} {result}") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") # Exit with error if any test failed if any(r == "FAILED" for r in results.values()): @@ -1205,17 +1294,11 @@ def _resolve_batch_sizes(cli_value): if cli_value: return cli_value try: - import yaml - from act.util.path_config import get_project_root - cfg_path = ( - Path(get_project_root()) - / "act/back_end/examples/config_gen_act_net.yaml" - ) - if cfg_path.exists(): - cfg = yaml.safe_load(cfg_path.read_text()) or {} - yaml_val = (cfg.get("validate") or {}).get("batch_sizes") - if yaml_val: - return yaml_val + from act.config.config import BackendConfig + net_factory = BackendConfig.from_yaml().generation.net_factory + yaml_val = (net_factory.get("validate") or {}).get("batch_sizes") + if yaml_val: + return yaml_val except Exception as e: # Intentional: optional YAML override; missing/malformed files fall through to default [None]. logger.debug("suppressed: %s", e) @@ -1261,11 +1344,13 @@ def main(): # Single (tf, solver) per invocation; matrix sweeps by repeated calls. python -m act.pipeline --verify vnnlib --category acasxu_2023 --max-instances 3 --tf-modes interval --solvers torchlp python -m act.pipeline --verify vnnlib --category acasxu_2023 --max-instances 3 --tf-modes hybridz --solvers torchlp + python -m act.pipeline --verify vnnlib --category acasxu_2023 --max-instances 3 --solvers hybridz python -m act.pipeline --verify vnnlib --category acasxu_2023 --max-instances 3 --solvers dual # Run verifier on a TorchVision dataset-model pair end-to-end. python -m act.pipeline --verify torchvision --dataset MNIST --model simple_cnn --num-samples 2 --tf-modes interval --solvers torchlp python -m act.pipeline --verify torchvision --dataset MNIST --model simple_cnn --num-samples 2 --tf-modes hybridz --solvers torchlp + python -m act.pipeline --verify torchvision --dataset MNIST --model simple_cnn --num-samples 2 --solvers hybridz python -m act.pipeline --verify torchvision --dataset MNIST --model simple_cnn --num-samples 2 --solvers dual # Run unified two-level verifier validation after verification. @@ -1360,10 +1445,10 @@ def main(): bab_group.add_argument( "--bab-solver-tier", type=str, - default="dual_alpha_eta", + default=None, choices=list(VALID_SOLVER_TIERS), help=( - "BaB solver tier when --bab is set (default: dual_alpha_eta). " + "BaB solver tier when --bab is set (default: from config.yaml). " "'lp' uses the existing LP/MILP backend; 'dual' uses DualSolver " "single-pass; 'dual_alpha' adds Lagrange-relaxed lower-slope " "optimization; 'dual_alpha_eta' adds joint slope + split-constraint " @@ -1373,75 +1458,77 @@ def main(): bab_group.add_argument( "--bab-max-depth", type=int, - default=8, - help="Maximum BaB tree depth (default: 8)", + default=None, + help="Maximum BaB tree depth (default: from config.yaml)", ) bab_group.add_argument( "--bab-max-nodes", type=int, - default=100, - help="Maximum BaB nodes to expand (default: 100)", + default=None, + help="Maximum BaB nodes to expand (default: from config.yaml)", ) bab_group.add_argument( "--bab-branching-method", type=str, - default="random", + default=None, choices=["random", "babsr", "fsb", "gain", "width"], - help="BaB branching strategy when --bab is set (default: random)", + help="BaB branching strategy when --bab is set (default: from config.yaml)", ) bab_group.add_argument( "--bab-bounding-method", type=str, - default="random", + default=None, choices=["random", "topk"], help=( "Pool selection when subproblems exceed the batch size: 'random' or " - "'topk' (keep the top-k by depth + lower-bound). Default: random." + "'topk' (keep the top-k by depth + lower-bound). Default: from config.yaml." ), ) bab_group.add_argument( "--bab-bounding-order", type=str, - default="depth_lb", + default=None, choices=["depth_lb", "greedy", "sa"], - help="TopKBounding order policy (default: depth_lb)", + help="TopKBounding order policy (default: from config.yaml)", ) bab_group.add_argument( "--bab-sa-cooling-rate", type=float, - default=0.99, - help="Cooling rate for --bab-bounding-order sa (default: 0.99)", + default=None, + help="Cooling rate for --bab-bounding-order sa (default: from config.yaml)", ) bab_group.add_argument( "--bab-per-class-alpha", type=str, - default="true", + default=None, choices=["true", "false"], help=( "Per-spec α tensor (True; tighter bounds, M× memory) vs shared α " - "across specs (False; looser, 1× memory). Default: true." + "across specs (False; looser, 1× memory). Default: from config.yaml." ), ) bab_group.add_argument( "--bab-no-incremental-start", action="store_true", + default=None, help="Disable parent→child α/η incremental-start propagation (debugging / ablation).", ) bab_group.add_argument( "--bab-frontier-cap", type=int, - default=0, - help="Maximum pending BaB frontier leaves to retain; 0 disables eviction (default: 0)", + default=None, + help="Maximum pending BaB frontier leaves to retain; 0 disables eviction (default: from config.yaml)", ) bab_group.add_argument( "--bab-input-split-fanout", type=int, - default=2, - help="Uniform fanout for input splits; 2 preserves binary splitting (default: 2)", + default=None, + help="Uniform fanout for input splits; 2 preserves binary splitting (default: from config.yaml)", ) bab_group.add_argument( "--bab-provenance", action="store_true", + default=None, help="Enable node_id/parent_id provenance sidecar (requires --bab-bounding-method topk).", ) @@ -1457,7 +1544,10 @@ def main(): "--timeout", type=float, default=None, - help="Timeout in seconds (default: from config.yaml)", + help=( + "Time budget in seconds for fuzzing, BaB, or standalone HybridZ " + "verification (default: component configuration)" + ), ) fuzz_group.add_argument( "--output", @@ -1530,26 +1620,33 @@ def main(): validation_group.add_argument( "--solvers", nargs="+", - default=["gurobi", "torchlp"], - help="Solvers for Level 1 validation (default: gurobi torchlp)", + default=None, + help=( + "Verification solvers: gurobi, torchlp, hybridz, or dual " + "(default: from config.yaml)" + ), ) validation_group.add_argument( "--tf-modes", nargs="+", - default=["interval"], - help="Transfer function modes for Level 2 bounds validation: interval, hybridz, dual (default: interval)", + default=None, + help=( + "Transfer function modes for bounds propagation: interval or " + "hybridz (default: from config.yaml); standalone hybridz selects " + "HybridzTF and dual ignores this option" + ), ) validation_group.add_argument( "--input-samples", type=int, - default=10, + default=None, dest="samples", - help="Number of input samples for Level 2 bounds validation (default: 10)", + help="Number of input samples for Level 2 bounds validation (default: from config.yaml)", ) validation_group.add_argument( "--per-neuron-topk", type=int, - default=10, + default=None, metavar="K", help="Number of worst per-neuron violations to report (default: 10). " "The bounds check itself is zero-tolerance by default — any deviation " @@ -1558,7 +1655,7 @@ def main(): validation_group.add_argument( "--bounds-tolerance", type=str, - default="auto", + default=None, metavar="ABS[,REL]|auto", help="FP-noise floor for the per-neuron bounds check: violation iff " "gap > ABS + REL*max(|lb|,|ub|). Default 'auto' = 100 ulp of --dtype " @@ -1576,8 +1673,8 @@ def main(): metavar="B1,B2,...", help="Batch sizes to validate at, e.g. '1,4'. Use 'none' for the " "network's native batch (from JSON). When omitted, falls back to " - "the ``validate.batch_sizes`` list in config_gen_act_net.yaml, " - "then to ``[None]`` (native only).", + "the ``validate.batch_sizes`` list in act/config/gen_act_net.yaml, then " + "to ``[None]`` (native only).", ) validation_group.add_argument( "--ignore-errors", @@ -1598,6 +1695,19 @@ def main(): _add_fuzz_config_args(parser) args = parser.parse_args() + requested_tf_modes = args.tf_modes + + _apply_pipeline_config_defaults(args) + if ( + requested_tf_modes is not None + and "hybridz" in args.solvers + and list(requested_tf_modes) != ["hybridz"] + ): + logger.warning( + "--solvers hybridz requires the hybridz transformer; overriding " + "--tf-modes %s", + " ".join(requested_tf_modes), + ) # Initialize device manager from CLI arguments initialize_from_args(args) diff --git a/act/front_end/README.md b/act/front_end/README.md index 8bf8b9351..365652ea1 100644 --- a/act/front_end/README.md +++ b/act/front_end/README.md @@ -154,11 +154,11 @@ The CLI automatically determines whether a name refers to: ### Smart Downloads ```bash # TorchVision: Downloads dataset + ALL recommended models -python -m act.front_end.cli --download MNIST +python -m act.front_end --download MNIST # ✓ Downloads: MNIST dataset + simple_cnn, lenet5, resnet18, efficientnet_b0 # VNNLIB: Downloads category with ONNX + VNNLIB files -python -m act.front_end.cli --download acasxu_2023 +python -m act.front_end --download acasxu_2023 # ✓ Downloads: 45 ONNX models + 100s of VNNLIB properties ``` @@ -236,7 +236,6 @@ creator = get_creator('torchvision') # or 'vnnlib' ``` front_end/ ├── __main__.py # 🆕 Entry point: python -m act.front_end -├── cli.py # 🆕 Unified CLI with auto-detection ├── creator_registry.py # 🆕 Factory + auto-detection ├── spec_creator_base.py # Base interface ├── specs.py # InputSpec/OutputSpec diff --git a/act/front_end/__main__.py b/act/front_end/__main__.py index 9461ebef1..927b9440f 100644 --- a/act/front_end/__main__.py +++ b/act/front_end/__main__.py @@ -11,7 +11,7 @@ License: AGPLv3+ """ -from act.front_end.cli import main +from act.config.frontend_cli import main if __name__ == "__main__": main() diff --git a/act/front_end/bert_loader/create_specs.py b/act/front_end/bert_loader/create_specs.py index 85310bb13..6db33f905 100644 --- a/act/front_end/bert_loader/create_specs.py +++ b/act/front_end/bert_loader/create_specs.py @@ -63,9 +63,9 @@ def create_specs_for_data_model_pairs( num_samples: int = 1, split: str = "test", max_verify_length: int = 8, - epsilon: float = 0.1, - p_norm: float = 1.0, - perturbed_words: int = 1, + epsilon: float | None = 0.1, + p_norm: float | None = 1.0, + perturbed_words: int | None = 1, ) -> list[tuple[str, str, nn.Module, list[torch.Tensor], list[tuple[InputSpec, OutputSpec]]]]: """Create embedding-space specs for BERT datasets. @@ -85,6 +85,12 @@ def create_specs_for_data_model_pairs( """ if max_samples is not None: num_samples = max_samples + if epsilon is None: + epsilon = float(self.config.get("eps", 0.1)) + if p_norm is None: + p_norm = float(self.config.get("p", 1.0)) + if perturbed_words is None: + perturbed_words = int(self.config.get("perturbed_words", 1)) datasets = [find_bert_dataset_name(name) for name in (dataset_names or ["sst"])] models = model_names or ["embedding_classifier"] results: list[ diff --git a/act/front_end/creator_registry.py b/act/front_end/creator_registry.py index c0c82485c..d6d18a92c 100644 --- a/act/front_end/creator_registry.py +++ b/act/front_end/creator_registry.py @@ -12,6 +12,7 @@ import logging from typing import Dict, Tuple, Optional, List from act.front_end.spec_creator_base import BaseSpecCreator +from act.util.format_utils import rule logger = logging.getLogger(__name__) @@ -224,16 +225,16 @@ def detect_creator(name: str, explicit_creator: Optional[str] = None) -> Tuple[s if __name__ == "__main__": # Quick demo - print("="*80) + print(rule()) print("CREATOR REGISTRY DEMO") - print("="*80) + print(rule()) print("\nAvailable creators:", list_creators()) # Test auto-detection - print("\n" + "="*80) + print("\n" + rule()) print("AUTO-DETECTION TESTS") - print("="*80) + print(rule()) test_cases = [ "MNIST", diff --git a/act/front_end/model_synthesis.py b/act/front_end/model_synthesis.py index ccc438d91..40e556d64 100644 --- a/act/front_end/model_synthesis.py +++ b/act/front_end/model_synthesis.py @@ -1,3 +1,4 @@ +# pyright: reportCallIssue=false, reportArgumentType=false, reportOptionalMemberAccess=false, reportMissingTypeArgument=false, reportIndexIssue=false, reportAttributeAccessIssue=false, reportConstantRedefinition=false #===- act/front_end/model_synthesis.py - Model Synthesis Framework -----====# # ACT: Abstract Constraint Transformer # Copyright (C) 2025– ACT Team @@ -16,18 +17,19 @@ # Detect if running as script (not as module) and exit with helpful message if __name__ == "__main__" and __package__ is None: import sys - print("\n" + "="*80) + from act.util.format_utils import rule + print("\n" + rule()) print("⚠️ ERROR: Cannot run as script due to import conflicts!") print("Please run as a module instead:") print(" python -m act.front_end.model_synthesis") - print("="*80 + "\n") + print(rule() + "\n") sys.exit(1) import copy import torch import torch.fx as fx import torch.nn as nn -from typing import Dict, Any, List, Tuple +from typing import Dict, Any, List, Tuple, Optional # Import ACT components from act.front_end.specs import InputSpec, OutputSpec, InKind, OutKind @@ -38,6 +40,7 @@ OutputSpecLayer, VerifiableModel, ) +from act.util.format_utils import rule # ----------------------------------------------------------------------------- @@ -369,7 +372,11 @@ def synthesize_models_from_specs( # ----------------------------------------------------------------------------- # 4) Model synthesis main function # ----------------------------------------------------------------------------- -def model_synthesis(creator: str = 'torchvision') -> Dict[Tuple[str, str, str, str], nn.Module]: +def model_synthesis( + creator: str = 'torchvision', + spec_overrides: Optional[Dict[str, Any]] = None, + text_verification_overrides: Optional[Dict[str, Any]] = None, +) -> Dict[Tuple[str, str, str, str], nn.Module]: """ Main model synthesis function using new spec creators. @@ -377,7 +384,9 @@ def model_synthesis(creator: str = 'torchvision') -> Dict[Tuple[str, str, str, s or VNNLibSpecCreator, then synthesizes wrapped models directly. Args: - creator: Creator to use ('torchvision' or 'vnnlib'). Defaults to 'torchvision'. + creator: Creator to use ('torchvision', 'vnnlib', or 'bert'). Defaults to 'torchvision'. + spec_overrides: Runtime spec configuration overrides. + text_verification_overrides: Runtime text verification overrides for BERT. Returns: wrapped_models: Dict[(dataset, model, in_kind, out_kind), VerifiableModel] @@ -386,16 +395,19 @@ def model_synthesis(creator: str = 'torchvision') -> Dict[Tuple[str, str, str, s RuntimeError: If no spec creator can load data-model pairs or create specs NotImplementedError: If VNNLIB creator is requested (not yet implemented) """ - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"MODEL SYNTHESIS: Using New Spec Creators ({creator.upper()})") - print(f"{'='*80}") + print(f"{rule()}") # Select creator based on parameter if creator == 'vnnlib': from act.front_end.vnnlib_loader.create_specs import VNNLibSpecCreator print(f"\n📊 Attempting to use VNNLibSpecCreator...") - spec_creator = VNNLibSpecCreator(config_name="vnnlib_default") + spec_creator = VNNLibSpecCreator( + config_name="vnnlib_default", + config_dict=spec_overrides, + ) # Create specs for all downloaded VNNLIB instances # Use max_instances=3 to limit for testing (185 total instances available) @@ -409,7 +421,10 @@ def model_synthesis(creator: str = 'torchvision') -> Dict[Tuple[str, str, str, s from act.front_end.torchvision_loader.create_specs import TorchVisionSpecCreator print(f"\n📊 Attempting to use TorchVisionSpecCreator...") - spec_creator = TorchVisionSpecCreator(config_name="torchvision_classification") + spec_creator = TorchVisionSpecCreator( + config_name="torchvision_classification", + config_dict=spec_overrides, + ) # Create specs for all downloaded dataset-model pairs spec_results = spec_creator.create_specs_for_data_model_pairs( @@ -417,8 +432,28 @@ def model_synthesis(creator: str = 'torchvision') -> Dict[Tuple[str, str, str, s validate_shapes=True ) + elif creator == 'bert': + from act.front_end.bert_loader.create_specs import BertSpecCreator + from act.config.config import FrontEndConfig + + print(f"\n📊 Attempting to use BertSpecCreator...") + front_end_config = FrontEndConfig.from_yaml(**(text_verification_overrides or {})) + text_config = front_end_config.text_verification + creator_overrides: Dict[str, Any] = {} + if spec_overrides: + creator_overrides.update(spec_overrides) + creator_overrides.update(text_config) + spec_creator = BertSpecCreator(config_dict=creator_overrides) + spec_results = spec_creator.create_specs_for_data_model_pairs( + num_samples=1, + validate_shapes=True, + epsilon=float(text_config['eps']), + p_norm=float(text_config['p']), + perturbed_words=int(text_config['perturbed_words']), + ) + else: - raise ValueError(f"Unknown creator: {creator}. Use 'torchvision' or 'vnnlib'.") + raise ValueError(f"Unknown creator: {creator}. Use 'torchvision', 'vnnlib', or 'bert'.") # Validate results if not spec_results: @@ -467,9 +502,9 @@ def model_synthesis(creator: str = 'torchvision') -> Dict[Tuple[str, str, str, s ) # Print summary - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"SYNTHESIS COMPLETE") - print(f"{'='*80}") + print(f"{rule()}") print(f" • Wrapped models: {len(wrapped_models)}") # Count unique dataset-model pairs from model keys unique_pairs = set() diff --git a/act/front_end/spec_creator_base.py b/act/front_end/spec_creator_base.py index 7cdd3adb0..fc713cc0c 100644 --- a/act/front_end/spec_creator_base.py +++ b/act/front_end/spec_creator_base.py @@ -1,3 +1,4 @@ +# pyright: reportOptionalMemberAccess=false, reportOperatorIssue=false, reportOptionalOperand=false, reportMissingTypeArgument=false """ Base class for specification creators with shape validation. @@ -10,11 +11,10 @@ from dataclasses import dataclass from typing import List, Tuple, Dict, Any, Optional, Callable, Union import logging -import yaml import torch from act.front_end.specs import InputSpec, OutputSpec, InKind, OutKind -from act.util.path_config import get_spec_config_path, get_default_spec_config_path +from act.config.config import FrontEndConfig logger = logging.getLogger(__name__) @@ -235,7 +235,7 @@ def __init__( Initialize spec creator with configuration. Args: - config_name: Named config from configs/specs/ (e.g., 'torchvision_classification') + config_name: Named front-end spec config (e.g., 'torchvision_classification') config_dict: Runtime configuration overrides """ self.config = self._load_config(config_name) @@ -243,15 +243,9 @@ def __init__( self.config.update(config_dict) def _load_config(self, config_name: Optional[str] = None) -> Dict[str, Any]: - """Load configuration from YAML file""" + """Load a named spec configuration from the front-end config.""" try: - if config_name: - config_path = get_spec_config_path(config_name) - else: - config_path = get_default_spec_config_path() - - with open(config_path, 'r') as f: - return yaml.safe_load(f) + return FrontEndConfig.from_yaml().spec_config(config_name) except Exception as e: print(f"⚠️ Could not load config: {e}, using defaults") return self._get_default_config() diff --git a/act/front_end/torchvision_loader/cli.py b/act/front_end/torchvision_loader/cli.py index c4e8e4979..e2d48fc83 100644 --- a/act/front_end/torchvision_loader/cli.py +++ b/act/front_end/torchvision_loader/cli.py @@ -13,6 +13,7 @@ from typing import Optional from act.util.cli_utils import add_device_args, initialize_from_args +from act.util.format_utils import rule from act.front_end.torchvision_loader.data_model_mapping import ( DATASET_MODEL_MAPPING, @@ -41,9 +42,9 @@ def print_mapping_summary(category: Optional[str] = None): Args: category: If provided, only show datasets in this category """ - print("=" * 100) + print(rule(100)) print("TORCHVISION DATASET → MODEL MAPPING SUMMARY") - print("=" * 100) + print(rule(100)) categories_to_show = [category] if category else get_all_categories() @@ -52,9 +53,9 @@ def print_mapping_summary(category: Optional[str] = None): if not datasets: continue - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"{cat.upper()} ({len(datasets)} datasets)") - print('='*100) + print(rule(100)) for dataset in sorted(datasets): info = DATASET_MODEL_MAPPING[dataset] @@ -76,9 +77,9 @@ def print_preprocessing_summary(): - Resize requirements - Ready without preprocessing """ - print("=" * 100) + print(rule(100)) print("PREPROCESSING REQUIREMENTS SUMMARY") - print("=" * 100) + print(rule(100)) needs_grayscale_rgb = [] needs_resize = [] @@ -115,7 +116,7 @@ def print_preprocessing_summary(): else: print(f" (None - all require some preprocessing)") - print("=" * 100) + print(rule(100)) def print_dataset_detail(dataset_name: str): @@ -128,9 +129,9 @@ def print_dataset_detail(dataset_name: str): actual_name = find_dataset_name(dataset_name) info = get_dataset_info(actual_name) - print("=" * 100) + print(rule(100)) print(f"DATASET: {actual_name}") - print("=" * 100) + print(rule(100)) print(f"Category: {info['category']}") print(f"Input Size: {info['input_size']}") if info['num_classes']: @@ -154,7 +155,7 @@ def print_dataset_detail(dataset_name: str): print(f" {i:2d}. {model}") print(f"\nNotes:") print(f" {info['notes']}") - print("=" * 100) + print(rule(100)) def main(): @@ -348,9 +349,9 @@ def main(): print("\nNo downloaded dataset-model pairs found.") print("Use --download DATASET MODEL to download a pair.") else: - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"DOWNLOADED DATASET-MODEL PAIRS ({len(downloaded)})") - print(f"{'='*80}") + print(f"{rule()}") # Calculate total size total_size_bytes = sum(item.get('size_bytes', 0) for item in downloaded) @@ -366,9 +367,9 @@ def main(): if item['preprocessing_required']: print(f" Preprocessing: {', '.join(item['preprocessing_steps'])}") - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"Total Size: {total_size_formatted}") - print(f"{'='*80}") + print(f"{rule()}") elif args.load_torchvision: # Load a downloaded dataset-model pair @@ -377,14 +378,14 @@ def main(): split = "test" # Default split batch_size = args.batch_size - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"LOADING DATASET-MODEL PAIR") - print(f"{'='*80}") + print(f"{rule()}") print(f"Dataset: {dataset_name}") print(f"Model: {model_name}") print(f"Split: {split}") print(f"Batch Size: {batch_size}") - print(f"{'='*80}\n") + print(f"{rule()}\n") try: # Load the dataset and model @@ -423,9 +424,9 @@ def main(): for step in result['metadata']['preprocessing_steps']: print(f" • {step}") - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"✓ Load completed successfully!") - print(f"{'='*80}\n") + print(f"{rule()}\n") except FileNotFoundError as e: print(f"✗ Error: {e}") @@ -445,9 +446,9 @@ def main(): info = get_dataset_info(dataset_name) models = info['models'] - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"RECOMMENDED MODELS FOR: {dataset_name}") - print(f"{'='*100}") + print(f"{rule(100)}") print(f"Category: {info['category']}") print(f"Input Size: {info['input_size']}") if info['num_classes']: @@ -491,7 +492,7 @@ def main(): print(f" {i:2d}. {model}") print(f"\nNotes: {info['notes']}") - print(f"{'='*100}") + print(f"{rule(100)}") except ValueError as e: print(f"Error: {e}") @@ -520,9 +521,9 @@ def main(): 'preprocessing': get_preprocessing_transforms(dataset_name) }) - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"DATASETS COMPATIBLE WITH: {model_name}") - print(f"{'='*100}") + print(f"{rule(100)}") if not matching_datasets: print(f"\nNo datasets found that recommend '{model_name}'") @@ -543,7 +544,7 @@ def main(): for category in sorted(by_category.keys()): datasets = by_category[category] print(f"\n{category.upper()} ({len(datasets)} dataset{'s' if len(datasets) > 1 else ''}):") - print("-" * 100) + print(rule(100, "-")) for ds in sorted(datasets, key=lambda x: x['name']): print(f"\n {ds['name']}") @@ -565,18 +566,18 @@ def main(): else: print(f" Preprocessing: None required") - print(f"\n{'='*100}") + print(f"\n{rule(100)}") elif args.inference: # Test inference for a specific dataset-model pair dataset_name, model_name = args.inference split = args.inference_split - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"INFERENCE TEST: {dataset_name} + {model_name}") - print(f"{'='*80}") + print(f"{rule()}") print(f"Split: {split}") - print(f"{'='*80}\n") + print(f"{rule()}\n") try: result = model_inference_with_dataset( @@ -586,9 +587,9 @@ def main(): verbose=True ) - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"INFERENCE TEST RESULT") - print(f"{'='*80}") + print(f"{rule()}") print(f"Status: {result['status'].upper()}") if result['status'] == 'success': @@ -601,7 +602,7 @@ def main(): if 'num_samples' in result: print(f" Dataset samples: {result['num_samples']}") - print(f"{'='*80}") + print(f"{rule()}") except FileNotFoundError as e: print(f"\n✗ Error: Dataset-model pair not found locally") @@ -622,9 +623,9 @@ def main(): model_name = find_model_name(user_model) result = validate_dataset_model_compatibility(dataset_name, model_name) - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"COMPATIBILITY CHECK: {dataset_name} + {model_name}") - print(f"{'='*100}") + print(f"{rule(100)}") print(f"Compatible: {'✓ YES' if result['compatible'] else '✗ NO'}") if result['issues']: @@ -639,7 +640,7 @@ def main(): else: print(f"\nNo preprocessing required - direct compatibility!") - print(f"{'='*100}") + print(f"{rule(100)}") except ValueError as e: print(f"Error: {e}") @@ -649,9 +650,9 @@ def main(): preprocessing = get_preprocessing_transforms(dataset_name) info = get_dataset_info(dataset_name) - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"PREPROCESSING FOR: {dataset_name}") - print(f"{'='*100}") + print(f"{rule(100)}") print(f"Input Size: {info['input_size']}") if preprocessing: @@ -673,22 +674,22 @@ def main(): print(f" root='./data', transform=transform, download=True)") else: print(f"\nNo special preprocessing required!") - print(f"{'='*100}") + print(f"{rule(100)}") except ValueError as e: print(f"Error: {e}") else: # Default: show quick examples - print("\n" + "=" * 100) + print("\n" + rule(100)) print("TORCHVISION DOMAIN-SPECIFIC CLI") - print("=" * 100) + print(rule(100)) print("\nFor common operations, use the unified CLI:") print(" python -m act.front_end --list # List all datasets/categories") print(" python -m act.front_end --search mnist # Search across all") print(" python -m act.front_end --info CIFAR10 # Show dataset info") print(" python -m act.front_end --download MNIST # Download dataset + models") - print("\n" + "-" * 100) + print("\n" + rule(100, "-")) print("TorchVision-Specific Commands (use --help for full list):") print(" python -m act.front_end.torchvision_loader --category classification") print(" python -m act.front_end.torchvision_loader --dataset CIFAR10") @@ -699,7 +700,7 @@ def main(): print(" python -m act.front_end.torchvision_loader --models-for CIFAR10") print(" python -m act.front_end.torchvision_loader --datasets-for resnet18") print(" python -m act.front_end.torchvision_loader --inference MNIST resnet18") - print("=" * 100) + print(rule(100)) if __name__ == "__main__": diff --git a/act/front_end/torchvision_loader/data_model_loader.py b/act/front_end/torchvision_loader/data_model_loader.py index 36ba70e21..22e7132aa 100644 --- a/act/front_end/torchvision_loader/data_model_loader.py +++ b/act/front_end/torchvision_loader/data_model_loader.py @@ -18,7 +18,7 @@ # Import path configuration from act.util.path_config import get_torchvision_data_root -from act.util.format_utils import format_bytes as _format_size, dir_size as _get_directory_size +from act.util.format_utils import format_bytes as _format_size, dir_size as _get_directory_size, rule # Import from data_model_mapping from act.front_end.torchvision_loader.data_model_mapping import ( @@ -274,9 +274,9 @@ def download_dataset_model_pair( models_dir.mkdir(parents=True, exist_ok=True) downloaded_splits = [] - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"{'ADDING MODEL' if dataset_exists else 'DOWNLOADING'}: {dataset_name} + {model_name}") - print(f"{'='*80}") + print(f"{rule()}") print(f"Target: {dataset_dir}") if not dataset_exists: print(f"Split: {split}") @@ -388,9 +388,9 @@ def download_dataset_model_pair( total_size_formatted = _format_size(total_size_bytes) # Print summary - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"✓ DOWNLOAD COMPLETE") - print(f"{'='*80}") + print(f"{rule()}") print(f"Dataset: {raw_dir}") print(f"Model: {model_path}") print(f"Info: {info_path}") @@ -553,9 +553,9 @@ def load_dataset_model_pair( # Auto-download if not found if not dataset_dir.exists() or not info_path.exists(): if auto_download: - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"Dataset-model pair not found locally. Downloading...") - print(f"{'='*80}\n") + print(f"{rule()}\n") # Determine which split to download download_split = split if split in ['train', 'test'] else 'both' @@ -573,9 +573,9 @@ def load_dataset_model_pair( f"Failed to download dataset-model pair: {download_result['message']}" ) - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"Download completed. Proceeding to load...") - print(f"{'='*80}\n") + print(f"{rule()}\n") else: raise FileNotFoundError( f"Dataset directory not found: {dataset_dir}\n" @@ -593,9 +593,9 @@ def load_dataset_model_pair( with open(info_path, 'r') as f: metadata = json.load(f) - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"LOADING: {dataset_name} + {model_name}") - print(f"{'='*80}") + print(f"{rule()}") # Check if requested split was downloaded if split not in metadata['splits_downloaded']: @@ -705,9 +705,9 @@ def load_dataset_model_pair( print(f" Batch size: {batch_size}") print(f" Preprocessing: {'Yes' if metadata['preprocessing_required'] else 'No'}") - print(f"\n{'='*80}") + print(f"\n{rule()}") print(f"✓ LOADED SUCCESSFULLY") - print(f"{'='*80}") + print(f"{rule()}") return { 'dataset': dataset, @@ -879,9 +879,9 @@ def main(): Loads all pairs from data/torchvision/, performs inference on each sample, and reports success/failure statistics. """ - print("="*80) + print(rule()) print("DATASET-MODEL PAIR INFERENCE TESTING") - print("="*80) + print(rule()) # Get all downloaded pairs downloaded_pairs = list_downloaded_pairs() @@ -893,7 +893,7 @@ def main(): return print(f"\n📊 Found {len(downloaded_pairs)} downloaded pairs") - print(f"{'='*80}\n") + print(f"{rule()}\n") # Track statistics results = [] @@ -925,9 +925,9 @@ def main(): failed_pairs = total_pairs - successful_pairs # Print summary - print("="*80) + print(rule()) print("SUMMARY") - print("="*80) + print(rule()) print(f"Total pairs tested: {total_pairs}") print(f"Successful: {successful_pairs}") print(f"Failed: {failed_pairs}") @@ -956,7 +956,7 @@ def main(): for pair_key, reason in failure_reasons.items(): print(f" • {pair_key}: {reason}") - print("="*80) + print(rule()) if __name__ == "__main__": diff --git a/act/front_end/vnnlib_loader/category_mapping.py b/act/front_end/vnnlib_loader/category_mapping.py index a1429ee48..7a8eca193 100644 --- a/act/front_end/vnnlib_loader/category_mapping.py +++ b/act/front_end/vnnlib_loader/category_mapping.py @@ -11,6 +11,8 @@ from typing import Dict, List, Optional +from act.util.format_utils import rule + # VNN-COMP 2026 benchmarks: https://github.com/VNN-COMP/vnncomp2026_benchmarks # Each registry key equals the repo folder name; the loader resolves instances # under the "/2.0/" version subdirectory by default (override per entry @@ -495,18 +497,18 @@ def get_summary_statistics() -> Dict: if __name__ == "__main__": # Quick demo - print("="*80) + print(rule()) print("VNNLIB CATEGORY MAPPING") - print("="*80) + print(rule()) stats = get_summary_statistics() print(f"\nTotal Categories: {stats['total_categories']}") print(f"Category Types: {stats['total_types']}") print(f"Year Range: {stats['oldest_year']}-{stats['newest_year']}") - print("\n" + "="*80) + print("\n" + rule()) print("CATEGORIES BY TYPE") - print("="*80) + print(rule()) for cat_type in sorted(get_all_types()): categories = list_categories_by_type(cat_type) diff --git a/act/front_end/vnnlib_loader/cli.py b/act/front_end/vnnlib_loader/cli.py index 939e73e92..6781275de 100644 --- a/act/front_end/vnnlib_loader/cli.py +++ b/act/front_end/vnnlib_loader/cli.py @@ -13,6 +13,7 @@ from typing import Optional from act.util.cli_utils import add_device_args, initialize_from_args +from act.util.format_utils import rule from act.front_end.vnnlib_loader.category_mapping import ( CATEGORY_MAPPING, @@ -39,14 +40,14 @@ def print_category_list(category_type: Optional[str] = None): """ if category_type: categories = list_categories_by_type(category_type) - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"VNNLIB CATEGORIES - TYPE: {category_type}") - print(f"{'='*100}") + print(f"{rule(100)}") else: categories = list_categories() - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"ALL VNNLIB CATEGORIES ({len(categories)})") - print(f"{'='*100}") + print(f"{rule(100)}") if not categories: print(f"No categories found for type: {category_type}") @@ -65,7 +66,7 @@ def print_category_list(category_type: Optional[str] = None): for cat_type in sorted(by_type.keys()): items = by_type[cat_type] print(f"\n{cat_type.upper()} ({len(items)} categories)") - print('-' * 100) + print(rule(100, "-")) for cat_name, info in sorted(items, key=lambda x: x[0]): print(f" {cat_name:30s} ({info['year']}) - {info['description']}") @@ -82,9 +83,9 @@ def print_category_detail(category_name: str): actual_name = find_category_name(category_name) info = get_category_info(actual_name) - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"CATEGORY: {actual_name}") - print(f"{'='*100}") + print(f"{rule(100)}") print(f"Type: {info['type']}") print(f"Year: {info['year']}") print(f"Description: {info['description']}") @@ -97,7 +98,7 @@ def print_category_detail(category_name: str): print(f" • Input: {info['input_dim']}") print(f" • Output: {info['output_dim']}") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") except ValueError as e: print(f"Error: {e}") @@ -107,9 +108,9 @@ def print_summary_statistics(): """Print summary statistics about VNNLIB categories.""" stats = get_summary_statistics() - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"VNNLIB CATEGORIES SUMMARY") - print(f"{'='*100}") + print(f"{rule(100)}") print(f"Total Categories: {stats['total_categories']}") print(f"Category Types: {stats['total_types']}") print(f"Year Range: {stats['oldest_year']} - {stats['newest_year']}") @@ -118,7 +119,7 @@ def print_summary_statistics(): for cat_type, count in sorted(stats['categories_by_type'].items()): print(f" • {cat_type:30s}: {count:2d} categories") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") def main(): @@ -239,9 +240,9 @@ def main(): actual_name = find_category_name(category) info = get_category_info(actual_name) - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"DOWNLOADING VNNLIB CATEGORY: {actual_name}") - print(f"{'='*100}") + print(f"{rule(100)}") print(f"Type: {info['type']}") print(f"Description: {info['description']}") print(f"Year: {info['year']}") @@ -252,7 +253,7 @@ def main(): print(f"Force re-download: Yes") print(f"\nDownloading from VNN-COMP GitHub repository...") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") # Download the category result = download_vnnlib_category( @@ -261,9 +262,9 @@ def main(): ) if result['status'] == 'success': - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"✓ DOWNLOAD SUCCESSFUL") - print(f"{'='*100}") + print(f"{rule(100)}") print(f"Category: {actual_name}") print(f"Path: {result['category_path']}") print(f"Instances: {result['num_instances']}") @@ -271,27 +272,27 @@ def main(): print(f"ONNX models: {result['num_onnx_models']}") if 'num_vnnlib_specs' in result: print(f"VNNLIB specs: {result['num_vnnlib_specs']}") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") else: - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"⚠️ DOWNLOAD FAILED") - print(f"{'='*100}") + print(f"{rule(100)}") print(f"Error: {result.get('message', 'Unknown error')}") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") except ValueError as e: - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"⚠️ INVALID CATEGORY") - print(f"{'='*100}") + print(f"{rule(100)}") print(f"Error: {e}") print(f"\nUse --list to see available categories") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") except Exception as e: - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"⚠️ DOWNLOAD ERROR") - print(f"{'='*100}") + print(f"{rule(100)}") print(f"Error: {e}") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") elif args.list_downloads: # List downloaded categories @@ -311,9 +312,9 @@ def main(): by_category[cat] = [] by_category[cat].append(pair) - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"DOWNLOADED VNNLIB CATEGORIES ({len(by_category)})") - print(f"{'='*100}") + print(f"{rule(100)}") for category in sorted(by_category.keys()): instances = by_category[category] @@ -325,9 +326,9 @@ def main(): print(f" Description: {info.get('description', 'N/A')}") print(f" Path: {instances[0]['category_path']}") - print(f"\n{'='*100}") + print(f"\n{rule(100)}") print(f"Total instances: {len(downloaded)}") - print(f"{'='*100}\n") + print(f"{rule(100)}\n") except Exception as e: print(f"Error listing downloads: {e}") diff --git a/act/front_end/vnnlib_loader/data_model_loader.py b/act/front_end/vnnlib_loader/data_model_loader.py index ca5344440..a77662c19 100644 --- a/act/front_end/vnnlib_loader/data_model_loader.py +++ b/act/front_end/vnnlib_loader/data_model_loader.py @@ -8,7 +8,8 @@ # # Purpose: # Download, list, and load VNNLIB benchmarks from VNN-COMP repository. -# Mirrors torchvision/data_model_loader.py structure for unified interface. +# Repository base URL(s) are overridable via the ACT_VNNCOMP_REPO_URL env var +# (comma-separated); the default targets the VNN-COMP 2026 benchmark mirror. # #===---------------------------------------------------------------------===# @@ -16,6 +17,7 @@ from pathlib import Path from typing import Any, List, Dict, Optional, Tuple import ast +import os import logging import json import csv @@ -43,9 +45,10 @@ # VNN-COMP GitHub repository base URLs (try multiple sources) -VNNCOMP_REPO_URLS = [ - "https://raw.githubusercontent.com/VNN-COMP/vnncomp2026_benchmarks/main/benchmarks" -] +VNNCOMP_REPO_URLS = os.environ.get( + "ACT_VNNCOMP_REPO_URL", + "https://raw.githubusercontent.com/VNN-COMP/vnncomp2026_benchmarks/main/benchmarks", +).split(",") DEFAULT_BENCHMARK_VERSION = "2.0" diff --git a/act/pipeline/README.md b/act/pipeline/README.md index fde9f0640..f05ee5aee 100644 --- a/act/pipeline/README.md +++ b/act/pipeline/README.md @@ -14,8 +14,6 @@ The ACT Pipeline bridges the front-end data processing and back-end verification ``` act/pipeline/ -├── cli.py # Main pipeline CLI -├── __main__.py # Package entry point ├── verification/ # Verification utilities submodule │ ├── torch2act.py # Automatic PyTorch→ACT conversion │ ├── act2torch.py # ACT→PyTorch conversion utilities diff --git a/act/pipeline/__main__.py b/act/pipeline/__main__.py index 4805d94d3..05ffdc044 100644 --- a/act/pipeline/__main__.py +++ b/act/pipeline/__main__.py @@ -21,7 +21,7 @@ License: AGPLv3+ """ -from act.pipeline.cli import main +from act.config.pipeline_cli import main if __name__ == "__main__": main() diff --git a/act/pipeline/fuzzing/README.md b/act/pipeline/fuzzing/README.md index 8d78c50f8..a8500550c 100644 --- a/act/pipeline/fuzzing/README.md +++ b/act/pipeline/fuzzing/README.md @@ -67,7 +67,7 @@ initial_seeds = [] for _, _, _, labeled_tensors, _ in spec_results: initial_seeds.extend(labeled_tensors) -# Fuzz (loads config from config.yaml with optional overrides) +# Fuzz (loads config from act/config/pipeline.yaml with optional overrides) config = FuzzingConfig.from_yaml(max_iterations=5000, device="cuda") fuzzer = ACTFuzzer( wrapped_model=list(wrapped_models.values())[0], @@ -81,7 +81,7 @@ print(f"Found {len(report.counterexamples)} counterexamples") ## Configuration -Edit `config.yaml` to customize: +Edit `act/config/pipeline.yaml` to customize: ```yaml fuzzing: @@ -179,7 +179,7 @@ range / perturb_size = range / (range * perturb_scale) = 1 / perturb_scale ### Configuration -Set in `config.yaml`: +Set in `act/config/pipeline.yaml`: ```yaml perturb_mode: "adaptive_scalar" # Options: "adaptive_scalar", "adaptive_perdim", "fixed" perturb_scale: 0.1 # Fraction of range per perturbation (default: 0.1 = 10 steps) @@ -414,7 +414,7 @@ from pathlib import Path from act.pipeline.fuzzing import ACTFuzzer, FuzzingConfig from act.pipeline.fuzzing.trace_reader import create_reader -# Enable tracing during fuzzing (loads config.yaml with overrides) +# Enable tracing during fuzzing (loads act/config/pipeline.yaml with overrides) config = FuzzingConfig.from_yaml( max_iterations=5000, trace_level=1, # Enable basic tracing diff --git a/act/pipeline/fuzzing/actfuzzer.py b/act/pipeline/fuzzing/actfuzzer.py index 88e90c536..c228ed9c3 100644 --- a/act/pipeline/fuzzing/actfuzzer.py +++ b/act/pipeline/fuzzing/actfuzzer.py @@ -10,7 +10,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import List, Dict, Optional, Tuple, Any +from typing import List, Dict, Optional, Tuple, Any, cast import os import time import json @@ -28,6 +28,7 @@ from act.pipeline.fuzzing.checker import Counterexample, PropertyChecker from act.util.path_config import get_pipeline_log_dir, get_project_root from act.util.device_manager import get_default_device +from act.util.format_utils import rule @dataclass @@ -112,56 +113,25 @@ def __post_init__(self): def from_yaml( cls, config_path: Optional[str | Path] = None, **overrides ) -> "FuzzingConfig": - """ - Load FuzzingConfig from YAML file with optional overrides. - - Args: - config_path: Path to config YAML file (default: act/pipeline/fuzzing/config.yaml) - **overrides: Keyword arguments to override YAML values + """Load FuzzingConfig from the pipeline YAML with optional overrides. - Returns: - FuzzingConfig instance with merged configuration - - Example: - >>> # Load defaults from YAML - >>> config = FuzzingConfig.from_yaml() - >>> - >>> # Override specific values - >>> config = FuzzingConfig.from_yaml( - ... timeout_seconds=60.0, - ... max_iterations=1000 - ... ) + The YAML file is read by act.config.config (the single reader of the + config YAML files); this only merges overrides and constructs. """ - # Default config path - if config_path is None: - config_path = Path(get_project_root()) / "act/pipeline/fuzzing/config.yaml" - else: - config_path = Path(config_path) + from act.config.config import read_fuzzing_section - # Verify config file exists - if not config_path.exists(): - raise FileNotFoundError( - f"Configuration file not found: {config_path}\n" - f"Expected location: act/pipeline/fuzzing/config.yaml" - ) - - # Load YAML - with open(config_path) as f: - yaml_data = yaml.safe_load(f) - yaml_config = yaml_data["fuzzing"] - - # Merge YAML config with overrides (overrides take precedence) - merged_config = {**yaml_config, **overrides} + return cls.from_mapping(read_fuzzing_section(config_path), **overrides) - # Convert output_dir string to Path if present + @classmethod + def from_mapping(cls, section: Dict[str, Any], **overrides) -> "FuzzingConfig": + """Build from an already-parsed ``fuzzing`` mapping (no file I/O).""" + merged_config = {**section, **overrides} if "output_dir" in merged_config and isinstance( merged_config["output_dir"], str ): merged_config["output_dir"] = ( Path(get_pipeline_log_dir()) / merged_config["output_dir"] ) - - # Create FuzzingConfig instance return cls(**merged_config) @@ -277,8 +247,8 @@ def __init__( self.model = wrapped_model.to(self.device) # Extract specs for MutationEngine (projection) and PropertyChecker (violation detection). - self.input_spec = self._extract_spec(InputSpecLayer) - self.output_spec = self._extract_spec(OutputSpecLayer) + self.input_spec = cast(Optional[InputSpec], self._extract_spec(InputSpecLayer)) + self.output_spec = cast(Optional[OutputSpec], self._extract_spec(OutputSpecLayer)) # Batch size is determined by model synthesis (number of VNNLib instances). self.batch_size = ( @@ -354,7 +324,7 @@ def _extract_spec(self, layer_type) -> Optional[InputSpec | OutputSpec]: """Extract spec from wrapper layer by type.""" for layer in self.model.children(): if isinstance(layer, layer_type): - return layer.spec + return cast(InputSpec | OutputSpec, cast(object, layer.spec)) return None def fuzz(self) -> FuzzingReport: @@ -364,10 +334,10 @@ def fuzz(self) -> FuzzingReport: Returns: FuzzingReport with counterexamples and statistics """ - print(f"{'=' * 80}") + print(f"{rule()}") print(f"ACT: Abstract Constraint Transformer") print(f"Inference-based whitebox fuzzing for neural network verification") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") batch_size = self.batch_size @@ -494,10 +464,10 @@ def _fuzz_iteration(self, start_iteration: int, batch_size: int): coverage=coverage, coverage_delta=global_delta / batch_size, energy=float(energies[i]), - seed_id=int(seeds.id[i].item()), + seed_id=str(int(seeds.id[i].item())), input_before=seeds.tensor[i : i + 1], input_after=inputs[i : i + 1], - parent_id=int(seeds.parent_id[i].item()), + parent_id=str(int(seeds.parent_id[i].item())), depth=int(seeds.depth[i].item()), activations=activations, gradients=gradients, @@ -562,7 +532,7 @@ def _generate_report(self) -> FuzzingReport: ) # Print summary - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"🎉 ACTFuzzer completed in {total_time:.1f}s") print(f" Iterations: {report.total_iterations}") print(f" Counterexamples: {len(report.counterexamples)}") @@ -575,7 +545,7 @@ def _generate_report(self) -> FuzzingReport: [f"{ln}[{i}]" for (ln, i) in report.never_activated_neurons[:10]] ) print(f" Never-activated sample: {sample_str}") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") if self.config.save_counterexamples and report.counterexamples: report.save(self.config.output_dir) diff --git a/act/pipeline/fuzzing/config.yaml b/act/pipeline/fuzzing/config.yaml deleted file mode 100644 index b4d4d407f..000000000 --- a/act/pipeline/fuzzing/config.yaml +++ /dev/null @@ -1,60 +0,0 @@ -# ACTFuzzer Configuration - -fuzzing: - # Execution parameters - max_iterations: 10000 - timeout_seconds: 3600.0 # 1 hour - - coverage_strategy: "GlobalCov" # "BestInputCov: Best-input coverage" or "GlobalCov: global coverage" - activation_threshold: 0.0001 # Neuron activation threshold - - # Mutation strategy weights (must sum to 1.0) - mutation_weights: - gradient: 0 # FGSM-style gradient-guided - pgd: 0.4 # PGD-style gradient-guided (iterative; opt-in) - activation: 0.3 # Activation-guided (DeepXplore) - boundary: 0.2 # Boundary exploration - random: 0.1 # Random baseline - - # Mutation perturbation size configuration - # ========================================= - # NOTE: We use "perturb_size" (not "epsilon") to avoid confusion with InputSpec.eps - # - InputSpec.eps: L∞ radius constraint (defines boundaries) - # - perturb_size: Mutation perturbation magnitude (defines exploration granularity) - # - # perturb_mode: How to compute mutation perturbation sizes - # - "adaptive_scalar": Single perturb_size from mean(ub-lb) * perturb_scale - # Best for uniform ranges (e.g., VNNLib BOX constraints) - # - "adaptive_perdim": Per-dimension perturb_size from (ub-lb) * perturb_scale - # Best for non-uniform ranges (different feature scales) - # - "fixed": Legacy hardcoded values (0.01 for gradient/activation, 0.005 for boundary/random) - # - # perturb_scale: Fraction of feasible range each mutation perturbation covers - # - Interpretation: steps_to_traverse = 1 / perturb_scale - # - Calculation: range / perturb_size = range / (range * scale) = 1 / scale - # - Examples: - # * 0.1 → 10% per perturbation → ~10 steps to traverse from lb to ub - # * 0.2 → 20% per perturbation → ~5 steps to traverse from lb to ub - # * 0.05 → 5% per perturbation → ~20 steps to traverse from lb to ub - # - # Recommended values: - # - perturb_scale=0.1 (default): Balanced exploration (10 steps) - # - perturb_scale=0.05: Fine-grained exploration (20 steps) - # - perturb_scale=0.2: Coarse exploration (5 steps) - perturb_mode: "adaptive_scalar" - perturb_scale: 0.1 - - # Seed selection - seed_selection_strategy: "energy" # or "random" - - # Output - save_counterexamples: true - output_dir: "fuzzing_results" - report_interval: 500 # Print progress every N iterations - verbose: 1 # 0=silent, 1=report violations in progress only, 2=print each violation immediately - - # Tracing configuration - trace_level: 0 # 0=disabled, 1=default, 2=full, 3=debug - trace_sample_rate: 1 # Capture every Nth iteration (1=all iterations) - trace_storage: "json" # "json" or "hdf5" - trace_output: null # Auto-generate path if null diff --git a/act/pipeline/fuzzing/mutations.py b/act/pipeline/fuzzing/mutations.py index d6dba9492..7728afa50 100644 --- a/act/pipeline/fuzzing/mutations.py +++ b/act/pipeline/fuzzing/mutations.py @@ -60,7 +60,7 @@ ### Configuration -Set in `act/pipeline/fuzzing/config.yaml`: +Set in `act/config/pipeline.yaml`: ```yaml perturb_mode: "adaptive_scalar" # Options: "adaptive_scalar", "adaptive_perdim", "fixed" perturb_scale: 0.1 # Fraction of range per step (default: 0.1 = 10 steps) @@ -72,7 +72,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Dict, List, Optional, Union, TYPE_CHECKING +from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING import torch import torch.nn as nn import torch.nn.functional as F @@ -93,7 +93,7 @@ def mutate(self, input_tensor: torch.Tensor, model: nn.Module, activations: Optional[Dict[str, torch.Tensor]] = None, - label: Optional[int] = None + label: Optional[torch.Tensor] = None ) -> torch.Tensor: """ Apply mutation to input tensor. @@ -246,8 +246,9 @@ def mutate(self, input_tensor, model, activations=None, label=None): # Loss selection based on label availability # label is a Tensor[B] int64, -1 = no label - has_labels = label is not None and (label >= 0).any() - if has_labels: + label_tensor = label if isinstance(label, torch.Tensor) else None + has_labels = label_tensor is not None and bool((label_tensor >= 0).any().item()) + if has_labels and label_tensor is not None: # CW-style margin loss: maximize (max_{j != target} z_j - z_target). # More directed than cross-entropy for TOP1/MARGIN robustness - its # gradient does not vanish once the target probability is small, so @@ -257,7 +258,7 @@ def mutate(self, input_tensor, model, activations=None, label=None): f"Model output should have batch dimension, got shape {output.shape}. " f"Ensure model outputs include batch dimension." ) - target = label.clamp(min=0).to(output.device).view(-1, 1) + target = label_tensor.clamp(min=0).to(output.device).view(-1, 1) tgt_logit = output.gather(1, target).squeeze(1) other = output.scatter(1, target, float("-inf")) loss = (other.max(dim=1).values - tgt_logit).sum() @@ -500,7 +501,7 @@ def _compute_adaptive_perturb_size(self) -> Union[float, torch.Tensor]: # The feasible region is all points x such that ||x - center||_∞ <= eps assert self.input_spec.center is not None and self.input_spec.eps is not None center = self.input_spec.center - eps = self.input_spec.eps.to(device=center.device, dtype=center.dtype) + eps = torch.as_tensor(self.input_spec.eps, device=center.device, dtype=center.dtype) lb = center - eps ub = center + eps elif self.input_spec.kind == InKind.LP_EMBEDDING: @@ -602,7 +603,7 @@ def mutate(self, seeds: 'FuzzingSeed') -> torch.Tensor: if self.input_spec.kind == InKind.LINF_BALL: assert self.input_spec.eps is not None _orig = seeds.original_tensor.to(self.device) - _eps = self.input_spec.eps.to(device=_orig.device, dtype=_orig.dtype) + _eps = torch.as_tensor(self.input_spec.eps, device=_orig.device, dtype=_orig.dtype) _lb = _orig - _eps _ub = _orig + _eps else: @@ -708,7 +709,7 @@ def _project(self, tensor: torch.Tensor, seeds: 'Optional[FuzzingSeed]' = None) # Use original_tensor as center to maintain L∞ distance from original center = seeds.original_tensor.to(tensor.device) - eps = eps.to(device=tensor.device, dtype=tensor.dtype) + eps = torch.as_tensor(eps, device=tensor.device, dtype=tensor.dtype) delta = tensor - center delta = torch.clamp(delta, -eps, eps) @@ -733,7 +734,7 @@ def get_last_loss(self) -> Optional[float]: """Get loss value from last mutation (Level 3 tracing only).""" return self.last_loss - def get_stats(self) -> Dict: + def get_stats(self) -> Dict[str, Any]: """Get mutation statistics.""" perturb_size_info = {} for strategy_name, strategy in self.strategies.items(): diff --git a/act/pipeline/fuzzing/trace_reader.py b/act/pipeline/fuzzing/trace_reader.py index 15838105d..01108d57a 100644 --- a/act/pipeline/fuzzing/trace_reader.py +++ b/act/pipeline/fuzzing/trace_reader.py @@ -45,6 +45,8 @@ import sys import torch +from act.util.format_utils import rule + class TraceReader: """Base class for reading trace files.""" @@ -234,9 +236,9 @@ def print_summary(reader: TraceReader): """Print trace summary statistics.""" summary = reader.get_summary() - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"TRACE SUMMARY: {reader.path.name}") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") print(f"📊 General Statistics:") print(f" Total traces: {summary['total_traces']}") @@ -257,14 +259,14 @@ def print_summary(reader: TraceReader): pct = 100 * count / total if total > 0 else 0 print(f" {strat:15s}: {count:5d} ({pct:5.1f}%)") - print(f"\n{'=' * 80}\n") + print(f"\n{rule()}\n") def print_trace_detail(trace: Dict[str, Any], idx: int): """Print detailed information about a single trace.""" - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"TRACE #{idx} (Iteration {trace.get('iteration', '?')})") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") # Basic info print(f"⏱️ Timestamp: {trace.get('timestamp', 'N/A')}") @@ -320,7 +322,7 @@ def print_trace_detail(trace: Dict[str, Any], idx: int): if "loss_value" in trace: print(f"\n📉 Loss: {trace['loss_value']:.6f}") - print(f"\n{'=' * 80}\n") + print(f"\n{rule()}\n") # ============================================================================ @@ -418,7 +420,7 @@ def main(): print( f"\n{'Idx':>5s} {'Iter':>6s} {'Strategy':>12s} {'Coverage':>10s} {'Violation':>10s}" ) - print(f"{'-' * 50}") + print(f"{rule(50, "-")}") for i in range(start, min(end, len(reader))): trace = reader[i] print( @@ -564,9 +566,9 @@ def show_trace_detail(self, trace_idx: int) -> None: trace = self.traces[trace_idx] - print(f"\n{'=' * 60}") + print(f"\n{rule(60)}") print(f"Trace #{trace_idx} Details") - print(f"{'=' * 60}") + print(f"{rule(60)}") print(f"Iteration: {trace.get('iteration', 'N/A')}") print(f"Mutation Strategy: {trace.get('mutation_strategy', 'N/A')}") print(f"Coverage: {trace.get('coverage', 0):.2%}", end="") diff --git a/act/pipeline/fuzzing/tracer.py b/act/pipeline/fuzzing/tracer.py index 098d753ca..01f54d769 100644 --- a/act/pipeline/fuzzing/tracer.py +++ b/act/pipeline/fuzzing/tracer.py @@ -20,6 +20,7 @@ from act.pipeline.fuzzing.trace_storage import create_storage, TraceStorage from act.pipeline.fuzzing.checker import Counterexample +from act.util.format_utils import rule class ExecutionTracer: @@ -276,14 +277,14 @@ def close(self): """Finalize tracing and close storage.""" # Print statistics stats = self.get_stats() - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"📊 Tracing Statistics") - print(f"{'=' * 80}") + print(f"{rule()}") print(f"Level: {stats['level']}") print(f"Traces captured: {stats['traces_captured']}") print(f"Sample rate: 1/{stats['sample_rate']}") print(f"Output: {os.path.relpath(stats['output_path'])}") - print(f"{'=' * 80}\n") + print(f"{rule()}\n") # Close storage (waits for async writes to complete) self.storage.close() diff --git a/act/pipeline/verification/act2torch.py b/act/pipeline/verification/act2torch.py index 12fa8229e..4bd0f0abf 100644 --- a/act/pipeline/verification/act2torch.py +++ b/act/pipeline/verification/act2torch.py @@ -9,8 +9,8 @@ # # ACT → PyTorch Converter (Schema-Driven) # ======================================= -# REGISTRY (layer_schema.py) validates params; _ACT_TO_TORCH (below) maps -# LayerKind → torch.nn.Module for restoration. +# REGISTRY (layer_schema.py) validates params; ACT_TO_TORCH maps LayerKind → +# torch.nn.Module for restoration. # # Layer Routing: # INPUT → InputLayer @@ -29,88 +29,11 @@ import logging from act.back_end.core import Net, Layer -from act.back_end.layer_schema import LayerKind, REGISTRY +from act.back_end.layer_schema import ACT_TO_TORCH, LayerKind, REGISTRY from act.util.device_manager import get_default_dtype, get_default_device logger = logging.getLogger(__name__) -class _ErfModule(nn.Module): - def forward(self, x: torch.Tensor) -> torch.Tensor: - return torch.erf(x) - - -class _SqrtModule(nn.Module): - def forward(self, x: torch.Tensor) -> torch.Tensor: - return torch.sqrt(torch.clamp(x, min=0.0)) - - -class _QuantizeModule(nn.Module): - def __init__(self, scale=None, zero_point=None, qmin=0, qmax=255): - super().__init__() - self.register_buffer("scale", torch.as_tensor(1.0 if scale is None else scale)) - self.register_buffer("zero_point", torch.as_tensor(0 if zero_point is None else zero_point)) - self.qmin = float(qmin) - self.qmax = float(qmax) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - scale = self.scale.to(device=x.device, dtype=x.dtype) - zp = self.zero_point.to(device=x.device, dtype=x.dtype) - return scale * torch.clamp(torch.round(x / scale), min=self.qmin - zp, max=self.qmax - zp) - - -class _SinModule(nn.Module): - def forward(self, x: torch.Tensor) -> torch.Tensor: - return torch.sin(x) - - -class _CosModule(nn.Module): - def forward(self, x: torch.Tensor) -> torch.Tensor: - return torch.cos(x) - - -# ACT LayerKind → PyTorch nn.Module path. -# Layers not listed are skipped during restoration (wrapper, graph ops, functional-only). -_ACT_TO_TORCH = { - LayerKind.DENSE.value: nn.Linear, - LayerKind.CONV1D.value: nn.Conv1d, - LayerKind.CONV2D.value: nn.Conv2d, - LayerKind.CONV3D.value: nn.Conv3d, - LayerKind.CONVTRANSPOSE2D.value: nn.ConvTranspose2d, - LayerKind.MAXPOOL1D.value: nn.MaxPool1d, - LayerKind.MAXPOOL2D.value: nn.MaxPool2d, - LayerKind.MAXPOOL3D.value: nn.MaxPool3d, - LayerKind.AVGPOOL1D.value: nn.AvgPool1d, - LayerKind.AVGPOOL2D.value: nn.AvgPool2d, - LayerKind.AVGPOOL3D.value: nn.AvgPool3d, - LayerKind.ADAPTIVEAVGPOOL2D.value: nn.AdaptiveAvgPool2d, - LayerKind.RELU.value: nn.ReLU, - LayerKind.LRELU.value: nn.LeakyReLU, - LayerKind.PRELU.value: nn.PReLU, - LayerKind.SIGMOID.value: nn.Sigmoid, - LayerKind.TANH.value: nn.Tanh, - LayerKind.ERF.value: _ErfModule, - LayerKind.SQRT.value: _SqrtModule, - LayerKind.SIN.value: _SinModule, - LayerKind.COS.value: _CosModule, - LayerKind.QUANTIZE.value: _QuantizeModule, - LayerKind.SOFTPLUS.value: nn.Softplus, - LayerKind.SILU.value: nn.SiLU, - LayerKind.GELU.value: nn.GELU, - LayerKind.RELU6.value: nn.ReLU6, - LayerKind.HARDTANH.value: nn.Hardtanh, - LayerKind.HARDSIGMOID.value: nn.Hardsigmoid, - LayerKind.HARDSWISH.value: nn.Hardswish, - LayerKind.MISH.value: nn.Mish, - LayerKind.SOFTSIGN.value: nn.Softsign, - LayerKind.FLATTEN.value: nn.Flatten, - LayerKind.EMBEDDING.value: nn.Embedding, - LayerKind.RNN.value: nn.RNN, - LayerKind.GRU.value: nn.GRU, - LayerKind.LSTM.value: nn.LSTM, - LayerKind.SOFTMAX.value: nn.Softmax, - LayerKind.MHA.value: nn.MultiheadAttention, -} - def _axis_shift(input_shape: Any, x: torch.Tensor) -> int: """Offset to map a stored slice/gather axis onto the live tensor's axes. @@ -1105,7 +1028,7 @@ def _restore_batchnorm(self, scale_layer: Layer) -> Optional[nn.Module]: return bn def _build_from_schema(self, act_layer: Layer) -> Optional[nn.Module]: - """Build PyTorch module from REGISTRY params + _ACT_TO_TORCH mapping.""" + """Build PyTorch module from REGISTRY params + ACT_TO_TORCH mapping.""" kind = act_layer.kind params = act_layer.params @@ -1119,7 +1042,7 @@ def _build_from_schema(self, act_layer: Layer) -> Optional[nn.Module]: if kind in (LayerKind.RNN.value, LayerKind.GRU.value, LayerKind.LSTM.value): return self._build_rnn_family(act_layer) - cls = _ACT_TO_TORCH.get(kind) + cls = ACT_TO_TORCH.get(kind) if cls is None: if "requires_graph_restoration" in spec.get("params_optional", []): logger.warning( @@ -1129,7 +1052,7 @@ def _build_from_schema(self, act_layer: Layer) -> Optional[nn.Module]: return None if kind == LayerKind.QUANTIZE.value: - return _QuantizeModule( + return cls( scale=params.get("scale"), zero_point=params.get("zero_point"), qmin=int(cast(Any, params.get("qmin", 0))), @@ -1217,7 +1140,7 @@ def _build_rnn_family(self, act_layer: Layer) -> nn.Module: if kind == LayerKind.RNN.value: ctor_kwargs["nonlinearity"] = params.get("nonlinearity", "tanh") - rnn = _ACT_TO_TORCH[kind](**ctor_kwargs) + rnn = ACT_TO_TORCH[kind](**ctor_kwargs) target_dtype = next(rnn.parameters()).dtype sd = {k: v.detach().clone().to(dtype=target_dtype) for k, v in params.items() if isinstance(v, torch.Tensor)} diff --git a/act/pipeline/verification/model_factory.py b/act/pipeline/verification/model_factory.py index c63cf9e08..1cf65bc33 100644 --- a/act/pipeline/verification/model_factory.py +++ b/act/pipeline/verification/model_factory.py @@ -39,6 +39,7 @@ from act.pipeline.verification.act2torch import ACTToTorch from act.util.device_manager import get_default_dtype, get_default_device from act.util.path_config import get_examples_nets_dir +from act.util.format_utils import rule logger = logging.getLogger(__name__) @@ -337,18 +338,18 @@ def main(): factory = ModelFactory() - print("=" * 80) + print(rule()) print("PyTorch Model Factory - Spec-Free Verification Testing") - print("=" * 80) + print(rule()) all_passed = True total_tests = 0 passed_tests = 0 for name in factory.list_networks(): - print(f"\n{'=' * 80}") + print(f"\n{rule()}") print(f"Network: {name}") - print("=" * 80) + print(rule()) # Get network info info = factory.get_network_info(name) @@ -366,7 +367,7 @@ def main(): for test_case in test_cases: print(f"\n📊 Test Case: {test_case}") - print("-" * 80) + print(rule(80, "-")) try: # Generate strategic input @@ -423,7 +424,7 @@ def main(): all_passed = False # Print summary - print("\n" + "=" * 80) + print("\n" + rule()) print(f"📊 Verification Test Summary:") print(f" Total tests: {total_tests}") print(f" ✅ Passed: {passed_tests}") @@ -431,13 +432,13 @@ def main(): if total_tests > 0: success_rate = (passed_tests / total_tests) * 100 print(f" Success rate: {success_rate:.1f}%") - print("=" * 80) + print(rule()) if all_passed: print("✅ All models created and tested successfully") else: print("⚠️ Some models had issues - see details above") - print("=" * 80) + print(rule()) if not all_passed: import sys diff --git a/act/pipeline/verification/torch2act.py b/act/pipeline/verification/torch2act.py index 87d813c66..a00bd515e 100644 --- a/act/pipeline/verification/torch2act.py +++ b/act/pipeline/verification/torch2act.py @@ -65,7 +65,7 @@ _HAS_STOCHASTIC_DEPTH = False from act.back_end.core import Net, Layer -from act.back_end.layer_schema import LayerKind +from act.back_end.layer_schema import ACT_TO_TORCH, LayerKind from act.back_end.layer_util import create_layer from act.pipeline.verification.utils import ( _prod, _normalize_tuple, _assert_dag, _broadcast_const_to_size, @@ -79,6 +79,10 @@ from act.back_end.solver.solver_torchlp import TorchLPSolver from act.back_end.solver.solver_gurobi import GurobiSolver from act.util.options import PerformanceOptions +from act.util.format_utils import rule + + +_TORCH_TO_ACT_EXACT = {module_cls: kind for kind, module_cls in ACT_TO_TORCH.items()} # ----------------------------------------------------------------------------- @@ -1163,38 +1167,79 @@ def _build_preds_succs(self) -> Tuple[Dict[int, List[int]], Dict[int, List[int]] def _convert_module(self, mod: nn.Module) -> None: """Convert a PyTorch module to ACT layer(s).""" - converters = { - nn.Flatten: self._convert_flatten, - nn.Linear: self._convert_linear, - nn.ReLU: lambda m: self._convert_activation(m, LayerKind.RELU), - nn.Conv2d: self._convert_conv2d, - nn.ConvTranspose2d: self._convert_conv_transpose2d, - nn.MaxPool2d: self._convert_pool2d, - nn.AvgPool2d: self._convert_pool2d, - nn.AdaptiveAvgPool2d: self._convert_adaptive_avgpool2d, - _BatchNorm: self._convert_batchnorm, - nn.SiLU: lambda m: self._convert_activation(m, LayerKind.SILU), - nn.Sigmoid: lambda m: self._convert_activation(m, LayerKind.SIGMOID), - nn.Tanh: lambda m: self._convert_activation(m, LayerKind.TANH), - nn.Softmax: self._convert_softmax, - nn.LeakyReLU: lambda m: self._convert_activation(m, LayerKind.LRELU, {"negative_slope": m.negative_slope}), - nn.LSTM: lambda m: self._convert_rnn_family(m, LayerKind.LSTM), - nn.GRU: lambda m: self._convert_rnn_family(m, LayerKind.GRU), - nn.RNN: lambda m: self._convert_rnn_family(m, LayerKind.RNN), - } - # No-op modules (identity during inference) if isinstance(mod, nn.Dropout) or ( _HAS_STOCHASTIC_DEPTH and isinstance(mod, StochasticDepth) ): return - - for mod_type, converter in converters.items(): - if isinstance(mod, mod_type): - converter(mod) - return + + if isinstance(mod, _BatchNorm): + self._convert_batchnorm(mod) + return + + kind = self._layer_kind_for_module(mod) + if kind == LayerKind.FLATTEN.value and isinstance(mod, nn.Flatten): + self._convert_flatten(mod) + return + if kind == LayerKind.DENSE.value and isinstance(mod, nn.Linear): + self._convert_linear(mod) + return + if kind == LayerKind.RELU.value: + self._convert_activation(mod, LayerKind.RELU) + return + if kind == LayerKind.CONV2D.value and isinstance(mod, nn.Conv2d): + self._convert_conv2d(mod) + return + if kind == LayerKind.CONVTRANSPOSE2D.value and isinstance(mod, nn.ConvTranspose2d): + self._convert_conv_transpose2d(mod) + return + if kind in (LayerKind.MAXPOOL2D.value, LayerKind.AVGPOOL2D.value) and isinstance(mod, (nn.MaxPool2d, nn.AvgPool2d)): + self._convert_pool2d(mod) + return + if kind == LayerKind.ADAPTIVEAVGPOOL2D.value and isinstance(mod, nn.AdaptiveAvgPool2d): + self._convert_adaptive_avgpool2d(mod) + return + if kind == LayerKind.SILU.value: + self._convert_activation(mod, LayerKind.SILU) + return + if kind == LayerKind.SIGMOID.value: + self._convert_activation(mod, LayerKind.SIGMOID) + return + if kind == LayerKind.TANH.value: + self._convert_activation(mod, LayerKind.TANH) + return + if kind == LayerKind.SOFTMAX.value: + self._convert_softmax(mod) + return + if kind == LayerKind.LRELU.value and isinstance(mod, nn.LeakyReLU): + self._convert_activation(mod, LayerKind.LRELU, {"negative_slope": mod.negative_slope}) + return + if kind == LayerKind.LSTM.value and isinstance(mod, nn.LSTM): + self._convert_rnn_family(mod, LayerKind.LSTM) + return + if kind == LayerKind.GRU.value and isinstance(mod, nn.GRU): + self._convert_rnn_family(mod, LayerKind.GRU) + return + if kind == LayerKind.RNN.value and isinstance(mod, nn.RNN): + self._convert_rnn_family(mod, LayerKind.RNN) + return raise NotImplementedError(f"Unsupported module: {type(mod).__name__}") + + def _layer_kind_for_module(self, mod: nn.Module) -> Optional[str]: + """Resolve an ACT layer kind from the canonical Torch mapping. + + Exact type matching handles overlapping module hierarchies first; the + fallback keeps subclass support such as custom nn.Linear subclasses + mapping to DENSE. + """ + exact = _TORCH_TO_ACT_EXACT.get(type(mod)) + if exact is not None: + return exact + for kind, module_cls in ACT_TO_TORCH.items(): + if isinstance(mod, module_cls): + return kind + return None # ------------------------------------------------------------------------- # Layer Conversion - Specific Converters @@ -2164,7 +2209,7 @@ def main(): debug_file = PerformanceOptions.debug_output_file with open(debug_file, 'w') as f: f.write(f"ACT Torch2ACT Conversion Debug Log\n") - f.write(f"{'='*80}\n\n") + f.write(f"{rule()}\n\n") print(f"Debug logging to: {debug_file}") print("Starting Spec-Free, Input-Free Torch→ACT Verification Demo") diff --git a/act/pipeline/verification/validate_verifier.py b/act/pipeline/verification/validate_verifier.py index 2591d9bdf..9209804e7 100644 --- a/act/pipeline/verification/validate_verifier.py +++ b/act/pipeline/verification/validate_verifier.py @@ -138,6 +138,7 @@ ) from act.util.stats import VerifyStatus from act.util.options import PerformanceOptions +from act.util.format_utils import rule logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -165,7 +166,7 @@ def __init__(self, device: str = "cpu", dtype: torch.dtype = torch.float64): with open(debug_file, "w") as f: f.write(f"ACT Verification Debug Log\n") f.write(f"Device: {device}, Dtype: {dtype}\n") - f.write(f"{'=' * 80}\n\n") + f.write(f"{rule()}\n\n") logger.info(f"Debug logging to: {debug_file}") def _batchify_net(self, net: Net, target_B: Optional[int]) -> Net: @@ -936,16 +937,16 @@ def _print_summary(self, summary: Dict[str, Any]): """Print validation summary for specific validation type.""" validation_type = summary.get("validation_type", "unknown") - print("\n" + "=" * 80) + print("\n" + rule()) print(f"VALIDATION SUMMARY - {validation_type.upper()}") - print("=" * 80) + print(rule()) if summary["total"] == 0: print() print("⚠️ No validation tests completed successfully") if "error_message" in summary: print(f" {summary['error_message']}") - print("=" * 80) + print(rule()) return print(f"\nTotal validation tests: {summary['total']}") @@ -967,7 +968,7 @@ def _print_summary(self, summary: Dict[str, Any]): print(f"⏭️ SKIPPED: {summary['skipped']}") print(f"❌ ERRORS: {summary['errors']}") print(f"🚨 FAILED: {summary['failed']}") - print("=" * 80) + print(rule()) if summary["failed"] > 0: print(f"\n🚨 CRITICAL: {validation_type.upper()} validation failed!") @@ -989,4 +990,4 @@ def _print_summary(self, summary: Dict[str, Any]): else: print(f"\n✅ {validation_type.upper()} validation PASSED!") - print("=" * 80) + print(rule()) diff --git a/act/pipeline/verification/vnncomp_runner.py b/act/pipeline/verification/vnncomp_runner.py deleted file mode 100755 index 70d9c30d2..000000000 --- a/act/pipeline/verification/vnncomp_runner.py +++ /dev/null @@ -1,201 +0,0 @@ -#===- act/pipeline/verification/vnncomp_runner.py - VNN-COMP runner ----====# -# ACT: Abstract Constraint Transformer -# Copyright (C) 2025– ACT Team -# -# Licensed under the GNU Affero General Public License v3.0 or later (AGPLv3+). -# Distributed without any warranty; see . -#===---------------------------------------------------------------------===# -# -# Purpose: -# VNN-COMP 2026 single-instance orchestration: onnx+vnnlib load, ACTFuzzer -# PGD pre-attack (FALSIFY), auto/escalation dual BaB (CERTIFY), disjunct -# aggregation, and VNN-COMP result emission. The thin CLI entrypoint lives at -# vnncomp/act_run_instance.py and calls run_vnncomp_instance(args). -# -#===---------------------------------------------------------------------===# - -from __future__ import annotations - -import sys -import time - -import torch - -from act.util.device_manager import initialize_device - - -def run_vnncomp_instance(args) -> None: - cuda_ok = torch.cuda.is_available() - print(f"[env] torch={torch.__version__} cuda_build={torch.version.cuda} " - f"cuda_available={cuda_ok} " - f"device_count={torch.cuda.device_count()} resolved_device={args.device}", - file=sys.stderr, flush=True) - if not cuda_ok: - print("[env] WARNING: running on CPU - expect timeouts. torch cannot see a GPU; " - "check the NVIDIA driver (nvidia-smi must work).", - file=sys.stderr, flush=True) - - t0 = time.time() - initialize_device(args.device, args.dtype) - - from act.back_end.bab.bab import clear_violation_check_module_cache, verify_bab_batched - from act.back_end.config import build_vnncomp_bab_config - from act.back_end.solver.solver_torchlp import TorchLPSolver - from act.front_end.model_synthesis import merge_split_relus - from act.front_end.model_synthesis import synthesize_models_from_specs - from act.front_end.vnnlib_loader.create_specs import create_specs_from_paths - from act.front_end.vnnlib_loader.vnnlib_parser import ( - extract_vnnlib_2_io_decls, write_vnncomp_result) - from act.pipeline.fuzzing.actfuzzer import pgd_preattack - from act.pipeline.verification.torch2act import TorchToACT - from act.util.stats import VerifyStatus - - def remaining() -> float: - return args.timeout - (time.time() - t0) - args.margin - - try: - sr = create_specs_from_paths(args.onnx, args.vnnlib) - except SystemExit as exc: - print(f"[load failed] {exc}", file=sys.stderr) - write_vnncomp_result(args.output, "unknown") - return - - raw_model = sr[2] - param = next(raw_model.parameters(), None) - io_decls = extract_vnnlib_2_io_decls(args.vnnlib) - - input_dim = int(sr[3][0].tensor.numel()) if sr[3] else 0 - low_dim = 0 < input_dim <= args.input_split_dims - if low_dim: - print(f"[profile] input_dim={input_dim} <= {args.input_split_dims}: " - f"input-split BaB with per-node bound recompute", file=sys.stderr, flush=True) - - def raw_forward(x): - ref = sr[3][0].tensor if sr[3] else None - if ref is not None and x.numel() == ref.numel() and x.shape != ref.shape: - x = x.reshape(ref.shape) - if param is not None: - x = x.to(device=param.device, dtype=param.dtype) - with torch.no_grad(): - return raw_model(x) - - verify_model, n_merged = merge_split_relus(raw_model) - if n_merged: - print(f"[merge] fused {n_merged} split-ReLU neurons", file=sys.stderr, flush=True) - sr = tuple(verify_model if i == 2 else v for i, v in enumerate(sr)) - - # A VNNLIB 2.0 top-1 OR that did NOT pattern-merge (front-end Layer 1) yields - # N disjunct models here; the instance is unsat only if EVERY disjunct is - # infeasible and sat if ANY single disjunct is reachable. Verifying just the - # first would be unsound. N == 1 reproduces the original single-model path - # exactly (per-model budgets collapse to remaining()). - wrapped_models = list(synthesize_models_from_specs([sr]).values()) - n_models = len(wrapped_models) - - if args.fuzzing_seconds > 0 and remaining() > 1.0: - per_model_fuzz = min(args.fuzzing_seconds, remaining() / n_models) - for wm in wrapped_models: - if remaining() <= 1.0: - break - try: - ce, _ = pgd_preattack(wm, sr[3], min(per_model_fuzz, remaining()), args.fuzzing_scale) - except Exception as exc: - ce = None - print(f"[attack skipped] {exc}", file=sys.stderr) - if ce is not None: - x = ce.input if hasattr(ce, "input") else ce - if io_decls is None: - write_vnncomp_result(args.output, "unknown") - else: - write_vnncomp_result(args.output, "sat", x=x, y=raw_forward(x), - in_decl=io_decls[0], out_decl=io_decls[1]) - return - - if remaining() <= 1.0: - write_vnncomp_result(args.output, "timeout") - return - - def _verify_wrapped(wrapped, deadline): - """Auto/escalation BaB flow on ONE disjunct model, capped at ``deadline`` - (wall-clock). When n_models == 1, deadline is set just past the global - margin deadline so budget_left() == remaining() and this path is - byte-identical to the original single-model logic.""" - net = TorchToACT(wrapped).run() - - def budget_left(): - return min(remaining(), deadline - time.time()) - - def _verify(tier, budget): - cfg = build_vnncomp_bab_config(args.config, llm_backend=args.llm_backend, llm_model=args.llm_model, - llm_timeout=args.llm_timeout, solver_tier=tier) - if low_dim: - # Low-dim regime (ACAS Xu-style): bisect the input domain and recompute - # every child's intermediate bounds on its own sub-box. Neuron splits and - # frozen root bounds - the large-net defaults - certify nothing here: the - # branching gain of an input split lives entirely in the recomputed - # intermediate relaxations. Uncapped frontier, since any eviction makes - # certification permanently impossible for the run. - cfg.branching_method = "width" - cfg.multi_split_levels = 1 - cfg.reuse_root_bounds = False - cfg.intermediate_refine = "none" - cfg.frontier_cap = 0 - clear_violation_check_module_cache() - return verify_bab_batched(net, solver_factory=TorchLPSolver, config=cfg, - max_batch_size=args.max_batch_size, time_budget_s=max(1.0, budget)) - - if args.solver_tier == "auto": - if low_dim: - # With input splits + per-node recompute, the cheap one-shot 'dual' - # bound is the workhorse (ACAS Xu prop_1 certifies in ~0.1s / 500 - # nodes vs 17s with alpha+eta); escalate only if it can't close. - res = _verify("dual", budget_left()) - if res.status not in (VerifyStatus.CERTIFIED, VerifyStatus.FALSIFIED) and budget_left() > 1.0: - res = _verify("dual_alpha_eta", budget_left()) - else: - # The one-shot 'dual' bound certifies tight nets (e.g. ViT attention) at the - # root in ~0.2s; escalate to the iterative alpha+eta tier + BaB only if - # still UNKNOWN. - res = _verify("dual", min(budget_left(), 15.0)) - if res.status not in (VerifyStatus.CERTIFIED, VerifyStatus.FALSIFIED) and budget_left() > 1.0: - res = _verify("dual_alpha_eta", budget_left()) - else: - res = _verify(args.solver_tier, budget_left()) - return res - - # Aggregate across disjuncts. Soundness (N > 1): 'unsat' requires EVERY - # disjunct CERTIFIED; one FALSIFIED disjunct with a CE gives 'sat'; a - # genuinely inconclusive verdict gives 'unknown'; otherwise the shortfall is - # the clock -> 'timeout'. - statuses = [] - falsified_ce = None - try: - unfinished = n_models - for wm in wrapped_models: - if remaining() <= 1.0: - break - deadline = time.time() + remaining() / max(1, unfinished) - res = _verify_wrapped(wm, deadline) - unfinished -= 1 - statuses.append(res.status) - if res.status == VerifyStatus.FALSIFIED and res.counterexample is not None: - falsified_ce = res.counterexample - break - except Exception as exc: - print(f"[verify error] {exc}", file=sys.stderr) - write_vnncomp_result(args.output, "unknown") - return - - if falsified_ce is not None: - if io_decls is None: - write_vnncomp_result(args.output, "unknown") - else: - write_vnncomp_result(args.output, "sat", x=falsified_ce, - y=raw_forward(falsified_ce), - in_decl=io_decls[0], out_decl=io_decls[1]) - elif len(statuses) == n_models and all(s == VerifyStatus.CERTIFIED for s in statuses): - write_vnncomp_result(args.output, "unsat") - elif any(s not in (VerifyStatus.CERTIFIED, VerifyStatus.TIMEOUT) for s in statuses): - write_vnncomp_result(args.output, "unknown") - else: - write_vnncomp_result(args.output, "timeout") diff --git a/act/util/path_config.py b/act/util/path_config.py index 52a040bd0..2c4e91ae0 100644 --- a/act/util/path_config.py +++ b/act/util/path_config.py @@ -1,3 +1,4 @@ +# pyright: reportMissingImports=false, reportMissingTypeArgument=false #===- util.path_config.py ----ACT Path Configuration ---------------------# # # ACT: Abstract Constraints Transformer @@ -173,49 +174,6 @@ def get_vnnlib_data_root() -> str: return str(vnnlib_root) -def get_spec_config_root() -> str: - """Get spec configuration directory (configs/specs/), creating if needed.""" - from pathlib import Path - config_root = Path(get_project_root()) / 'configs' / 'specs' - config_root.mkdir(parents=True, exist_ok=True) - return str(config_root) - - -def get_default_spec_config_path() -> str: - """Get path to default spec configuration.""" - from pathlib import Path - return str(Path(get_spec_config_root()) / 'default_spec_config.yaml') - - -def get_spec_config_path(name: str) -> str: - """ - Resolve named spec config to full path. - - Args: - name: Config name (with or without .yaml extension) - - Returns: - Full path to spec config file - - Raises: - FileNotFoundError: If config file doesn't exist - """ - from pathlib import Path - if not name.endswith('.yaml'): - name = f"{name}.yaml" - path = Path(get_spec_config_root()) / name - if not path.exists(): - raise FileNotFoundError(f"Spec config '{name}' not found in {get_spec_config_root()}") - return str(path) - - -def list_spec_configs() -> list: - """List all available spec configuration files.""" - from pathlib import Path - config_root = Path(get_spec_config_root()) - return sorted([f.stem for f in config_root.glob('*.yaml')]) - - # ============================================================================ # NetFactory Paths # ============================================================================ @@ -225,10 +183,4 @@ def get_examples_nets_dir() -> str: from pathlib import Path d = Path(get_project_root()) / 'act' / 'back_end' / 'examples' / 'nets' d.mkdir(parents=True, exist_ok=True) - return str(d) - - -def get_examples_gen_config_path() -> str: - """Get path to the NetFactory generation YAML config.""" - from pathlib import Path - return str(Path(get_project_root()) / 'act' / 'back_end' / 'examples' / 'config_gen_act_net.yaml') \ No newline at end of file + return str(d) \ No newline at end of file diff --git a/act/util/stats.py b/act/util/stats.py index 70a9d9dcf..42ffd454e 100644 --- a/act/util/stats.py +++ b/act/util/stats.py @@ -29,6 +29,8 @@ from dataclasses import dataclass, field from typing import Dict, Any, List, Tuple, Optional +from act.util.format_utils import rule + # ============================================================================= # Verification Status and Result Types @@ -316,7 +318,7 @@ def print_verification_stats(prediction_stats: Dict[str, Any]) -> None: for status, count in verification_result_stat_dict.items(): print(f" {status}: {count}") - print("-" * 50) + print(rule(50, "-")) @staticmethod def print_final_verification_summary(results: List) -> Any: @@ -331,14 +333,14 @@ def print_final_verification_summary(results: List) -> Any: """ # VerifyStatus is now defined at module level in this file - print("\n" + "🏆" + "="*70 + "🏆") + print("\n" + "🏆" + rule(70) + "🏆") print("📊 Final verification results summary") - print("🏆" + "="*70 + "🏆") + print("🏆" + rule(70) + "🏆") for idx, result in enumerate(results): print(f"Sample {idx+1}: {result.name}") - print("-" * 60) + print(rule(60, "-")) certified_count = sum(1 for r in results if r == VerifyStatus.CERTIFIED) falsified_count = sum(1 for r in results if r == VerifyStatus.FALSIFIED) @@ -372,7 +374,7 @@ def print_final_verification_summary(results: List) -> Any: print(f"📊 FALSIFIED over total: {falsified_total_percentage:.2f}% ({falsified_count}/{total_count})") print(f"📊 MODEL_INFER_FAILURE over total: {model_infer_failure_percentage:.2f}% ({model_infer_failure_count}/{total_count})") - print("-" * 60) + print(rule(60, "-")) if all(r == VerifyStatus.CERTIFIED for r in results): final_result = VerifyStatus.CERTIFIED @@ -384,7 +386,7 @@ def print_final_verification_summary(results: List) -> Any: final_result = VerifyStatus.UNKNOWN print("❓ Final Result: UNKNOWN - inconclusive") - print("🏆" + "="*70 + "🏆") + print("🏆" + rule(70) + "🏆") return final_result diff --git a/configs/specs/default_spec_config.yaml b/configs/specs/default_spec_config.yaml deleted file mode 100644 index 8e094ba88..000000000 --- a/configs/specs/default_spec_config.yaml +++ /dev/null @@ -1,74 +0,0 @@ -# Default Specification Configuration for ACT Spec Creators -# -# This configuration provides sensible defaults for creating verification -# specifications from both TorchVision and VNNLIB sources. -# -# Used by: BaseSpecCreator and subclasses (TorchVisionSpecCreator, VNNLibSpecCreator) - -# Epsilon values for perturbation-based input specs -# Used for BOX and LINF_BALL input specifications -epsilons: - - 0.01 - - 0.03 - - 0.05 - -# Margin values for robustness output specs -# Used for MARGIN_ROBUST output specifications -margins: - - 0.0 - -# Input specification types to generate -# Options: BOX, LINF_BALL, LIN_POLY -input_kinds: - - BOX - - LINF_BALL - -# Output specification types to generate -# Options: LINEAR_LE, TOP1_ROBUST, MARGIN_ROBUST, RANGE -output_kinds: - - MARGIN_ROBUST - -# Spec combination strategy -# Options: -# - full: All input_specs × all output_specs (cartesian product) -# - minimal: One input_spec, one output_spec (smallest set) -# - balanced: Multiple combinations with controlled growth -combination_strategy: balanced - -# Balanced strategy parameters (used when strategy = balanced) -balanced_params: - max_input_specs: 3 - max_output_specs: 2 - max_total_combinations: 6 - -# Validation settings -validation: - # Whether to validate specs against model I/O shapes - validate_shapes: true - - # Whether to skip specs that fail validation (vs raising error) - skip_invalid: true - -# TorchVision-specific settings (overridden by torchvision_classification.yaml) -torchvision: - # Number of samples to use per dataset-model pair - num_samples: 10 - - # Starting index in dataset - start_index: 0 - - # Dataset split to use - split: "test" - -# VNNLIB-specific settings (overridden by vnnlib_default.yaml) -vnnlib: - # Maximum instances per category - max_instances: null # null = process all instances - - # Whether to simplify ONNX models before conversion - simplify_onnx: true - -# Logging configuration -logging: - level: "INFO" # DEBUG, INFO, WARNING, ERROR - show_progress: true diff --git a/configs/specs/torchvision_classification.yaml b/configs/specs/torchvision_classification.yaml deleted file mode 100644 index 577a85ff9..000000000 --- a/configs/specs/torchvision_classification.yaml +++ /dev/null @@ -1,107 +0,0 @@ -# TorchVision Classification Specification Configuration -# -# Optimized preset for creating verification specs from TorchVision -# image classification dataset-model pairs. -# -# Used by: TorchVisionSpecCreator - -# Epsilon values optimized for image perturbations -# Common values for L-infinity robustness in image classification -epsilons: - - 0.01 # Small perturbation (barely perceptible) - - 0.03 # Medium perturbation (standard in adversarial robustness) - - 0.05 # Large perturbation (visible but reasonable) - - 0.1 # Very large perturbation (significant modification) - -# Margin values for classification robustness -# margin=0.0 means strict classification (y_true must be top-1) -# margin>0.0 means relaxed classification (y_true + margin > others) -margins: - - 0.0 - - 0.5 - -# Input specification types -# BOX: Explicit lower/upper bounds [lb, ub] per pixel -# LINF_BALL: L-infinity ball with center and epsilon -input_kinds: - - BOX - - LINF_BALL - -# Output specification types -# MARGIN_ROBUST: y[y_true] - max(y[others]) >= margin -# TOP1_ROBUST: argmax(y) == y_true (equivalent to MARGIN_ROBUST with margin=0) -output_kinds: - - MARGIN_ROBUST - - TOP1_ROBUST - -# Combination strategy -# For TorchVision, use 'full' to explore multiple epsilon-margin combinations -combination_strategy: full - -# Balanced strategy parameters (not used with 'full' strategy) -balanced_params: - max_input_specs: 5 - max_output_specs: 3 - max_total_combinations: 15 - -# Validation settings -validation: - # Always validate for image classification - validate_shapes: true - - # Skip invalid specs rather than failing - skip_invalid: true - -# TorchVision-specific settings -torchvision: - # Number of samples per dataset-model pair - # Higher for comprehensive testing, lower for quick exploration - num_samples: 20 - - # Starting index in dataset (useful for skipping early samples) - start_index: 0 - - # Use test split for evaluation - split: "test" - - # Preprocessing settings (handled automatically by data_model_loader) - # These are informational only - preprocessing_info: - normalize: true # Images normalized to [0, 1] - resize: true # Resized to model input size - grayscale: false # RGB by default (MNIST uses grayscale) - -# Image classification specific -image_classification: - # Clamp perturbations to valid pixel range - clamp_range: - min: 0.0 - max: 1.0 - - # Whether to center perturbations around sample - center_perturbations: true - -# Logging configuration -logging: - level: "INFO" - show_progress: true - - # Show per-sample spec generation details - verbose_samples: false - -# Performance settings -performance: - # Batch processing for validation (if applicable) - batch_validate: false - - # Cache model I/O shapes to avoid repeated inference - cache_shapes: true - -# Notes for users -notes: | - TorchVision Classification Preset: - - Designed for MNIST, CIFAR10, ImageNet models - - Uses standard epsilon values (0.01, 0.03, 0.05, 0.1) - - Generates both BOX and LINF_BALL input specs - - Tests both strict (TOP1_ROBUST) and margin-based robustness - - Full combination strategy creates many spec pairs for thorough testing diff --git a/configs/specs/vnnlib_default.yaml b/configs/specs/vnnlib_default.yaml deleted file mode 100644 index 11288c1ff..000000000 --- a/configs/specs/vnnlib_default.yaml +++ /dev/null @@ -1,151 +0,0 @@ -# VNNLIB Benchmark Specification Configuration -# -# Optimized preset for creating verification specs from VNN-COMP -# VNNLIB benchmark instances with ONNX models. -# -# Used by: VNNLibSpecCreator - -# Epsilon values (not typically used for VNNLIB) -# VNNLIB specs already contain concrete constraints -# These are fallback values if needed for additional perturbations -epsilons: - - 0.01 - -# Margin values (not typically used for VNNLIB) -# VNNLIB specs already define output constraints -margins: - - 0.0 - -# Input specification types -# VNNLIB typically provides BOX constraints -# LIN_POLY may be needed for some benchmarks with linear constraints -input_kinds: - - BOX - - LIN_POLY - -# Output specification types -# VNNLIB typically provides LINEAR_LE constraints (c^T * y <= d) -# RANGE may be needed for simple bounds -output_kinds: - - LINEAR_LE - - RANGE - -# Combination strategy -# For VNNLIB, use 'minimal' since specs are already defined in .vnnlib files -# Each instance has exactly one input spec and one output spec -combination_strategy: minimal - -# Balanced strategy parameters (not used with 'minimal' strategy) -balanced_params: - max_input_specs: 1 - max_output_specs: 1 - max_total_combinations: 1 - -# Validation settings -validation: - # Validate specs against converted PyTorch models - validate_shapes: true - - # Skip invalid specs rather than failing - skip_invalid: true - -# VNNLIB-specific settings -vnnlib: - # Maximum instances per category - # null = process all instances in category - # Set to a number (e.g., 10) to limit for quick testing - max_instances: null - - # Whether to simplify ONNX models before PyTorch conversion - # Simplification can improve conversion success rate - simplify_onnx: true - - # ONNX conversion settings - onnx_conversion: - # Attempt conversion even if ONNX validation fails - force_conversion: false - - # Validate converted PyTorch model with dummy input - test_conversion: true - -# ONNX model handling -onnx: - # Input shape inference method - # Options: 'from_onnx', 'from_vnnlib', 'auto' - shape_inference: 'auto' - - # Handle dynamic batch dimensions - # If true, assumes first dimension is batch and removes it - handle_batch_dim: true - -# VNNLIB parsing options -vnnlib_parsing: - # How to extract input tensor from constraints - # Options: 'center', 'lower_bound', 'upper_bound', 'midpoint' - # 'center' = (lb + ub) / 2 (default) - input_tensor_method: 'center' - - # Handle missing output constraints - # If VNNLIB has no output constraints, use fallback spec - fallback_output_spec: 'RANGE' - -# Category-specific settings (optional overrides) -category_overrides: - # Example: ACAS Xu benchmarks - acasxu: - max_instances: 50 - simplify_onnx: false - - # Example: MNIST fully connected - mnist_fc: - max_instances: 100 - test_conversion: true - - # Example: CIFAR10 ResNet - cifar10_resnet: - max_instances: 20 - simplify_onnx: true - -# Logging configuration -logging: - level: "INFO" - show_progress: true - - # Show detailed ONNX conversion logs - verbose_conversion: false - - # Show detailed VNNLIB parsing logs - verbose_parsing: false - -# Performance settings -performance: - # Cache converted PyTorch models to avoid repeated conversion - cache_models: true - - # Parallel processing for multiple instances - parallel: false - num_workers: 1 - -# Download settings -download: - # Base URL for VNN-COMP benchmarks - vnncomp_repo: "https://raw.githubusercontent.com/ChristopherBrix/vnncomp_benchmarks/main" - - # Automatic retry on download failure - retry_failed: true - max_retries: 3 - -# Notes for users -notes: | - VNNLIB Benchmark Preset: - - Designed for VNN-COMP benchmark instances - - Minimal combination strategy (1 input spec, 1 output spec per instance) - - Automatically downloads from VNN-COMP GitHub repository - - Converts ONNX models to PyTorch for unified verification interface - - Parses VNNLIB SMT-LIB format to extract constraints - - Handles common VNN-COMP categories (MNIST, CIFAR10, ACAS Xu, etc.) - - Usage: - 1. Download category: download_vnnlib_category("mnist_fc") - 2. Create specs: creator = VNNLibSpecCreator(); results = creator.create_specs_for_data_model_pairs() - 3. Each result contains: (category, instance_id, pytorch_model, [input_tensor], [(input_spec, output_spec)]) diff --git a/data/vnnlib/README.md b/data/vnnlib/README.md index 1b6f8c6bb..80143895b 100644 --- a/data/vnnlib/README.md +++ b/data/vnnlib/README.md @@ -108,16 +108,16 @@ onnx/ACASXU_run2a_1_2_batch_2000.onnx,vnnlib/prop_1.vnnlib,60 ```bash # Auto-detect and download VNNLIB category -python -m act.front_end.cli --download acasxu_2023 +python -m act.front_end --download acasxu_2023 # List all categories (TorchVision + VNNLIB) -python -m act.front_end.cli --list +python -m act.front_end --list # Show category details -python -m act.front_end.cli --info cifar100_2024 +python -m act.front_end --info cifar100_2024 # Search categories -python -m act.front_end.cli --search yolo +python -m act.front_end --search yolo ``` ### Using VNNLIB-Specific CLI diff --git a/vnncomp/act_run_instance.py b/vnncomp/act_run_instance.py index f38b61263..91555e0ff 100755 --- a/vnncomp/act_run_instance.py +++ b/vnncomp/act_run_instance.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""ACT VNN-COMP 2026 single-instance runner (thin CLI entrypoint). +"""ACT VNN-COMP 2026 single-instance runner. Wraps the ACT pipeline (arbitrary onnx+vnnlib load, ACTFuzzer mutation pre-attack for FALSIFICATION, dual_alpha_eta BaB for CERTIFICATION) and emits the VNN-COMP result contract: @@ -13,15 +13,16 @@ python act_run_instance.py [opts] -This entrypoint only parses CLI arguments; the orchestration (loading, PGD pre-attack, -auto/escalation BaB, disjunct aggregation, CE emission) lives in -act.pipeline.verification.vnncomp_runner.run_vnncomp_instance. +main() parses the CLI arguments; run_vnncomp_instance() orchestrates the single-instance +flow: onnx+vnnlib load, ACTFuzzer PGD pre-attack (FALSIFY), auto/escalation dual BaB +(CERTIFY), disjunct aggregation, and VNN-COMP result emission. """ from __future__ import annotations import argparse import sys +import time from pathlib import Path # This runner lives in /vnncomp/; the repo root (which holds the `act` @@ -32,7 +33,185 @@ import torch -from act.pipeline.verification.vnncomp_runner import run_vnncomp_instance +from act.util.device_manager import initialize_device + + +def run_vnncomp_instance(args) -> None: + cuda_ok = torch.cuda.is_available() + print(f"[env] torch={torch.__version__} cuda_build={torch.version.cuda} " + f"cuda_available={cuda_ok} " + f"device_count={torch.cuda.device_count()} resolved_device={args.device}", + file=sys.stderr, flush=True) + if not cuda_ok: + print("[env] WARNING: running on CPU - expect timeouts. torch cannot see a GPU; " + "check the NVIDIA driver (nvidia-smi must work).", + file=sys.stderr, flush=True) + + t0 = time.time() + initialize_device(args.device, args.dtype) + + from act.back_end.bab.bab import clear_violation_check_module_cache, verify_bab_batched + from act.config.config import build_vnncomp_bab_config + from act.back_end.solver.solver_torchlp import TorchLPSolver + from act.front_end.model_synthesis import merge_split_relus + from act.front_end.model_synthesis import synthesize_models_from_specs + from act.front_end.vnnlib_loader.create_specs import create_specs_from_paths + from act.front_end.vnnlib_loader.vnnlib_parser import ( + extract_vnnlib_2_io_decls, write_vnncomp_result) + from act.pipeline.fuzzing.actfuzzer import pgd_preattack + from act.pipeline.verification.torch2act import TorchToACT + from act.util.stats import VerifyStatus + + def remaining() -> float: + return args.timeout - (time.time() - t0) - args.margin + + try: + sr = create_specs_from_paths(args.onnx, args.vnnlib) + except SystemExit as exc: + print(f"[load failed] {exc}", file=sys.stderr) + write_vnncomp_result(args.output, "unknown") + return + + raw_model = sr[2] + param = next(raw_model.parameters(), None) + io_decls = extract_vnnlib_2_io_decls(args.vnnlib) + + input_dim = int(sr[3][0].tensor.numel()) if sr[3] else 0 + low_dim = 0 < input_dim <= args.input_split_dims + if low_dim: + print(f"[profile] input_dim={input_dim} <= {args.input_split_dims}: " + f"input-split BaB with per-node bound recompute", file=sys.stderr, flush=True) + + def raw_forward(x): + ref = sr[3][0].tensor if sr[3] else None + if ref is not None and x.numel() == ref.numel() and x.shape != ref.shape: + x = x.reshape(ref.shape) + if param is not None: + x = x.to(device=param.device, dtype=param.dtype) + with torch.no_grad(): + return raw_model(x) + + verify_model, n_merged = merge_split_relus(raw_model) + if n_merged: + print(f"[merge] fused {n_merged} split-ReLU neurons", file=sys.stderr, flush=True) + sr = tuple(verify_model if i == 2 else v for i, v in enumerate(sr)) + + # A VNNLIB 2.0 top-1 OR that did NOT pattern-merge (front-end Layer 1) yields + # N disjunct models here; the instance is unsat only if EVERY disjunct is + # infeasible and sat if ANY single disjunct is reachable. Verifying just the + # first would be unsound. N == 1 reproduces the original single-model path + # exactly (per-model budgets collapse to remaining()). + wrapped_models = list(synthesize_models_from_specs([sr]).values()) + n_models = len(wrapped_models) + + if args.fuzzing_seconds > 0 and remaining() > 1.0: + per_model_fuzz = min(args.fuzzing_seconds, remaining() / n_models) + for wm in wrapped_models: + if remaining() <= 1.0: + break + try: + ce, _ = pgd_preattack(wm, sr[3], min(per_model_fuzz, remaining()), args.fuzzing_scale) + except Exception as exc: + ce = None + print(f"[attack skipped] {exc}", file=sys.stderr) + if ce is not None: + x = ce.input if hasattr(ce, "input") else ce + if io_decls is None: + write_vnncomp_result(args.output, "unknown") + else: + write_vnncomp_result(args.output, "sat", x=x, y=raw_forward(x), + in_decl=io_decls[0], out_decl=io_decls[1]) + return + + if remaining() <= 1.0: + write_vnncomp_result(args.output, "timeout") + return + + def _verify_wrapped(wrapped, deadline): + """Auto/escalation BaB flow on ONE disjunct model, capped at ``deadline`` + (wall-clock). When n_models == 1, deadline is set just past the global + margin deadline so budget_left() == remaining() and this path is + byte-identical to the original single-model logic.""" + net = TorchToACT(wrapped).run() + + def budget_left(): + return min(remaining(), deadline - time.time()) + + def _verify(tier, budget): + cfg, dual_cfg = build_vnncomp_bab_config(args.config, llm_backend=args.llm_backend, llm_model=args.llm_model, + llm_timeout=args.llm_timeout, solver_tier=tier) + if low_dim: + # Low-dim regime (ACAS Xu-style): bisect the input domain and recompute + # every child's intermediate bounds on its own sub-box. Neuron splits and + # frozen root bounds - the large-net defaults - certify nothing here: the + # branching gain of an input split lives entirely in the recomputed + # intermediate relaxations. Uncapped frontier, since any eviction makes + # certification permanently impossible for the run. + cfg.branching_method = "width" + cfg.multi_split_levels = 1 + cfg.reuse_root_bounds = False + cfg.intermediate_refine = "none" + cfg.frontier_cap = 0 + clear_violation_check_module_cache() + return verify_bab_batched(net, solver_factory=TorchLPSolver, config=cfg, + max_batch_size=args.max_batch_size, time_budget_s=max(1.0, budget), + dual_config=dual_cfg) + + if args.solver_tier == "auto": + if low_dim: + # With input splits + per-node recompute, the cheap one-shot 'dual' + # bound is the workhorse (ACAS Xu prop_1 certifies in ~0.1s / 500 + # nodes vs 17s with alpha+eta); escalate only if it can't close. + res = _verify("dual", budget_left()) + if res.status not in (VerifyStatus.CERTIFIED, VerifyStatus.FALSIFIED) and budget_left() > 1.0: + res = _verify("dual_alpha_eta", budget_left()) + else: + # The one-shot 'dual' bound certifies tight nets (e.g. ViT attention) at the + # root in ~0.2s; escalate to the iterative alpha+eta tier + BaB only if + # still UNKNOWN. + res = _verify("dual", min(budget_left(), 15.0)) + if res.status not in (VerifyStatus.CERTIFIED, VerifyStatus.FALSIFIED) and budget_left() > 1.0: + res = _verify("dual_alpha_eta", budget_left()) + else: + res = _verify(args.solver_tier, budget_left()) + return res + + # Aggregate across disjuncts. Soundness (N > 1): 'unsat' requires EVERY + # disjunct CERTIFIED; one FALSIFIED disjunct with a CE gives 'sat'; a + # genuinely inconclusive verdict gives 'unknown'; otherwise the shortfall is + # the clock -> 'timeout'. + statuses = [] + falsified_ce = None + try: + unfinished = n_models + for wm in wrapped_models: + if remaining() <= 1.0: + break + deadline = time.time() + remaining() / max(1, unfinished) + res = _verify_wrapped(wm, deadline) + unfinished -= 1 + statuses.append(res.status) + if res.status == VerifyStatus.FALSIFIED and res.counterexample is not None: + falsified_ce = res.counterexample + break + except Exception as exc: + print(f"[verify error] {exc}", file=sys.stderr) + write_vnncomp_result(args.output, "unknown") + return + + if falsified_ce is not None: + if io_decls is None: + write_vnncomp_result(args.output, "unknown") + else: + write_vnncomp_result(args.output, "sat", x=falsified_ce, + y=raw_forward(falsified_ce), + in_decl=io_decls[0], out_decl=io_decls[1]) + elif len(statuses) == n_models and all(s == VerifyStatus.CERTIFIED for s in statuses): + write_vnncomp_result(args.output, "unsat") + elif any(s not in (VerifyStatus.CERTIFIED, VerifyStatus.TIMEOUT) for s in statuses): + write_vnncomp_result(args.output, "unknown") + else: + write_vnncomp_result(args.output, "timeout") def main() -> None: