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: