From 40128e574c3c35b032ab7c1ff72d584d89285859 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Fri, 10 Jul 2026 07:10:41 +1000 Subject: [PATCH 01/27] fix and opt backend --- act/back_end/cons_exportor.py | 197 +++++++++--------- act/back_end/core.py | 25 ++- act/back_end/dual_tf/tf_mlp.py | 3 +- act/back_end/hybridz_tf/tf_mlp.py | 3 +- act/back_end/interval_tf/tf_mlp.py | 5 +- act/back_end/net_factory.py | 127 ++++++----- act/back_end/solver/solver_dual.py | 4 +- act/back_end/solver/solver_torchlp.py | 43 ++-- act/back_end/utils.py | 4 + .../vnnlib_loader/data_model_loader.py | 11 +- 10 files changed, 234 insertions(+), 188 deletions(-) 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_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/hybridz_tf/tf_mlp.py b/act/back_end/hybridz_tf/tf_mlp.py index 1bbe3478d..db637fe64 100644 --- a/act/back_end/hybridz_tf/tf_mlp.py +++ b/act/back_end/hybridz_tf/tf_mlp.py @@ -15,6 +15,7 @@ import torch import torch.nn.functional as F 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, hz_multiply, @@ -243,7 +244,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/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/net_factory.py b/act/back_end/net_factory.py index a384e8e7b..e73872af2 100644 --- a/act/back_end/net_factory.py +++ b/act/back_end/net_factory.py @@ -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: 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_torchlp.py b/act/back_end/solver/solver_torchlp.py index b7845911a..21b2940d8 100644 --- a/act/back_end/solver/solver_torchlp.py +++ b/act/back_end/solver/solver_torchlp.py @@ -104,9 +104,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 +124,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/utils.py b/act/back_end/utils.py index ffc0a40c9..9f1b857ca 100644 --- a/act/back_end/utils.py +++ b/act/back_end/utils.py @@ -19,6 +19,10 @@ EPS = 1e-12 +# Single-source default LRELU/LeakyReLU negative slope, shared by dual and +# hybridz transfer functions when a layer omits the alpha/negative_slope param. +LRELU_ALPHA_DEFAULT = 0.01 + def split_weight(W): return W.clamp(min=0), W.clamp(max=0) 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" From 66b23d47c9f9edc4de223273bd4a896cddf6e3bf Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Fri, 10 Jul 2026 12:12:21 +1000 Subject: [PATCH 02/27] feat: unified config.yaml<->CLI parity across back_end/pipeline/front_end Consolidate a single config.yaml per component and make every Bucket-A tunable appear in BOTH the yaml and the CLI, behavior-preserving (new defaults equal the current hardcoded/argparse values). - back_end: surface all BaBConfig/BackendConfig knobs into config.yaml; add SolverConfig (TorchLP hyperparams) + TFConfig (hz_max_input_dim) wired through auto-generated --solver-*/--tf-* flags, from_yaml routing, TorchLPSolver and HybridzTF. - pipeline: new act/pipeline/config.py + single act/pipeline/config.yaml (fuzzing + verification.bab + validation at the pipeline defaults); repoint FuzzingConfig, delete fuzzing/config.yaml. - front_end: new act/front_end/config.py + single act/front_end/config.yaml (full torchvision/vnnlib/default spec presets + text_verification); repoint spec loader + path_config, delete configs/specs/*; add spec CLI flags and wire the previously-dead text-verify args. Excludes vnncomp settings, numerical/soundness constants, and math/data/enums. Verified: config snapshots byte-identical per tier; soundness harness 141/141. --- act/back_end/analyze.py | 4 +- act/back_end/cli.py | 26 +-- act/back_end/config.py | 77 ++++++++- act/back_end/config.yaml | 154 +++++++++++++++++ act/back_end/dual_tf/tf_cnn.py | 2 +- act/back_end/hybridz_tf/hybridz_tf.py | 5 +- act/back_end/solver/solver_torchlp.py | 32 ++-- act/back_end/transfer_functions.py | 12 +- act/back_end/utils.py | 5 +- act/front_end/bert_loader/create_specs.py | 12 +- act/front_end/cli.py | 77 ++++++++- act/front_end/config.py | 44 +++++ act/front_end/config.yaml | 159 ++++++++++++++++++ act/front_end/model_synthesis.py | 45 ++++- act/front_end/spec_creator_base.py | 16 +- act/pipeline/cli.py | 153 ++++++++++++----- act/pipeline/config.py | 77 +++++++++ act/pipeline/config.yaml | 48 ++++++ act/pipeline/fuzzing/actfuzzer.py | 18 +- act/pipeline/fuzzing/config.yaml | 60 ------- act/pipeline/fuzzing/mutations.py | 21 +-- act/util/path_config.py | 47 +++--- configs/specs/default_spec_config.yaml | 74 -------- configs/specs/torchvision_classification.yaml | 107 ------------ configs/specs/vnnlib_default.yaml | 151 ----------------- 25 files changed, 880 insertions(+), 546 deletions(-) create mode 100644 act/front_end/config.py create mode 100644 act/front_end/config.yaml create mode 100644 act/pipeline/config.py create mode 100644 act/pipeline/config.yaml delete mode 100644 act/pipeline/fuzzing/config.yaml delete mode 100644 configs/specs/default_spec_config.yaml delete mode 100644 configs/specs/torchvision_classification.yaml delete mode 100644 configs/specs/vnnlib_default.yaml 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/cli.py b/act/back_end/cli.py index 9da8597cb..12759a293 100644 --- a/act/back_end/cli.py +++ b/act/back_end/cli.py @@ -62,7 +62,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.back_end.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig, SolverConfig, TFConfig existing_options = { option @@ -118,24 +118,28 @@ 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", "solver_config", "tf"}, ) add_group(BaBConfig, "BaB Config Overrides (generated)", "bab-", "bab_", set()) add_group(GenerationConfig, "Generation Config Overrides (generated)", "gen-", "gen_", set()) add_group(HybridZConfig, "HybridZ Config Overrides (generated)", "hz-", "hybridz_", set()) + add_group(SolverConfig, "Solver Config Overrides (generated)", "solver-", "solver_", set()) + add_group(TFConfig, "TF Config Overrides (generated)", "tf-", "tf_", set()) def _backend_override_keys_from_dataclasses() -> set[str]: - from act.back_end.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig + from act.back_end.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig, SolverConfig, TFConfig keys = { fld.name for fld in fields(BackendConfig) - if fld.name not in {"bab", "generation", "hybridz"} + if fld.name not in {"bab", "generation", "hybridz", "solver_config", "tf"} } 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)) + keys.update(f"solver_{fld.name}" for fld in fields(SolverConfig)) + keys.update(f"tf_{fld.name}" for fld in fields(TFConfig)) return keys @@ -151,7 +155,7 @@ class _SkipUnsupported(NamedTuple): kinds: tuple[str, ...] -def _make_solver(solver_name: str): +def _make_solver(solver_name: str, solver_config=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``. @@ -163,14 +167,14 @@ def _make_solver(solver_name: str): return GurobiSolver() if solver_name == "torchlp": - return TorchLPSolver() + return TorchLPSolver(config=solver_config) # "auto": try Gurobi, fall back to TorchLP try: from act.back_end.solver.solver_gurobi import GurobiSolver return GurobiSolver() except Exception: - return TorchLPSolver() + return TorchLPSolver(config=solver_config) def _verify_one_net( @@ -256,7 +260,7 @@ 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.solver_config), timelimit=backend_cfg.timeout, ) results = [ @@ -301,7 +305,7 @@ 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.solver_config), config=bab_cfg, max_batch_size=backend_cfg.bab_max_batch_size, time_budget_s=backend_cfg.timeout, @@ -1294,7 +1298,7 @@ def main(): if args.tf_mode is not None: from act.back_end.analyze import initialize_tf_mode - initialize_tf_mode(args.tf_mode) + initialize_tf_mode(args.tf_mode, backend_cfg.tf) # Set the solver-mode global so verify_once / _verify_one_net can dispatch # dual ↔ LP-cascade without consulting the TF mode (refactor decoupled @@ -1492,7 +1496,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.solver_config), timelimit=cfg.timeout, ) assert len(lp_results) == 1 diff --git a/act/back_end/config.py b/act/back_end/config.py index 351114f26..019237157 100644 --- a/act/back_end/config.py +++ b/act/back_end/config.py @@ -335,6 +335,29 @@ class HybridZConfig: timeout: Optional[float] = None engine: str = "dense_hz_objbound" + +@dataclass +class SolverConfig: + 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 + + +@dataclass +class TFConfig: + hz_max_input_dim: int = 1024 + # --------------------------------------------------------------------------- # BackendConfig — unified back-end configuration # --------------------------------------------------------------------------- @@ -383,6 +406,8 @@ class BackendConfig: generation: GenerationConfig = field(default_factory=GenerationConfig) hybridz: HybridZConfig = field(default_factory=HybridZConfig) + solver_config: SolverConfig = field(default_factory=SolverConfig) + tf: TFConfig = field(default_factory=TFConfig) method: Optional[str] = None p: float = 2.0 @@ -467,6 +492,8 @@ def from_yaml( - ``bab_`` → ``BaBConfig.`` - ``gen_`` → ``GenerationConfig.`` - ``hybridz_`` → ``HybridZConfig.`` + - ``solver_`` → ``SolverConfig.`` + - ``tf_`` → ``TFConfig.`` - ``bab_enabled`` → top-level ``bab_enabled`` """ path = Path(config_path) if config_path else _DEFAULT_YAML @@ -480,6 +507,10 @@ def from_yaml( bab_raw: dict[str, Any] = backend_raw.pop("bab", {}) gen_raw: dict[str, Any] = backend_raw.pop("generation", {}) hz_raw: dict[str, Any] = backend_raw.pop("hybridz", {}) + solver_raw: dict[str, Any] = backend_raw.pop("solver_config", {}) + if isinstance(backend_raw.get("solver"), dict): + solver_raw = backend_raw.pop("solver") + tf_raw: dict[str, Any] = backend_raw.pop("tf", {}) # Extract "enabled" from bab section → top-level bab_enabled bab_enabled = bab_raw.pop("enabled", None) @@ -488,9 +519,13 @@ 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)} + solver_fields = {fld.name for fld in fields(SolverConfig)} + tf_fields = {fld.name for fld in fields(TFConfig)} bab_overrides: dict[str, Any] = {} gen_overrides: dict[str, Any] = {} hz_overrides: dict[str, Any] = {} + solver_overrides: dict[str, Any] = {} + tf_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,6 +534,10 @@ 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("solver_") and k[7:] in solver_fields: + solver_overrides[k[7:]] = v + elif k.startswith("tf_") and k[3:] in tf_fields: + tf_overrides[k[3:]] = v else: top_overrides[k] = v @@ -516,8 +555,22 @@ def from_yaml( hz_merged.update(hz_overrides) hz_config = HybridZConfig(**hz_merged) + solver_merged = {k: v for k, v in solver_raw.items() if k in solver_fields} + solver_merged.update(solver_overrides) + solver_config = SolverConfig(**solver_merged) + + tf_merged = {k: v for k, v in tf_raw.items() if k in tf_fields} + tf_merged.update(tf_overrides) + tf_config = TFConfig(**tf_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", + "solver_config", + "tf", + } top_merged: dict[str, Any] = {} for k, v in backend_raw.items(): if k in top_fields: @@ -528,7 +581,14 @@ 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, + solver_config=solver_config, + tf=tf_config, + **top_merged, + ) def to_yaml(self, path: Union[str, Path]) -> Path: path = Path(path) @@ -538,12 +598,23 @@ def to_yaml(self, path: Union[str, Path]) -> Path: bab_d = d.pop("bab") gen_d = d.pop("generation") hz_d = d.pop("hybridz") + solver_d = d.pop("solver_config") + tf_d = d.pop("tf") 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, + "generation": gen_d, + "hybridz": hz_d, + "solver_config": solver_d, + "tf": tf_d, + } + }, f, default_flow_style=False, sort_keys=False, diff --git a/act/back_end/config.yaml b/act/back_end/config.yaml index 341dcc11e..69f93694a 100644 --- a/act/back_end/config.yaml +++ b/act/back_end/config.yaml @@ -40,6 +40,46 @@ backend: # Wall-clock budget in seconds for both single execution and BaB verification. timeout: 60.0 + # Enable the batched LP cascade tier before BaB. + lp_enabled: true + + # Maximum BaB subproblems solved in one batch. + bab_max_batch_size: 8 + + solver_config: + # TorchLP equality penalty weight. + rho_eq: 10.0 + # TorchLP inequality penalty weight. + rho_ineq: 10.0 + # TorchLP Adam iteration cap. + max_iter: 2000 + # Feasibility tolerance for SAT reporting. + tol_feas: 0.0001 + # TorchLP Adam learning rate. + lr: 0.01 + # TorchLP Adam beta1. + beta1: 0.9 + # TorchLP Adam beta2. + beta2: 0.999 + # TorchLP Adam weight decay. + weight_decay: 0.0 + # Variable-count threshold for large-N overrides. + large_n_threshold: 20000 + # Large-N iteration cap. + large_n_max_iter: 800 + # Large-N feasibility tolerance. + large_n_tol: 0.001 + # Stop after this many stagnant feasibility checks. + stagnation_patience: 300 + # Minimum feasibility improvement to reset stagnation. + stagnation_tol: 0.00001 + # Iteration stride between feasibility checks. + feas_check_stride: 5 + + tf: + # HybridZ generator/input dimension fallback limit. + hz_max_input_dim: 1024 + bab: # Enable Branch-and-Bound refinement (true) or single execution (false). enabled: true @@ -115,6 +155,120 @@ backend: # Track logical BaB node ids and parent ids in TopKBounding. provenance_enabled: false + # Refine only eta in child dual subproblems. + eta_only_children: false + + # Number of root pre-split levels before the main loop. + presplit_levels: 0 + + # Root intermediate-bound refinement mode. + intermediate_refine: "none" + + # Width ratio threshold for auto intermediate refinement. + intermediate_refine_ratio: 10.0 + + # Reuse root intermediate bounds in descendant subproblems. + reuse_root_bounds: false + + # Per-subproblem intermediate refinement mode. + per_subproblem_refine: "none" + + # Adam iterations for per-subproblem refinement rows. + per_subproblem_refine_iters: 0 + + # Row cap for per-subproblem refinement. + per_subproblem_refine_rows_cap: 64 + + # Fraction of available GPU memory targeted by auto batch sizing. + auto_batch_safety: 0.55 + + # Hard cap for auto-sized BaB batches. + auto_batch_cap: 2048 + + # Floor for auto-sized BaB batches. + auto_batch_floor: 8 + + # Number of simultaneous neuron split levels. + multi_split_levels: 1 + + # Enable the LLM-probe controller. + llm_probe_enabled: false + + # LLM-probe provider backend. + llm_probe_backend: "mock" + + # LLM-probe model name. + llm_probe_model: "" + + # LLM-probe API base URL override. + llm_probe_base_url: "" + + # Environment variable containing the LLM API key. + llm_probe_api_key_env: "" + + # LLM-probe sampling temperature. + llm_probe_temperature: 0.0 + + # LLM-probe per-request timeout in seconds. + llm_probe_timeout: 30.0 + + # Maximum candidates sent per LLM-probe domain. + llm_probe_max_candidates: 8 + + # Maximum candidates sent across all LLM-probe domains. + llm_probe_max_candidates_total: 1024 + + # Top-K neuron candidates retained before probing. + llm_probe_neuron_topk: 512 + + # LLM-probe consult cadence in waves. + llm_probe_cadence: 1 + + # Number of prior wave outcomes included in prompts. + llm_probe_history: 8 + + # Consecutive probe failures before disabling the controller. + llm_probe_max_failures: 3 + + # Decision types the LLM may steer. + llm_probe_decisions: "split,frontier,refine" + + # Log LLM-probe decisions. + llm_probe_log: false + + # Print verbose BaB diagnostics. + verbose: false + + # Public BERT verification method selector. + method: null + + # Enable backward attention form for BERT methods. + baf: true + + # BERT alpha handling mode. + alpha_mode: "fixed" + + # Norm p for perturbation semantics. + p: 2.0 + + # Number of perturbed words for text verification. + perturbed_words: 1 + + # Initial epsilon for text verification. + eps: 0.00001 + + # Maximum epsilon for text verification. + max_eps: 0.01 + + # Number of verification iterations for text methods. + num_verify_iters: 5 + + # Top-k value for text verification. + k: 1 + + # Alpha optimization steps for text methods. + alpha_opt_steps: 1000 + # ── Network Generation (net_factory) ──────────────────────────────────── # Controls how many networks to generate, where, and TF filtering. # Architecture sampling DSL (family definitions, input/output specs) 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/hybridz_tf/hybridz_tf.py b/act/back_end/hybridz_tf/hybridz_tf.py index d2c357926..af6366279 100644 --- a/act/back_end/hybridz_tf/hybridz_tf.py +++ b/act/back_end/hybridz_tf/hybridz_tf.py @@ -20,6 +20,7 @@ import torch from typing import Dict, Optional +from act.back_end.config import TFConfig from act.back_end.core import Bounds, Fact, Layer, Net, ConSet from act.back_end.transfer_functions import TransferFunction from act.back_end.layer_schema import LayerKind @@ -37,12 +38,14 @@ class HybridzTF(TransferFunction): - def __init__(self): + def __init__(self, config: Optional[TFConfig] = None): + cfg = config or TFConfig() self._hz_cache: Dict[int, HZono] = {} self._cache_net_id: Optional[int] = None self._tanh_K: int = 2 self._sigmoid_K: int = 2 self._var_id_stride: int = 1 + setattr(self, "_HZ_MAX_INPUT_DIM", cfg.hz_max_input_dim) @staticmethod def _net_var_id_stride(net: Net) -> int: diff --git a/act/back_end/solver/solver_torchlp.py b/act/back_end/solver/solver_torchlp.py index 21b2940d8..8dfb73b1a 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.back_end.config import SolverConfig 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[SolverConfig] = None): + cfg = config or SolverConfig() 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) diff --git a/act/back_end/transfer_functions.py b/act/back_end/transfer_functions.py index f127c5b43..748e6e6b2 100644 --- a/act/back_end/transfer_functions.py +++ b/act/back_end/transfer_functions.py @@ -75,7 +75,7 @@ def side_state_signature(self, layer_id: int) -> Any: return None # Global transfer function management -_current_tf: TransferFunction = None +_current_tf: Optional[TransferFunction] = None def set_transfer_function(tf_impl: TransferFunction) -> None: @@ -106,7 +106,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 +120,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 " @@ -188,10 +188,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 9f1b857ca..2561105b2 100644 --- a/act/back_end/utils.py +++ b/act/back_end/utils.py @@ -19,8 +19,7 @@ EPS = 1e-12 -# Single-source default LRELU/LeakyReLU negative slope, shared by dual and -# hybridz transfer functions when a layer omits the alpha/negative_slope param. +# Default LRELU/LeakyReLU negative slope when a layer omits negative_slope. LRELU_ALPHA_DEFAULT = 0.01 def split_weight(W): @@ -122,7 +121,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: 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/cli.py b/act/front_end/cli.py index f8590ec6b..e6ef85a6d 100644 --- a/act/front_end/cli.py +++ b/act/front_end/cli.py @@ -23,6 +23,43 @@ from act.back_end.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] + + +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: + keys = ( + 'method', + 'p', + 'perturbed_words', + 'eps', + 'max_eps', + 'num_verify_iters', + 'k', + 'alpha_opt_steps', + ) + overrides = {key: getattr(args, key) for key in 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): """ Print unified list of all available datasets/categories. @@ -599,6 +636,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, @@ -673,7 +744,11 @@ def main(): 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 diff --git a/act/front_end/config.py b/act/front_end/config.py new file mode 100644 index 000000000..26a3f88ab --- /dev/null +++ b/act/front_end/config.py @@ -0,0 +1,44 @@ +"""Front-end configuration loading.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional, Union + +import yaml + +_DEFAULT_YAML = Path(__file__).with_name("config.yaml") + + +@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 _DEFAULT_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/front_end/config.yaml b/act/front_end/config.yaml new file mode 100644 index 000000000..4c2819631 --- /dev/null +++ b/act/front_end/config.yaml @@ -0,0 +1,159 @@ +specs: + 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 + + ' + 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" + 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: + method: null + p: 2.0 + perturbed_words: 1 + eps: 1.0e-05 + max_eps: 0.01 + num_verify_iters: 5 + k: 1 + alpha_opt_steps: 1000 diff --git a/act/front_end/model_synthesis.py b/act/front_end/model_synthesis.py index ccc438d91..d076e40e9 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 @@ -27,7 +28,7 @@ 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 @@ -369,7 +370,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 +382,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] @@ -395,7 +402,10 @@ def model_synthesis(creator: str = 'torchvision') -> Dict[Tuple[str, str, str, s 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 +419,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 +430,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.front_end.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: diff --git a/act/front_end/spec_creator_base.py b/act/front_end/spec_creator_base.py index 7cdd3adb0..4711ebcdc 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.front_end.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/pipeline/cli.py b/act/pipeline/cli.py index 69d114b38..9cd8ab5fb 100644 --- a/act/pipeline/cli.py +++ b/act/pipeline/cli.py @@ -33,6 +33,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.pipeline.config import PipelineConfig _FUZZ_MUTATION_WEIGHT_KEYS = frozenset( @@ -169,6 +170,77 @@ def _collect_fuzzing_overrides(args: Any) -> dict[str, Any]: return overrides +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 + + bab_attr_map = { + "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", + } + for key, attr in 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["bab_per_class_alpha"] = str(args.bab_per_class_alpha).lower() == "true" + if getattr(args, "bab_no_incremental_start", None) is not None: + overrides["bab_incremental_start_enabled"] = not args.bab_no_incremental_start + + val_attr_map = { + "solvers": "solvers", + "tf_modes": "tf_modes", + "samples": "samples", + "per_neuron_topk": "per_neuron_topk", + "bounds_tolerance": "bounds_tolerance", + "batch_sizes": "batch_sizes", + } + for key, attr in 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.bab.per_class_alpha else "false" + args.bab_no_incremental_start = not config.bab.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.""" @@ -442,7 +514,8 @@ 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}") @@ -878,29 +951,13 @@ 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), - ) + config = PipelineConfig.from_yaml(**_collect_pipeline_config_overrides(args)).bab budget = float(getattr(args, "timeout", 60.0) or 60.0) spec_layers = gather_input_spec_layers(net) @@ -1360,10 +1417,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 +1430,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).", ) @@ -1530,26 +1589,26 @@ def main(): validation_group.add_argument( "--solvers", nargs="+", - default=["gurobi", "torchlp"], - help="Solvers for Level 1 validation (default: gurobi torchlp)", + default=None, + help="Solvers for Level 1 validation (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 Level 2 bounds validation: interval, hybridz, dual (default: from config.yaml)", ) 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 +1617,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 " @@ -1599,6 +1658,8 @@ def main(): args = parser.parse_args() + _apply_pipeline_config_defaults(args) + # Initialize device manager from CLI arguments initialize_from_args(args) diff --git a/act/pipeline/config.py b/act/pipeline/config.py new file mode 100644 index 000000000..fcec94a39 --- /dev/null +++ b/act/pipeline/config.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass, fields +from pathlib import Path +from typing import Any, Optional + +import yaml + +from act.back_end.config import BaBConfig +from act.pipeline.fuzzing.actfuzzer import FuzzingConfig + +_DEFAULT_YAML = Path(__file__).parent / "config.yaml" + + +@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 + validation: ValidationConfig + + @classmethod + def from_yaml( + cls, + config_path: Optional[str | Path] = None, + **overrides: Any, + ) -> "PipelineConfig": + path = Path(config_path) if config_path else _DEFAULT_YAML + if not path.exists(): + raise FileNotFoundError( + f"Pipeline config not found: {path}\nExpected: act/pipeline/config.yaml" + ) + + 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_") + val_overrides = _strip_prefixed_overrides(overrides, "val_") + + fuzzing = FuzzingConfig.from_yaml(path, **fuzz_overrides) + bab_data = ((yaml_data.get("verification") or {}).get("bab") or {}) + validation_data = yaml_data.get("validation") or {} + + bab = BaBConfig(**_merge_dataclass_fields(BaBConfig, bab_data, bab_overrides)) + validation = ValidationConfig( + **_merge_dataclass_fields(ValidationConfig, validation_data, val_overrides) + ) + return cls(fuzzing=fuzzing, bab=bab, 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 diff --git a/act/pipeline/config.yaml b/act/pipeline/config.yaml new file mode 100644 index 000000000..f2057a1ee --- /dev/null +++ b/act/pipeline/config.yaml @@ -0,0 +1,48 @@ +# ACT Pipeline Configuration + +fuzzing: + max_iterations: 10000 + timeout_seconds: 3600.0 + coverage_strategy: "GlobalCov" + activation_threshold: 0.0001 + mutation_weights: + gradient: 0 + pgd: 0.4 + activation: 0.3 + boundary: 0.2 + random: 0.1 + perturb_mode: "adaptive_scalar" + perturb_scale: 0.1 + seed_selection_strategy: "energy" + save_counterexamples: true + output_dir: "fuzzing_results" + report_interval: 500 + verbose: 1 + trace_level: 0 + trace_sample_rate: 1 + trace_storage: "json" + trace_output: null + stop_on_first_violation: false + +verification: + bab: + solver_tier: "dual_alpha_eta" + max_depth: 8 + max_nodes: 100 + branching_method: "random" + bounding_method: "random" + bounding_order: "depth_lb" + sa_cooling_rate: 0.99 + frontier_cap: 0 + input_split_fanout: 2 + per_class_alpha: true + incremental_start_enabled: true + provenance_enabled: false + +validation: + solvers: ["gurobi", "torchlp"] + tf_modes: ["interval"] + samples: 10 + per_neuron_topk: 10 + bounds_tolerance: "auto" + batch_sizes: null diff --git a/act/pipeline/fuzzing/actfuzzer.py b/act/pipeline/fuzzing/actfuzzer.py index 88e90c536..49a31adca 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 @@ -116,7 +116,7 @@ def from_yaml( Load FuzzingConfig from YAML file with optional overrides. Args: - config_path: Path to config YAML file (default: act/pipeline/fuzzing/config.yaml) + config_path: Path to config YAML file (default: act/pipeline/config.yaml) **overrides: Keyword arguments to override YAML values Returns: @@ -134,7 +134,7 @@ def from_yaml( """ # Default config path if config_path is None: - config_path = Path(get_project_root()) / "act/pipeline/fuzzing/config.yaml" + config_path = Path(get_project_root()) / "act/pipeline/config.yaml" else: config_path = Path(config_path) @@ -142,7 +142,7 @@ def from_yaml( if not config_path.exists(): raise FileNotFoundError( f"Configuration file not found: {config_path}\n" - f"Expected location: act/pipeline/fuzzing/config.yaml" + f"Expected location: act/pipeline/config.yaml" ) # Load YAML @@ -277,8 +277,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 +354,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: @@ -494,10 +494,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, 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..a97dac68f 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/pipeline/config.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/util/path_config.py b/act/util/path_config.py index 52a040bd0..f89d0f5f2 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 @@ -174,46 +175,38 @@ def get_vnnlib_data_root() -> str: def get_spec_config_root() -> str: - """Get spec configuration directory (configs/specs/), creating if needed.""" + """Get the consolidated front-end config directory.""" from pathlib import Path - config_root = Path(get_project_root()) / 'configs' / 'specs' - config_root.mkdir(parents=True, exist_ok=True) - return str(config_root) + return str(Path(get_project_root()) / 'act' / 'front_end') def get_default_spec_config_path() -> str: - """Get path to default spec configuration.""" + """Get path to the consolidated front-end configuration.""" from pathlib import Path - return str(Path(get_spec_config_root()) / 'default_spec_config.yaml') + return str(Path(get_spec_config_root()) / '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 - """ + """Resolve a named spec config to the consolidated front-end config path.""" + import yaml 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()}") + + key = name[:-5] if name.endswith('.yaml') else name + path = Path(get_default_spec_config_path()) + with open(path) as f: + specs = (yaml.safe_load(f) or {}).get('specs', {}) + if key not in specs: + raise FileNotFoundError(f"Spec config '{key}' not found in {path}") 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')]) + """List available spec configuration names.""" + import yaml + + with open(get_default_spec_config_path()) as f: + specs = (yaml.safe_load(f) or {}).get('specs', {}) + return sorted(specs) # ============================================================================ 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)]) From 8bf6da2ee8202c106a93897a77ece48679c7ed2b Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Fri, 10 Jul 2026 15:34:43 +1000 Subject: [PATCH 03/27] refactor(config): centralize all config under act/config/ Merge the three per-tier config modules into one act/config/config.py and move the yaml files to act/config/{backend,pipeline,frontend}_config.yaml. Update every importer to act.config.config, repoint runtime yaml paths (FuzzingConfig, --backend-config help, CI act-bab.yml hashFiles), and fix the gen_config path and the three _DEFAULT_YAML constants for the new location. FuzzingConfig is imported lazily inside PipelineConfig.from_yaml to avoid an import cycle. Pure relocation, behavior-preserving: all config snapshots byte-identical, imports clean (no cycle), soundness 141/141. --- .github/workflows/act-bab.yml | 2 +- act/back_end/bab/__init__.py | 2 +- act/back_end/bab/bab.py | 2 +- act/back_end/cli.py | 12 +- act/back_end/hybridz_tf/hybridz_tf.py | 2 +- act/back_end/solver/solver_torchlp.py | 2 +- act/config/__init__.py | 0 .../backend_config.yaml} | 2 +- act/{back_end => config}/config.py | 138 ++++++++++++++++-- .../frontend_config.yaml} | 0 .../pipeline_config.yaml} | 0 act/front_end/cli.py | 2 +- act/front_end/config.py | 44 ------ act/front_end/model_synthesis.py | 2 +- act/front_end/spec_creator_base.py | 2 +- act/pipeline/cli.py | 4 +- act/pipeline/config.py | 77 ---------- act/pipeline/fuzzing/actfuzzer.py | 6 +- act/pipeline/fuzzing/mutations.py | 2 +- act/pipeline/verification/vnncomp_runner.py | 2 +- 20 files changed, 145 insertions(+), 158 deletions(-) create mode 100644 act/config/__init__.py rename act/{back_end/config.yaml => config/backend_config.yaml} (99%) rename act/{back_end => config}/config.py (85%) rename act/{front_end/config.yaml => config/frontend_config.yaml} (100%) rename act/{pipeline/config.yaml => config/pipeline_config.yaml} (100%) delete mode 100644 act/front_end/config.py delete mode 100644 act/pipeline/config.py diff --git a/.github/workflows/act-bab.yml b/.github/workflows/act-bab.yml index 07284b9d1..758bc8c7b 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/back_end/examples/config_gen_act_net.yaml', 'act/back_end/net_factory.py', 'act/config/backend_config.yaml') }} restore-keys: | act-nets- 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..7fc6c9aaf 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, VALID_SOLVER_TIERS from act.back_end.bab.node import ( BabNode, SubproblemBatch, diff --git a/act/back_end/cli.py b/act/back_end/cli.py index 12759a293..a6b521c94 100644 --- a/act/back_end/cli.py +++ b/act/back_end/cli.py @@ -24,7 +24,7 @@ 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 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 @@ -62,7 +62,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, SolverConfig, TFConfig + from act.config.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig, SolverConfig, TFConfig existing_options = { option @@ -128,7 +128,7 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk def _backend_override_keys_from_dataclasses() -> set[str]: - from act.back_end.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig, SolverConfig, TFConfig + from act.config.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig, SolverConfig, TFConfig keys = { fld.name @@ -1248,7 +1248,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_config.yaml)", ) # Common options @@ -1281,7 +1281,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, @@ -1424,7 +1424,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 diff --git a/act/back_end/hybridz_tf/hybridz_tf.py b/act/back_end/hybridz_tf/hybridz_tf.py index 119568954..03e5419e8 100644 --- a/act/back_end/hybridz_tf/hybridz_tf.py +++ b/act/back_end/hybridz_tf/hybridz_tf.py @@ -20,7 +20,7 @@ import torch from typing import Dict, Optional -from act.back_end.config import TFConfig +from act.config.config import TFConfig from act.back_end.core import Bounds, Fact, Layer, Net, ConSet from act.back_end.transfer_functions import TransferFunction from act.back_end.layer_schema import LayerKind diff --git a/act/back_end/solver/solver_torchlp.py b/act/back_end/solver/solver_torchlp.py index 8dfb73b1a..28d0a4282 100644 --- a/act/back_end/solver/solver_torchlp.py +++ b/act/back_end/solver/solver_torchlp.py @@ -3,7 +3,7 @@ from typing import Optional import torch -from act.back_end.config import SolverConfig +from act.config.config import SolverConfig from act.back_end.solver.solver_base import ( Solver, SolveStatus, diff --git a/act/config/__init__.py b/act/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/act/back_end/config.yaml b/act/config/backend_config.yaml similarity index 99% rename from act/back_end/config.yaml rename to act/config/backend_config.yaml index 69f93694a..86d1cd7be 100644 --- a/act/back_end/config.yaml +++ b/act/config/backend_config.yaml @@ -126,7 +126,7 @@ backend: # ── Dual-tier knobs (α/η optimization for DualSolver BaB) ──────────── # Solver tier for BaB bound computation: - # Source of truth: act.back_end.config.VALID_SOLVER_TIERS + # Source of truth: act.config.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]) diff --git a/act/back_end/config.py b/act/config/config.py similarity index 85% rename from act/back_end/config.py rename to act/config/config.py index 019237157..148d7e6ed 100644 --- a/act/back_end/config.py +++ b/act/config/config.py @@ -1,20 +1,16 @@ -# ===- 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_config.yaml" +_PIPELINE_YAML = Path(__file__).parent / "pipeline_config.yaml" +_FRONTEND_YAML = Path(__file__).parent / "frontend_config.yaml" _VALID_SOLVERS = {"auto", "gurobi", "torchlp", "dual", "hybridz"} _VALID_DEVICES = {"cpu", "cuda", "gpu"} @@ -82,7 +78,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_config.yaml BaBConfig.from_yaml(path, **kw) # custom YAML + overrides """ @@ -255,11 +251,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_config.yaml" ) with open(path) as f: @@ -293,7 +289,7 @@ def to_yaml(self, path: Union[str, Path]) -> Path: # --------------------------------------------------------------------------- _DEFAULT_GEN_CONFIG = str( - Path(__file__).parent / "examples" / "config_gen_act_net.yaml" + Path(__file__).parent.parent / "back_end" / "examples" / "config_gen_act_net.yaml" ) @@ -368,7 +364,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_config.yaml``; CLI flags and environment variables override it at load time. Construction:: @@ -496,7 +492,7 @@ def from_yaml( - ``tf_`` → ``TFConfig.`` - ``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}") @@ -729,3 +725,115 @@ def build_vnncomp_bab_config( if llm_model: cfg.llm_probe_model = llm_model return 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 + 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_config.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_") + val_overrides = _strip_prefixed_overrides(overrides, "val_") + + fuzzing = FuzzingConfig.from_yaml(path, **fuzz_overrides) + bab_data = ((yaml_data.get("verification") or {}).get("bab") or {}) + validation_data = yaml_data.get("validation") or {} + + bab = BaBConfig(**_merge_dataclass_fields(BaBConfig, bab_data, bab_overrides)) + validation = ValidationConfig( + **_merge_dataclass_fields(ValidationConfig, validation_data, val_overrides) + ) + return cls(fuzzing=fuzzing, bab=bab, 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 + + +# --------------------------------------------------------------------------- +# 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/front_end/config.yaml b/act/config/frontend_config.yaml similarity index 100% rename from act/front_end/config.yaml rename to act/config/frontend_config.yaml diff --git a/act/pipeline/config.yaml b/act/config/pipeline_config.yaml similarity index 100% rename from act/pipeline/config.yaml rename to act/config/pipeline_config.yaml diff --git a/act/front_end/cli.py b/act/front_end/cli.py index e6ef85a6d..4217dfd28 100644 --- a/act/front_end/cli.py +++ b/act/front_end/cli.py @@ -20,7 +20,7 @@ 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]]: diff --git a/act/front_end/config.py b/act/front_end/config.py deleted file mode 100644 index 26a3f88ab..000000000 --- a/act/front_end/config.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Front-end configuration loading.""" - -from __future__ import annotations - -from copy import deepcopy -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Optional, Union - -import yaml - -_DEFAULT_YAML = Path(__file__).with_name("config.yaml") - - -@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 _DEFAULT_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/front_end/model_synthesis.py b/act/front_end/model_synthesis.py index d076e40e9..a31f3e98b 100644 --- a/act/front_end/model_synthesis.py +++ b/act/front_end/model_synthesis.py @@ -432,7 +432,7 @@ def model_synthesis( elif creator == 'bert': from act.front_end.bert_loader.create_specs import BertSpecCreator - from act.front_end.config import FrontEndConfig + from act.config.config import FrontEndConfig print(f"\n📊 Attempting to use BertSpecCreator...") front_end_config = FrontEndConfig.from_yaml(**(text_verification_overrides or {})) diff --git a/act/front_end/spec_creator_base.py b/act/front_end/spec_creator_base.py index 4711ebcdc..fc713cc0c 100644 --- a/act/front_end/spec_creator_base.py +++ b/act/front_end/spec_creator_base.py @@ -14,7 +14,7 @@ import torch from act.front_end.specs import InputSpec, OutputSpec, InKind, OutKind -from act.front_end.config import FrontEndConfig +from act.config.config import FrontEndConfig logger = logging.getLogger(__name__) diff --git a/act/pipeline/cli.py b/act/pipeline/cli.py index 9cd8ab5fb..e1dd5206a 100644 --- a/act/pipeline/cli.py +++ b/act/pipeline/cli.py @@ -20,7 +20,7 @@ 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.config.config import VALID_SOLVER_TIERS logger = logging.getLogger(__name__) from act.front_end.specs import OutputSpec @@ -33,7 +33,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.pipeline.config import PipelineConfig +from act.config.config import PipelineConfig _FUZZ_MUTATION_WEIGHT_KEYS = frozenset( diff --git a/act/pipeline/config.py b/act/pipeline/config.py deleted file mode 100644 index fcec94a39..000000000 --- a/act/pipeline/config.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, fields -from pathlib import Path -from typing import Any, Optional - -import yaml - -from act.back_end.config import BaBConfig -from act.pipeline.fuzzing.actfuzzer import FuzzingConfig - -_DEFAULT_YAML = Path(__file__).parent / "config.yaml" - - -@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 - validation: ValidationConfig - - @classmethod - def from_yaml( - cls, - config_path: Optional[str | Path] = None, - **overrides: Any, - ) -> "PipelineConfig": - path = Path(config_path) if config_path else _DEFAULT_YAML - if not path.exists(): - raise FileNotFoundError( - f"Pipeline config not found: {path}\nExpected: act/pipeline/config.yaml" - ) - - 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_") - val_overrides = _strip_prefixed_overrides(overrides, "val_") - - fuzzing = FuzzingConfig.from_yaml(path, **fuzz_overrides) - bab_data = ((yaml_data.get("verification") or {}).get("bab") or {}) - validation_data = yaml_data.get("validation") or {} - - bab = BaBConfig(**_merge_dataclass_fields(BaBConfig, bab_data, bab_overrides)) - validation = ValidationConfig( - **_merge_dataclass_fields(ValidationConfig, validation_data, val_overrides) - ) - return cls(fuzzing=fuzzing, bab=bab, 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 diff --git a/act/pipeline/fuzzing/actfuzzer.py b/act/pipeline/fuzzing/actfuzzer.py index 49a31adca..89dca731f 100644 --- a/act/pipeline/fuzzing/actfuzzer.py +++ b/act/pipeline/fuzzing/actfuzzer.py @@ -116,7 +116,7 @@ def from_yaml( Load FuzzingConfig from YAML file with optional overrides. Args: - config_path: Path to config YAML file (default: act/pipeline/config.yaml) + config_path: Path to config YAML file (default: act/config/pipeline_config.yaml) **overrides: Keyword arguments to override YAML values Returns: @@ -134,7 +134,7 @@ def from_yaml( """ # Default config path if config_path is None: - config_path = Path(get_project_root()) / "act/pipeline/config.yaml" + config_path = Path(get_project_root()) / "act/config/pipeline_config.yaml" else: config_path = Path(config_path) @@ -142,7 +142,7 @@ def from_yaml( if not config_path.exists(): raise FileNotFoundError( f"Configuration file not found: {config_path}\n" - f"Expected location: act/pipeline/config.yaml" + f"Expected location: act/config/pipeline_config.yaml" ) # Load YAML diff --git a/act/pipeline/fuzzing/mutations.py b/act/pipeline/fuzzing/mutations.py index a97dac68f..a8b2afbb5 100644 --- a/act/pipeline/fuzzing/mutations.py +++ b/act/pipeline/fuzzing/mutations.py @@ -60,7 +60,7 @@ ### Configuration -Set in `act/pipeline/config.yaml`: +Set in `act/config/pipeline_config.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) diff --git a/act/pipeline/verification/vnncomp_runner.py b/act/pipeline/verification/vnncomp_runner.py index 70d9c30d2..a87b2e6d5 100755 --- a/act/pipeline/verification/vnncomp_runner.py +++ b/act/pipeline/verification/vnncomp_runner.py @@ -39,7 +39,7 @@ def run_vnncomp_instance(args) -> None: 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.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 e6c85243fe707af0f5e031a74c6abb7bb4c295f8 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Sat, 11 Jul 2026 10:45:02 +1000 Subject: [PATCH 04/27] refactor(config): move tier CLIs into act/config/; prune+dedup yamls; docs - Move act/{back_end,pipeline,front_end}/cli.py -> act/config/{backend,pipeline, frontend}_cli.py (matching the yaml names); repoint the three __main__.py. Entry points 'python -m act.X' unchanged; sub-loader CLIs untouched. - Remove dead HybridZConfig.engine (surfaced in yaml but never read anywhere). - Dedup pipeline_config.yaml verification.bab to its 3 real overrides (solver_tier/max_depth/max_nodes); the other keys just restated BaBConfig defaults. Behavior-preserving (absent keys fall to the same defaults). - Update READMEs (act + tiers + data/vnnlib) for config centralization + CLI move. Verified: pipeline bab byte-identical, backend identical minus engine, all python -m act.{back_end,pipeline,front_end} + sub-loaders EXIT 0, soundness 141/141. --- act/README.md | 33 ++++++++++++------- act/back_end/README.md | 2 +- act/back_end/__main__.py | 2 +- .../cli.py => config/backend_cli.py} | 0 act/config/backend_config.yaml | 1 - act/config/config.py | 1 - .../cli.py => config/frontend_cli.py} | 0 .../cli.py => config/pipeline_cli.py} | 0 act/config/pipeline_config.yaml | 11 ++----- act/front_end/README.md | 5 ++- act/front_end/__main__.py | 2 +- act/pipeline/README.md | 2 -- act/pipeline/__main__.py | 2 +- act/pipeline/fuzzing/README.md | 8 ++--- data/vnnlib/README.md | 8 ++--- 15 files changed, 38 insertions(+), 39 deletions(-) rename act/{back_end/cli.py => config/backend_cli.py} (100%) rename act/{front_end/cli.py => config/frontend_cli.py} (100%) rename act/{pipeline/cli.py => config/pipeline_cli.py} (100%) diff --git a/act/README.md b/act/README.md index 0c8f0e73a..7c8521dc0 100644 --- a/act/README.md +++ b/act/README.md @@ -33,8 +33,16 @@ 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_config.yaml # Back-end specific configuration +│ ├── pipeline_config.yaml # Pipeline specific configuration +│ ├── frontend_config.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 +59,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 +97,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 +129,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 +143,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_config.yaml`) - `--list-examples`: List all available example networks - `--info`: Display network structure and details - `--verify`: Run verification (single-shot or branch-and-bound) @@ -263,7 +268,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 +288,12 @@ 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, SolverConfig, etc. + - **`backend_config.yaml`**: Network generation and backend defaults + - **`pipeline_config.yaml`**: Pipeline testing and fuzzing defaults + - **`frontend_config.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,7 +437,7 @@ 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` +These files are generated from the YAML configuration `act/config/backend_config.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 YAML configuration and the factory rather than hand-editing the JSON files. @@ -434,7 +445,7 @@ 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 `backend_config.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/cli.py b/act/config/backend_cli.py similarity index 100% rename from act/back_end/cli.py rename to act/config/backend_cli.py diff --git a/act/config/backend_config.yaml b/act/config/backend_config.yaml index 86d1cd7be..04e9c1ff8 100644 --- a/act/config/backend_config.yaml +++ b/act/config/backend_config.yaml @@ -33,7 +33,6 @@ backend: hybridz: timeout: null - engine: "dense_hz_objbound" # ── BaBVerification ──────────────────────────────────────────────────────── diff --git a/act/config/config.py b/act/config/config.py index 148d7e6ed..399c2cc74 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -329,7 +329,6 @@ def __post_init__(self) -> None: @dataclass class HybridZConfig: timeout: Optional[float] = None - engine: str = "dense_hz_objbound" @dataclass diff --git a/act/front_end/cli.py b/act/config/frontend_cli.py similarity index 100% rename from act/front_end/cli.py rename to act/config/frontend_cli.py diff --git a/act/pipeline/cli.py b/act/config/pipeline_cli.py similarity index 100% rename from act/pipeline/cli.py rename to act/config/pipeline_cli.py diff --git a/act/config/pipeline_config.yaml b/act/config/pipeline_config.yaml index f2057a1ee..d8ce8b708 100644 --- a/act/config/pipeline_config.yaml +++ b/act/config/pipeline_config.yaml @@ -26,18 +26,11 @@ fuzzing: verification: bab: + # Sparse override: only keys that differ from BaBConfig defaults are set + # here; every other BaBConfig field is inherited from its dataclass default. solver_tier: "dual_alpha_eta" max_depth: 8 max_nodes: 100 - branching_method: "random" - bounding_method: "random" - bounding_order: "depth_lb" - sa_cooling_rate: 0.99 - frontier_cap: 0 - input_split_fanout: 2 - per_class_alpha: true - incremental_start_enabled: true - provenance_enabled: false validation: solvers: ["gurobi", "torchlp"] 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/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..cb69893ff 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_config.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_config.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_config.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_config.yaml with overrides) config = FuzzingConfig.from_yaml( max_iterations=5000, trace_level=1, # Enable basic tracing 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 From e096bdc8511af26ef1ac699f0ea2a4a6609ed86e Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Sat, 11 Jul 2026 11:24:37 +1000 Subject: [PATCH 05/27] fix(ci): repoint codecov ignore globs to relocated act/config/*_cli.py The three tier CLIs were git mv'd to act/config/ but .codecov.yml still ignored them at their old act/{back_end,front_end,pipeline}/cli.py paths, so ~773 lines of intentionally-excluded argparse plumbing (48-70% covered) started counting toward project coverage (~1% drop). Restore the intended exclusion at the new paths. --- .codecov.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 1abbe3cbd..01de212ea 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -44,9 +44,9 @@ 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/front_end/torchvision_loader/cli.py" - "act/back_end/serialization/test_serialization.py" - "vnncomp/" From 642fd0e79a57f29d7a37227817876a6863ed8a8d Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Sat, 11 Jul 2026 11:34:18 +1000 Subject: [PATCH 06/27] docs(config): reorganize the 3 yaml files into commented blocks User-facing config files are edited by hand, so group related options into labelled blocks and document each key with its purpose and valid values. - backend_config.yaml: order runtime -> cascade -> solver_config -> tf -> hybridz -> bab -> generation; split the 57-key bab block into labelled sub-blocks (search limits / branching+bounding / dual alpha-eta / refinement / auto-batch / llm-probe / bert-text / diagnostics). - pipeline_config.yaml: comment every fuzzing key (budget / coverage / mutation / seeding / output / tracing / early-stop) with valid enum values; annotate validation. - frontend_config.yaml: add a specs-schema header (valid input/output kinds, combination strategies), per-preset labels, and text_verification comments. Comments/reordering only: no key, value, or nesting changed. Verified by a resolved-config snapshot (all 3 configs load byte-identical before/after), entry points EXIT 0, and soundness harness EXIT 0 (all SOUND BOUNDS passed). --- act/config/backend_config.yaml | 189 +++++++++++++++++++------------- act/config/frontend_config.yaml | 44 ++++++-- act/config/pipeline_config.yaml | 96 ++++++++++++---- 3 files changed, 225 insertions(+), 104 deletions(-) diff --git a/act/config/backend_config.yaml b/act/config/backend_config.yaml index 04e9c1ff8..45f8feab5 100644 --- a/act/config/backend_config.yaml +++ b/act/config/backend_config.yaml @@ -1,20 +1,29 @@ +# ═══════════════════════════════════════════════════════════════════════════ # 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 → solver_config → tf → hybridz +# → bab (branch-and-bound) → generation (net_factory) backend: - # ── Verification Runtime (global) ──────────────────────────────────────────────────── + # ───────────────────────────────────────────────────────────────────────── + # Runtime selectors (apply to every back-end command) + # ───────────────────────────────────────────────────────────────────────── # Solver backend for LP/MILP verification. - # "auto" – try Gurobi first, fall back to TorchLP + # "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 solver: "torchlp" # Compute device. @@ -31,55 +40,65 @@ backend: # Print extra diagnostic output across all commands. verbose: false - hybridz: - timeout: null - - # ── BaBVerification ──────────────────────────────────────────────────────── + # ───────────────────────────────────────────────────────────────────────── + # 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 before BaB. + # 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. + # 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 (solver_config) + # ───────────────────────────────────────────────────────────────────────── solver_config: - # TorchLP equality penalty weight. - rho_eq: 10.0 - # TorchLP inequality penalty weight. - rho_ineq: 10.0 - # TorchLP Adam iteration cap. - max_iter: 2000 - # Feasibility tolerance for SAT reporting. - tol_feas: 0.0001 - # TorchLP Adam learning rate. - lr: 0.01 - # TorchLP Adam beta1. - beta1: 0.9 - # TorchLP Adam beta2. - beta2: 0.999 - # TorchLP Adam weight decay. - weight_decay: 0.0 - # Variable-count threshold for large-N overrides. - large_n_threshold: 20000 - # Large-N iteration cap. - large_n_max_iter: 800 - # Large-N feasibility tolerance. - large_n_tol: 0.001 - # Stop after this many stagnant feasibility checks. - stagnation_patience: 300 - # Minimum feasibility improvement to reset stagnation. - stagnation_tol: 0.00001 - # Iteration stride between feasibility checks. - feas_check_stride: 5 - + # -- 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 + + # ───────────────────────────────────────────────────────────────────────── + # Transfer-function limits (tf) + # ───────────────────────────────────────────────────────────────────────── tf: # HybridZ generator/input dimension fallback limit. hz_max_input_dim: 1024 + # ───────────────────────────────────────────────────────────────────────── + # HybridZ solver (hybridz) + # ───────────────────────────────────────────────────────────────────────── + hybridz: + # Per-call wall-clock budget in seconds; null = no HybridZ-specific limit. + timeout: null + + # ═════════════════════════════════════════════════════════════════════════ + # Branch-and-Bound refinement (bab) + # ═════════════════════════════════════════════════════════════════════════ bab: + + # -- Enable + search-tree limits -------------------------------------- + # Enable Branch-and-Bound refinement (true) or single execution (false). enabled: true @@ -95,13 +114,17 @@ backend: # Uniform fanout for input-domain splits; 2 preserves binary splitting. input_split_fanout: 2 + # -- Branching & bounding strategy ------------------------------------ + # 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. + # "gain" – joint multi-neuron split by optimized bound gain + # "width" – width-based baseline + # NOTE: "babsr"/"fsb"/"gain" perform real neuron-split only on a dual tier + # (solver_tier "dual_alpha" or "dual_alpha_eta"); on "lp" they fall + # back to the width-based baseline. branching_method: "random" # How to select the next wave of subproblems when the pool exceeds the @@ -111,7 +134,8 @@ backend: # and smaller lower-bound (closer to a counterexample) rank higher bounding_method: "random" - # TopKBounding order policy: depth_lb (default), greedy, or sa. + # TopKBounding order policy (only used when bounding_method = "topk"). + # "depth_lb" (default) | "greedy" | "sa" (simulated annealing) bounding_order: "depth_lb" # Simulated-annealing cooling rate for bounding_order = "sa". @@ -123,17 +147,17 @@ backend: bounding_depth_weight: 0.5 bounding_bound_weight: 0.5 - # ── Dual-tier knobs (α/η optimization for DualSolver BaB) ──────────── - # Solver tier for BaB bound computation: + # -- Dual-tier α/η optimization (DualSolver BaB) ---------------------- + + # Solver tier for BaB bound computation. # Source of truth: act.config.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) + # "lp" – LP/MILP backend + # "dual" – DualSolver (linear-relaxation dual, no iterative opt) + # "dual_alpha" – DualSolver + Lagrange-relaxed lower-slope opt (Adam on α ∈ [0,1]) + # "dual_alpha_eta" – DualSolver + joint α + η (split-constraint KKT multipliers) solver_tier: "lp" - # Number of Adam iterations for α/η optimization (only used in - # dual_alpha / dual_alpha_eta tiers). + # Number of Adam iterations for α/η optimization (dual_alpha / dual_alpha_eta only). dual_n_iters: 50 # Learning rate for α (ReLU lower-bound slope, clamped to [0, 1]). @@ -151,49 +175,53 @@ backend: # 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 - - # Refine only eta in child dual subproblems. + # Refine only η in child dual subproblems (freeze α inherited from parent). eta_only_children: false + # -- Intermediate-bound refinement ------------------------------------ + # Number of root pre-split levels before the main loop. presplit_levels: 0 - # Root intermediate-bound refinement mode. + # Root intermediate-bound refinement mode: "none" | "auto" | "all". intermediate_refine: "none" - # Width ratio threshold for auto intermediate refinement. + # Width ratio threshold for intermediate_refine = "auto". intermediate_refine_ratio: 10.0 - # Reuse root intermediate bounds in descendant subproblems. + # Reuse root intermediate bounds in descendant subproblems (dual tiers). reuse_root_bounds: false - # Per-subproblem intermediate refinement mode. + # Per-subproblem intermediate refinement mode: "none" | "tail" | "all" + # (requires reuse_root_bounds). per_subproblem_refine: "none" - # Adam iterations for per-subproblem refinement rows. + # Adam iterations for per-subproblem refinement rows (0 = single fixed-slope pass). per_subproblem_refine_iters: 0 - # Row cap for per-subproblem refinement. + # Row cap for per-subproblem refinement (top-cap by interval width). per_subproblem_refine_rows_cap: 64 + # Number of simultaneous neuron split levels (gain branching only); 1 = single split. + multi_split_levels: 1 + + # -- Auto batch sizing (max_batch_size = "auto") ---------------------- + # Fraction of available GPU memory targeted by auto batch sizing. auto_batch_safety: 0.55 - # Hard cap for auto-sized BaB batches. + # Hard cap for auto-sized BaB batches (also the CPU fallback). auto_batch_cap: 2048 # Floor for auto-sized BaB batches. auto_batch_floor: 8 - # Number of simultaneous neuron split levels. - multi_split_levels: 1 + # -- LLM-probe controller (optional experimental branching aid) ------- # Enable the LLM-probe controller. llm_probe_enabled: false - # LLM-probe provider backend. + # Provider backend: "mock" | "openrouter" | "openai" | "glm" | "minimax" | "claude_cli". llm_probe_backend: "mock" # LLM-probe model name. @@ -229,28 +257,30 @@ backend: # Consecutive probe failures before disabling the controller. llm_probe_max_failures: 3 - # Decision types the LLM may steer. + # Comma-separated decision types the LLM may steer: + # "split" | "frontier" | "refine" | "neuron" | "input_split". llm_probe_decisions: "split,frontier,refine" # Log LLM-probe decisions. llm_probe_log: false - # Print verbose BaB diagnostics. - verbose: false + # -- BERT / text verification ----------------------------------------- - # Public BERT verification method selector. + # Public BERT verification method selector: null (off) or one of + # "planar" | "rule" | "alpha" | "ibp" | "discrete". When set, it overrides + # baf / alpha_mode / solver_tier per the method's preset. method: null # Enable backward attention form for BERT methods. baf: true - # BERT alpha handling mode. + # BERT alpha handling mode: "fixed" | "rule" | "optimized" | "none". alpha_mode: "fixed" # Norm p for perturbation semantics. p: 2.0 - # Number of perturbed words for text verification. + # Number of perturbed words for text verification (1 or 2). perturbed_words: 1 # Initial epsilon for text verification. @@ -268,11 +298,20 @@ backend: # Alpha optimization steps for text methods. alpha_opt_steps: 1000 - # ── Network Generation (net_factory) ──────────────────────────────────── + # -- Diagnostics ------------------------------------------------------ + + # Track logical BaB node ids and parent ids in TopKBounding. + provenance_enabled: false + + # Print verbose BaB diagnostics. + verbose: false + + # ═════════════════════════════════════════════════════════════════════════ + # Network generation (net_factory) + # ═════════════════════════════════════════════════════════════════════════ # Controls how many networks to generate, where, and TF filtering. - # Architecture sampling DSL (family definitions, input/output specs) + # The 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" @@ -290,8 +329,8 @@ backend: name_prefix: "cfg_seed" # Transfer-function targets for layer filtering. - # null – no filtering, all layer types allowed - # ["interval"] – only layers supported by interval TF + # null – no filtering, all layer types allowed + # ["interval"] – only layers supported by interval TF # ["interval", "hybridz"] – layers supported by both TFs tf_targets: null diff --git a/act/config/frontend_config.yaml b/act/config/frontend_config.yaml index 4c2819631..8bc2a54f9 100644 --- a/act/config/frontend_config.yaml +++ b/act/config/frontend_config.yaml @@ -1,4 +1,28 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# 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 @@ -55,6 +79,7 @@ specs: - 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 @@ -118,6 +143,7 @@ specs: 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 @@ -148,12 +174,16 @@ specs: 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 - perturbed_words: 1 - eps: 1.0e-05 - max_eps: 0.01 - num_verify_iters: 5 - k: 1 - alpha_opt_steps: 1000 + 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/config/pipeline_config.yaml b/act/config/pipeline_config.yaml index d8ce8b708..223fedd81 100644 --- a/act/config/pipeline_config.yaml +++ b/act/config/pipeline_config.yaml @@ -1,41 +1,93 @@ +# ═══════════════════════════════════════════════════════════════════════════ # 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_config.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: - max_iterations: 10000 - timeout_seconds: 3600.0 + + # -- 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 + 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 - pgd: 0.4 - activation: 0.3 - boundary: 0.2 - random: 0.1 + 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" - save_counterexamples: true - output_dir: "fuzzing_results" - report_interval: 500 + + # -- 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 - trace_level: 0 - trace_sample_rate: 1 - trace_storage: "json" - trace_output: null + + # -- 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. - solver_tier: "dual_alpha_eta" - max_depth: 8 - max_nodes: 100 + # The full, documented BaB surface lives in backend_config.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 +# ───────────────────────────────────────────────────────────────────────────── +# 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 - per_neuron_topk: 10 - bounds_tolerance: "auto" - batch_sizes: null + 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 From e472c5808b7f5172ee106846d1ce85a859079099 Mon Sep 17 00:00:00 2001 From: BUPTlkj Date: Sat, 11 Jul 2026 19:17:54 +1000 Subject: [PATCH 07/27] feat(hybridz): complete frontend solver integration --- act/config/backend_cli.py | 26 ++++++++++++++---- act/config/pipeline_cli.py | 56 +++++++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 19 deletions(-) diff --git a/act/config/backend_cli.py b/act/config/backend_cli.py index a6b521c94..85fa5905a 100644 --- a/act/config/backend_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 @@ -32,6 +33,7 @@ _TF_MODES: tuple[str, ...] = ("interval", "hybridz") _SOLVERS: tuple[str, ...] = tuple(sorted(_VALID_SOLVERS)) +logger = logging.getLogger(__name__) def _strip_optional(tp: Any) -> Any: @@ -965,11 +967,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 +997,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( @@ -1295,10 +1299,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, backend_cfg.tf) + initialize_tf_mode(tf_mode, backend_cfg.tf) # Set the solver-mode global so verify_once / _verify_one_net can dispatch # dual ↔ LP-cascade without consulting the TF mode (refactor decoupled diff --git a/act/config/pipeline_cli.py b/act/config/pipeline_cli.py index e1dd5206a..796fa8d32 100644 --- a/act/config/pipeline_cli.py +++ b/act/config/pipeline_cli.py @@ -725,6 +725,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 @@ -775,10 +780,15 @@ def _verify_and_validate_cell( """ from act.back_end.verifier import verify_once + verify_kwargs = ( + {"timelimit": getattr(args, "timeout", None)} + if solver == "hybridz" + else {} + ) if args.validate_soundness: - results, facts = verify_once(net, collect_facts=True) + results, facts = verify_once(net, collect_facts=True, **verify_kwargs) else: - results = verify_once(net) + results = verify_once(net, **verify_kwargs) facts = None statuses = [r.status.name for r in results] print(f" {cell_label if cell_label is not None else tag}: {statuses}") @@ -806,10 +816,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 @@ -822,8 +830,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": @@ -1016,8 +1024,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": @@ -1093,14 +1101,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) @@ -1318,11 +1325,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. @@ -1516,7 +1525,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", @@ -1590,13 +1602,20 @@ def main(): "--solvers", nargs="+", default=None, - help="Solvers for Level 1 validation (default: from config.yaml)", + help=( + "Verification solvers: gurobi, torchlp, hybridz, or dual " + "(default: from config.yaml)" + ), ) validation_group.add_argument( "--tf-modes", nargs="+", default=None, - help="Transfer function modes for Level 2 bounds validation: interval, hybridz, dual (default: from config.yaml)", + 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", @@ -1657,8 +1676,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) From 7ef95eeefada3faa74ef41aa8d85eaf818e15a23 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Mon, 13 Jul 2026 10:03:15 +1000 Subject: [PATCH 08/27] config: derivation-based CLI<->YAML parity check (no hand-coded fields) + inline vnncomp runner - act.config.check_parity + CI derive CLI<->YAML<->dataclass parity from the config dataclasses; no hand-coded field/prefix lists. config.py is the sole reader of the backend/pipeline/frontend config YAMLs. - inline the VNN-COMP runner into vnncomp/act_run_instance.py; remove act/pipeline/verification/vnncomp_runner.py. --- .codecov.yml | 1 + .coveragerc | 1 + .github/workflows/act.config.yml | 43 +++++ act/config/backend_cli.py | 35 ++-- act/config/backend_config.yaml | 6 +- act/config/check_parity.py | 189 ++++++++++++++++++ act/config/config.py | 36 +++- act/config/frontend_cli.py | 35 ++-- act/config/pipeline_cli.py | 53 +++--- act/pipeline/fuzzing/actfuzzer.py | 49 +---- act/pipeline/verification/vnncomp_runner.py | 201 -------------------- act/util/path_config.py | 35 ---- vnncomp/act_run_instance.py | 188 +++++++++++++++++- 13 files changed, 536 insertions(+), 336 deletions(-) create mode 100644 .github/workflows/act.config.yml create mode 100644 act/config/check_parity.py delete mode 100755 act/pipeline/verification/vnncomp_runner.py diff --git a/.codecov.yml b/.codecov.yml index 01de212ea..264d2aab9 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -47,6 +47,7 @@ ignore: - "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.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/config/backend_cli.py b/act/config/backend_cli.py index f2f02f480..10bd88562 100644 --- a/act/config/backend_cli.py +++ b/act/config/backend_cli.py @@ -129,19 +129,32 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk add_group(TFConfig, "TF Config Overrides (generated)", "tf-", "tf_", set()) +# 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_", + "solver_config": "solver_", + "tf": "tf_", +} + + def _backend_override_keys_from_dataclasses() -> set[str]: - from act.config.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig, SolverConfig, TFConfig + from act.config.config import BackendConfig - keys = { - fld.name - for fld in fields(BackendConfig) - if fld.name not in {"bab", "generation", "hybridz", "solver_config", "tf"} - } - 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)) - keys.update(f"solver_{fld.name}" for fld in fields(SolverConfig)) - keys.update(f"tf_{fld.name}" for fld in fields(TFConfig)) + 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 diff --git a/act/config/backend_config.yaml b/act/config/backend_config.yaml index 45f8feab5..adeacb9d9 100644 --- a/act/config/backend_config.yaml +++ b/act/config/backend_config.yaml @@ -310,8 +310,10 @@ backend: # Network generation (net_factory) # ═════════════════════════════════════════════════════════════════════════ # Controls how many networks to generate, where, and TF filtering. - # The architecture sampling DSL (family definitions, input/output specs) - # lives in gen_config_path — a separate file with its own schema. + # The architecture-sampling DSL (families, sampling rules, input/output specs) + # is intentionally a SEPARATE file (gen_config_path): a large declarative DSL + # with its own schema and no dataclass/CLI mapping, so it does not belong in + # this flat, dataclass-backed settings file. generation: # Path to the architecture sampling config (families, specs, etc.). gen_config_path: "act/back_end/examples/config_gen_act_net.yaml" diff --git a/act/config/check_parity.py b/act/config/check_parity.py new file mode 100644 index 000000000..0ec4eb0cc --- /dev/null +++ b/act/config/check_parity.py @@ -0,0 +1,189 @@ +"""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_config.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, BackendConfig + 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}") + + # 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) + } + + 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, ValidationConfig + from act.pipeline.fuzzing.actfuzzer import FuzzingConfig + from act.config.pipeline_cli import ( + _FUZZ_OVERRIDE_SPEC, _PIPELINE_BAB_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) + bab_yaml = set(((pipeline_yaml.get("verification") or {}).get("bab") or {}).keys()) + print("[pipeline.verification.bab] (sparse override of backend_config.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)) + + +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/config/config.py b/act/config/config.py index 399c2cc74..6351c6dfd 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -404,14 +404,14 @@ class BackendConfig: solver_config: SolverConfig = field(default_factory=SolverConfig) tf: TFConfig = field(default_factory=TFConfig) - 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 + 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 --------------------------------------------------------- @@ -771,7 +771,9 @@ def from_yaml( bab_overrides = _strip_prefixed_overrides(overrides, "bab_") val_overrides = _strip_prefixed_overrides(overrides, "val_") - fuzzing = FuzzingConfig.from_yaml(path, **fuzz_overrides) + fuzzing = FuzzingConfig.from_mapping( + yaml_data.get("fuzzing") or {}, **fuzz_overrides + ) bab_data = ((yaml_data.get("verification") or {}).get("bab") or {}) validation_data = yaml_data.get("validation") or {} @@ -801,6 +803,22 @@ def _merge_dataclass_fields( 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_config.yaml" + ) + with open(path) as f: + yaml_data = yaml.safe_load(f) or {} + return yaml_data.get("fuzzing") or {} + + # --------------------------------------------------------------------------- # Front-end configuration loading # --------------------------------------------------------------------------- diff --git a/act/config/frontend_cli.py b/act/config/frontend_cli.py index 4217dfd28..fc108b369 100644 --- a/act/config/frontend_cli.py +++ b/act/config/frontend_cli.py @@ -30,6 +30,25 @@ def _parse_list_arg(value: Optional[str], item_type: type = str) -> Optional[lis 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 = { @@ -45,17 +64,11 @@ def _build_spec_overrides(args: argparse.Namespace) -> dict[str, Any] | None: def _build_text_verification_overrides(args: argparse.Namespace) -> dict[str, Any] | None: - keys = ( - 'method', - 'p', - 'perturbed_words', - 'eps', - 'max_eps', - 'num_verify_iters', - 'k', - 'alpha_opt_steps', - ) - overrides = {key: getattr(args, key) for key in keys if getattr(args, key) is not 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 diff --git a/act/config/pipeline_cli.py b/act/config/pipeline_cli.py index 796fa8d32..317846e39 100644 --- a/act/config/pipeline_cli.py +++ b/act/config/pipeline_cli.py @@ -170,6 +170,35 @@ 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) + ( + "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: @@ -182,19 +211,7 @@ def _collect_pipeline_config_overrides(args: Any) -> dict[str, Any]: value = _parse_mutation_weights(value) overrides[f"fuzz_{key}"] = value - bab_attr_map = { - "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", - } - for key, attr in bab_attr_map.items(): + for key, attr in _PIPELINE_BAB_ATTR_MAP.items(): value = getattr(args, attr, None) if value is not None: overrides[f"bab_{key}"] = value @@ -203,15 +220,7 @@ def _collect_pipeline_config_overrides(args: Any) -> dict[str, Any]: if getattr(args, "bab_no_incremental_start", None) is not None: overrides["bab_incremental_start_enabled"] = not args.bab_no_incremental_start - val_attr_map = { - "solvers": "solvers", - "tf_modes": "tf_modes", - "samples": "samples", - "per_neuron_topk": "per_neuron_topk", - "bounds_tolerance": "bounds_tolerance", - "batch_sizes": "batch_sizes", - } - for key, attr in val_attr_map.items(): + for key, attr in _PIPELINE_VAL_ATTR_MAP.items(): value = getattr(args, attr, None) if value is not None: overrides[f"val_{key}"] = value diff --git a/act/pipeline/fuzzing/actfuzzer.py b/act/pipeline/fuzzing/actfuzzer.py index 89dca731f..b5cd59ccd 100644 --- a/act/pipeline/fuzzing/actfuzzer.py +++ b/act/pipeline/fuzzing/actfuzzer.py @@ -112,56 +112,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/config/pipeline_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/config/pipeline_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/config/pipeline_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) diff --git a/act/pipeline/verification/vnncomp_runner.py b/act/pipeline/verification/vnncomp_runner.py deleted file mode 100755 index a87b2e6d5..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.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 = 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 f89d0f5f2..d1ad9e692 100644 --- a/act/util/path_config.py +++ b/act/util/path_config.py @@ -174,41 +174,6 @@ def get_vnnlib_data_root() -> str: return str(vnnlib_root) -def get_spec_config_root() -> str: - """Get the consolidated front-end config directory.""" - from pathlib import Path - return str(Path(get_project_root()) / 'act' / 'front_end') - - -def get_default_spec_config_path() -> str: - """Get path to the consolidated front-end configuration.""" - from pathlib import Path - return str(Path(get_spec_config_root()) / 'config.yaml') - - -def get_spec_config_path(name: str) -> str: - """Resolve a named spec config to the consolidated front-end config path.""" - import yaml - from pathlib import Path - - key = name[:-5] if name.endswith('.yaml') else name - path = Path(get_default_spec_config_path()) - with open(path) as f: - specs = (yaml.safe_load(f) or {}).get('specs', {}) - if key not in specs: - raise FileNotFoundError(f"Spec config '{key}' not found in {path}") - return str(path) - - -def list_spec_configs() -> list: - """List available spec configuration names.""" - import yaml - - with open(get_default_spec_config_path()) as f: - specs = (yaml.safe_load(f) or {}).get('specs', {}) - return sorted(specs) - - # ============================================================================ # NetFactory Paths # ============================================================================ diff --git a/vnncomp/act_run_instance.py b/vnncomp/act_run_instance.py index f38b61263..2881552d5 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,184 @@ 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 = 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") def main() -> None: From f6abf9fd2d848f1cc81edcf4c766d786333ac400 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Mon, 13 Jul 2026 16:22:34 +1000 Subject: [PATCH 09/27] refactor(config): embed net_factory DSL in backend_config.yaml; drop config_gen_act_net.yaml Consolidate the network-generation DSL (families, sampling rules, input/output specs, validate.batch_sizes) into backend_config.yaml under backend.generation.net_factory, exposed via GenerationConfig.net_factory. - config.py: add GenerationConfig.net_factory dict field; drop gen_config_path - backend_config.yaml: embed the DSL under generation.net_factory - net_factory.py: NetFactory takes required config dict; remove gen_config_path fallback + _load_config (no back-compat) and now-unused yaml import - backend_cli.py: pass config=gen.net_factory; exclude net_factory from generated CLI args; drop gen_config_path alias - pipeline_cli.py: _resolve_batch_sizes reads backend.generation.net_factory; fix --batch-sizes help text - path_config.py: remove get_examples_gen_config_path (no callers) - docs: repoint README references - delete act/back_end/examples/config_gen_act_net.yaml Behavior-preserving: embedded DSL parses deep-equal to the old file and _load_config was a plain yaml load. Verified: CLI<->YAML parity OK, serialization round-trip 74/74, CLI entry points import, lsp clean. --- act/README.md | 4 +- act/back_end/examples/README.md | 10 +- act/back_end/examples/config_gen_act_net.yaml | 423 ----------------- act/back_end/net_factory.py | 23 +- act/config/backend_cli.py | 6 +- act/config/backend_config.yaml | 439 +++++++++++++++++- act/config/config.py | 15 +- act/config/pipeline_cli.py | 20 +- act/util/path_config.py | 8 +- 9 files changed, 458 insertions(+), 490 deletions(-) delete mode 100644 act/back_end/examples/config_gen_act_net.yaml diff --git a/act/README.md b/act/README.md index 7c8521dc0..dd3a52d70 100644 --- a/act/README.md +++ b/act/README.md @@ -246,8 +246,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/backend_config.yaml`** (`backend.generation.net_factory`): 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** diff --git a/act/back_end/examples/README.md b/act/back_end/examples/README.md index bdd8cb2f2..11272efbd 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 backend_config.yaml (backend.generation.net_factory) 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/backend_config.yaml` (`backend.generation.net_factory`) - 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 `backend_config.yaml` (`backend.generation.net_factory`) +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/net_factory.py b/act/back_end/net_factory.py index e73872af2..9e0a7e063 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 backend_config.yaml (backend.generation.net_factory). # # Usage: # python -m act.back_end --generate # default 15 nets @@ -31,7 +31,6 @@ 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 @@ -1116,7 +1115,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, @@ -1126,12 +1125,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 = "backend_config.yaml:backend.generation.net_factory" common = self.config["common"] self.tf_targets = tf_targets @@ -1177,16 +1172,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) diff --git a/act/config/backend_cli.py b/act/config/backend_cli.py index 10bd88562..b74fbe837 100644 --- a/act/config/backend_cli.py +++ b/act/config/backend_cli.py @@ -123,7 +123,7 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk {"bab", "generation", "hybridz", "solver_config", "tf"}, ) add_group(BaBConfig, "BaB Config Overrides (generated)", "bab-", "bab_", set()) - add_group(GenerationConfig, "Generation Config Overrides (generated)", "gen-", "gen_", set()) + add_group(GenerationConfig, "Generation Config Overrides (generated)", "gen-", "gen_", {"net_factory"}) add_group(HybridZConfig, "HybridZ Config Overrides (generated)", "hz-", "hybridz_", set()) add_group(SolverConfig, "Solver Config Overrides (generated)", "solver-", "solver_", set()) add_group(TFConfig, "TF Config Overrides (generated)", "tf-", "tf_", set()) @@ -391,14 +391,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, @@ -1407,7 +1406,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"), diff --git a/act/config/backend_config.yaml b/act/config/backend_config.yaml index adeacb9d9..44ffcba3b 100644 --- a/act/config/backend_config.yaml +++ b/act/config/backend_config.yaml @@ -309,15 +309,11 @@ backend: # ═════════════════════════════════════════════════════════════════════════ # Network generation (net_factory) # ═════════════════════════════════════════════════════════════════════════ - # Controls how many networks to generate, where, and TF filtering. - # The architecture-sampling DSL (families, sampling rules, input/output specs) - # is intentionally a SEPARATE file (gen_config_path): a large declarative DSL - # with its own schema and no dataclass/CLI mapping, so it does not belong in - # this flat, dataclass-backed settings file. + # Controls how many networks to generate, where, and TF filtering. The + # architecture-sampling DSL (families, sampling rules, input/output specs) is + # embedded below under `net_factory:` and consumed by NetFactory via + # GenerationConfig.net_factory (was previously act/back_end/examples/config_gen_act_net.yaml). 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" @@ -354,3 +350,430 @@ backend: # Write a manifest.json listing all generated network filenames. write_manifest: true + + # Architecture-sampling DSL consumed by NetFactory (families, sampling + # rules, input/output specs, generation controls, validate.batch_sizes). + net_factory: + # ============================================================================ + # 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/config.py b/act/config/config.py index 6351c6dfd..72b52b673 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -288,21 +288,16 @@ def to_yaml(self, path: Union[str, Path]) -> Path: # GenerationConfig — network generation (net_factory) parameters # --------------------------------------------------------------------------- -_DEFAULT_GEN_CONFIG = str( - Path(__file__).parent.parent / "back_end" / "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 the simple knobs (how many, where, seed, TF filtering). The + architecture-sampling DSL (families, sampling rules, input/output specs) is + embedded in ``net_factory``, loaded from ``backend.generation.net_factory`` + in backend_config.yaml. """ - gen_config_path: str = _DEFAULT_GEN_CONFIG output_dir: str = "act/back_end/examples/nets" num_instances: int = 15 base_seed: int = 42 @@ -314,6 +309,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( diff --git a/act/config/pipeline_cli.py b/act/config/pipeline_cli.py index 317846e39..e2771e680 100644 --- a/act/config/pipeline_cli.py +++ b/act/config/pipeline_cli.py @@ -1278,17 +1278,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) @@ -1663,8 +1657,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 backend_config.yaml " + "(backend.generation.net_factory), then to ``[None]`` (native only).", ) validation_group.add_argument( "--ignore-errors", diff --git a/act/util/path_config.py b/act/util/path_config.py index d1ad9e692..2c4e91ae0 100644 --- a/act/util/path_config.py +++ b/act/util/path_config.py @@ -183,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 From f5711b57056b204dc6bafc1bc9482480ba9a81fe Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Mon, 13 Jul 2026 16:46:12 +1000 Subject: [PATCH 10/27] refactor(util): adopt shared rule() banner helper across 20 files (266 sites) Replace inline separator idioms (char*width, e.g. "="*80) with rule() from act.util.format_utils, consolidating 266 copies of the banner idiom into one helper. Behavior-preserving: rule(N, C) == C*N by construction. - add `from act.util.format_utils import rule` to each file (data_model_loader extends its existing format_utils import; model_synthesis adds a local import inside its run-as-script guard) - widths/chars preserved exactly: rule(), rule(100), rule(70, "-"), ... Verified: 0 remaining idioms, lsp clean (no new errors), all 3 CLI entry points --help exit 0, all 20 modules import. --- act/back_end/net_factory.py | 7 +- .../serialization/test_serialization.py | 5 +- act/back_end/transfer_functions.py | 5 +- act/back_end/utils.py | 5 +- act/config/backend_cli.py | 51 +++++----- act/config/frontend_cli.py | 95 ++++++++++--------- act/config/pipeline_cli.py | 89 ++++++++--------- act/front_end/creator_registry.py | 9 +- act/front_end/model_synthesis.py | 14 +-- act/front_end/torchvision_loader/cli.py | 85 +++++++++-------- .../torchvision_loader/data_model_loader.py | 38 ++++---- .../vnnlib_loader/category_mapping.py | 10 +- act/front_end/vnnlib_loader/cli.py | 61 ++++++------ act/pipeline/fuzzing/actfuzzer.py | 9 +- act/pipeline/fuzzing/trace_reader.py | 20 ++-- act/pipeline/fuzzing/tracer.py | 7 +- act/pipeline/verification/model_factory.py | 17 ++-- act/pipeline/verification/torch2act.py | 3 +- .../verification/validate_verifier.py | 13 +-- act/util/stats.py | 14 +-- 20 files changed, 290 insertions(+), 267 deletions(-) diff --git a/act/back_end/net_factory.py b/act/back_end/net_factory.py index 9e0a7e063..2f70e4a63 100644 --- a/act/back_end/net_factory.py +++ b/act/back_end/net_factory.py @@ -37,6 +37,7 @@ 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__) @@ -1561,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}") @@ -1577,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/transfer_functions.py b/act/back_end/transfer_functions.py index 748e6e6b2..8a056435b 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): @@ -165,9 +166,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() diff --git a/act/back_end/utils.py b/act/back_end/utils.py index 2561105b2..69411c45a 100644 --- a/act/back_end/utils.py +++ b/act/back_end/utils.py @@ -16,6 +16,7 @@ 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 @@ -194,9 +195,9 @@ def validate_constraints(globalC, after: Dict[int, Any], 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/config/backend_cli.py b/act/config/backend_cli.py index b74fbe837..b460cfc61 100644 --- a/act/config/backend_cli.py +++ b/act/config/backend_cli.py @@ -29,6 +29,7 @@ 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") @@ -381,9 +382,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 @@ -407,9 +408,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: @@ -423,9 +424,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 @@ -451,9 +452,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}") @@ -473,36 +474,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 @@ -529,16 +530,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 @@ -694,9 +695,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") @@ -712,9 +713,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 @@ -736,11 +737,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 "!" @@ -778,7 +779,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 diff --git a/act/config/frontend_cli.py b/act/config/frontend_cli.py index fc108b369..c22ba98c0 100644 --- a/act/config/frontend_cli.py +++ b/act/config/frontend_cli.py @@ -14,6 +14,7 @@ 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 @@ -80,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') @@ -99,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']}") @@ -107,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): @@ -122,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 @@ -133,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') @@ -144,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']}") @@ -157,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): @@ -178,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) @@ -215,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}") @@ -233,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 @@ -260,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 @@ -278,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}/") @@ -296,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}/") @@ -320,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: @@ -340,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 = {} @@ -358,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': @@ -397,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: @@ -409,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}") @@ -438,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(): @@ -749,9 +750,9 @@ 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 @@ -765,9 +766,9 @@ def main(): 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)) @@ -778,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 @@ -849,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/pipeline_cli.py b/act/config/pipeline_cli.py index e2771e680..eb171a0f5 100644 --- a/act/config/pipeline_cli.py +++ b/act/config/pipeline_cli.py @@ -20,6 +20,7 @@ import torch from act.util.cli_utils import add_device_args, initialize_from_args +from act.util.format_utils import rule from act.config.config import VALID_SOLVER_TIERS logger = logging.getLogger(__name__) @@ -253,10 +254,10 @@ def _apply_pipeline_config_defaults(args: Any) -> PipelineConfig: 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") # ============================================================================ @@ -266,14 +267,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']}") @@ -282,7 +283,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", []) @@ -292,20 +293,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']}") @@ -316,21 +317,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: @@ -381,14 +382,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: @@ -437,9 +438,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}") @@ -448,9 +449,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() @@ -464,7 +465,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)") @@ -489,7 +490,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)") @@ -501,7 +502,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") # ============================================================================ @@ -527,9 +528,9 @@ def cmd_fuzz(args): 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 = [] @@ -634,9 +635,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 @@ -659,9 +660,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] @@ -674,9 +675,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] @@ -691,15 +692,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 @@ -1189,10 +1190,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" @@ -1205,7 +1206,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" @@ -1218,7 +1219,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" @@ -1231,7 +1232,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" @@ -1244,7 +1245,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" @@ -1256,13 +1257,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()): 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 a31f3e98b..40e556d64 100644 --- a/act/front_end/model_synthesis.py +++ b/act/front_end/model_synthesis.py @@ -17,11 +17,12 @@ # 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 @@ -39,6 +40,7 @@ OutputSpecLayer, VerifiableModel, ) +from act.util.format_utils import rule # ----------------------------------------------------------------------------- @@ -393,9 +395,9 @@ def model_synthesis( 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': @@ -500,9 +502,9 @@ def model_synthesis( ) # 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/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/pipeline/fuzzing/actfuzzer.py b/act/pipeline/fuzzing/actfuzzer.py index b5cd59ccd..c228ed9c3 100644 --- a/act/pipeline/fuzzing/actfuzzer.py +++ b/act/pipeline/fuzzing/actfuzzer.py @@ -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 @@ -333,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 @@ -531,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)}") @@ -544,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/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/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..f7b0b0e21 100644 --- a/act/pipeline/verification/torch2act.py +++ b/act/pipeline/verification/torch2act.py @@ -79,6 +79,7 @@ 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 # ----------------------------------------------------------------------------- @@ -2164,7 +2165,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/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 From d69d4d2083ed37b9d8a702284d1fb0cacef8df1d Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Mon, 13 Jul 2026 17:17:08 +1000 Subject: [PATCH 11/27] refactor(verification): single canonical LayerKind<->nn.Module map Consolidate the 38 module-backed LayerKind<->nn.Module pairs (previously duplicated as the _ACT_TO_TORCH forward dict in act2torch.py and the isinstance reverse dispatch in torch2act.py) into one source: act/back_end/layer_torch_map.py (ACT_TO_TORCH + custom activation modules). - act2torch.py: import ACT_TO_TORCH instead of a local dict literal - torch2act.py: derive _TORCH_TO_ACT_EXACT reverse lookup from ACT_TO_TORCH; keep isinstance dispatch for subclass matching + param extraction + BN decomposition (semantics unchanged) Adding a layer now edits one place. Behavior-preserving; verified --verify act2torch (float64) and --verify torch2act (float32/float64) all exit 0. --- act/back_end/layer_torch_map.py | 97 ++++++++++++++++++++++++++ act/pipeline/verification/act2torch.py | 90 ++---------------------- act/pipeline/verification/torch2act.py | 95 ++++++++++++++++++------- 3 files changed, 174 insertions(+), 108 deletions(-) create mode 100644 act/back_end/layer_torch_map.py diff --git a/act/back_end/layer_torch_map.py b/act/back_end/layer_torch_map.py new file mode 100644 index 000000000..688461cc4 --- /dev/null +++ b/act/back_end/layer_torch_map.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# ===- act/back_end/layer_torch_map.py - ACT/Torch layer mapping ---------====# + +"""Canonical LayerKind <-> torch.nn.Module correspondence.""" + +from typing import override + +import torch +import torch.nn as nn + +from act.back_end.layer_schema import LayerKind + + +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, +} + + +__all__ = ["ACT_TO_TORCH"] diff --git a/act/pipeline/verification/act2torch.py b/act/pipeline/verification/act2torch.py index 12fa8229e..2f987d5ba 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 @@ -30,87 +30,11 @@ from act.back_end.core import Net, Layer from act.back_end.layer_schema import LayerKind, REGISTRY +from act.back_end.layer_torch_map import ACT_TO_TORCH 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 +1029,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 +1043,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 +1053,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 +1141,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/torch2act.py b/act/pipeline/verification/torch2act.py index f7b0b0e21..359c8d3e8 100644 --- a/act/pipeline/verification/torch2act.py +++ b/act/pipeline/verification/torch2act.py @@ -66,6 +66,7 @@ from act.back_end.core import Net, Layer from act.back_end.layer_schema import LayerKind +from act.back_end.layer_torch_map import ACT_TO_TORCH from act.back_end.layer_util import create_layer from act.pipeline.verification.utils import ( _prod, _normalize_tuple, _assert_dag, _broadcast_const_to_size, @@ -82,6 +83,9 @@ from act.util.format_utils import rule +_TORCH_TO_ACT_EXACT = {module_cls: kind for kind, module_cls in ACT_TO_TORCH.items()} + + # ----------------------------------------------------------------------------- # Unified graph-based tracing for PyTorch models # ----------------------------------------------------------------------------- @@ -1164,38 +1168,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 From 3b06e537cbca9281325838aa5fec2138e71dad0f Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Mon, 13 Jul 2026 17:29:20 +1000 Subject: [PATCH 12/27] refactor(verifier): hoist shared seed-context setup into _setup_verify_context verify_once and verify_lp_batched duplicated the INPUT_SPEC seed setup (gather_input_spec_layers -> seed_from_input_specs -> batch-dim check -> B). Extract the minimal shared block into _setup_verify_context(net) -> (spec_layers, seed_bounds, B). Deliberately minimal: entry_id/input_ids/output_ids/assert_layer stay in verify_once (verify_lp_batched never computed them, and find_entry_layer_id/ get_output_ids can raise, so hoisting them would add new raising behavior). verify_lp_batched keeps its extra ub.dim() check. Behavior-preserving; verified identical before/after: --validate-soundness torchlp 222/0, dual 141/0, --test-serialization 74/74, all exit 0. --- act/back_end/verifier.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/act/back_end/verifier.py b/act/back_end/verifier.py index 426a0b088..7fa111f19 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, @@ -545,19 +557,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 ( From c27cbf97ac6f69afa3507f3475c31f72158f25e1 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Mon, 13 Jul 2026 17:31:35 +1000 Subject: [PATCH 13/27] test(tf): guard IntervalTF/HybridzTF registry keyset parity (WS4.2) The two _LAYER_REGISTRY keysets are intentionally asymmetric by exactly one kind (MEAN is interval-only; HybridZ raises NotImplementedError for it). A literal merge into one canonical list would silently change dispatch behavior, so per Oracle review WS4.2 ships as a drift-detection guard rather than a merge. Pins interval-only == {MEAN} and hybridz-only == {} so future drift fails loudly instead of silently changing which layers each TF supports. Runnable via: python -m act.back_end.test_tf_registry_parity --- act/back_end/test_tf_registry_parity.py | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 act/back_end/test_tf_registry_parity.py diff --git a/act/back_end/test_tf_registry_parity.py b/act/back_end/test_tf_registry_parity.py new file mode 100644 index 000000000..97f9983ae --- /dev/null +++ b/act/back_end/test_tf_registry_parity.py @@ -0,0 +1,41 @@ +"""Pin the intended keyset relationship between the IntervalTF and HybridzTF +layer registries so future divergence fails loudly. + +Both transfer functions keep a ``_LAYER_REGISTRY`` over the same ``LayerKind`` +keyset but map to different handlers. The keysets are intentionally asymmetric by +exactly one kind: ``MEAN`` is interval-only (HybridZ raises NotImplementedError +for it). This is a deliberate design choice, not a bug, so merging the two into a +single canonical list would silently change dispatch behavior. This guard asserts +the exact intended difference; any drift (a kind added/removed from one registry +only) makes it fail instead of silently changing which layers each TF supports. +""" + +from act.back_end.interval_tf.interval_tf import IntervalTF +from act.back_end.hybridz_tf.hybridz_tf import HybridzTF +from act.back_end.layer_schema import LayerKind + + +def _registry_keys(tf_cls) -> set[str]: + return set(tf_cls._LAYER_REGISTRY.keys()) + + +def test_interval_hybridz_registry_parity() -> None: + interval = _registry_keys(IntervalTF) + hybridz = _registry_keys(HybridzTF) + + interval_only = interval - hybridz + hybridz_only = hybridz - interval + + assert interval_only == {LayerKind.MEAN.value}, ( + "IntervalTF/HybridzTF _LAYER_REGISTRY drift: interval-only kinds changed " + f"from {{'MEAN'}} to {sorted(interval_only)}" + ) + assert hybridz_only == set(), ( + "HybridzTF gained registry kinds absent from IntervalTF: " + f"{sorted(hybridz_only)}" + ) + + +if __name__ == "__main__": + test_interval_hybridz_registry_parity() + print("OK: IntervalTF/HybridzTF registry parity guard passed (intended diff = {MEAN})") From 6b3ba59c6577c5ff3c28b2279c1ab1e9fd4cfa05 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Mon, 13 Jul 2026 17:36:28 +1000 Subject: [PATCH 14/27] refactor(solver): use SolveStatus constants in gurobi solve_batch (WS3.2a) Replace raw "SAT"/"UNSAT"/"UNKNOWN" string literals in GurobiSolver.solve_batch with SolveStatus.SAT/UNSAT/UNKNOWN (solver_base), matching solver_torchlp which already uses the enum. Value-identical (SolveStatus.SAT == "SAT") so behavior-preserving. Per Oracle review only the gurobi status literals are unified; solver_hz's VerifyStatus is intentionally left (it is the verify-layer abstraction, not solve-layer drift). Verified: solver_base self-test 5/5, no raw literals remain in solve_batch, imports + SolveStatus values OK, lsp adds no new errors. --- act/back_end/solver/solver_gurobi.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/act/back_end/solver/solver_gurobi.py b/act/back_end/solver/solver_gurobi.py index 5d8a71860..5ce2c3288 100644 --- a/act/back_end/solver/solver_gurobi.py +++ b/act/back_end/solver/solver_gurobi.py @@ -6,6 +6,7 @@ from act.back_end.solver.solver_base import ( Solver, SolverCaps, + SolveStatus, _empty_blockdiag, _problem, _make_problem_n1, @@ -176,15 +177,15 @@ def solve_batch( 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) From 0c0d7cf1025b89583a4d2e396933c85ff474c954 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Mon, 13 Jul 2026 17:50:16 +1000 Subject: [PATCH 15/27] refactor(tf): extract RegistryTF base for shared apply() dispatch skeleton (WS4.1) IntervalTF and HybridzTF duplicated the apply() dispatch skeleton (context set + registry lookup + NotImplementedError on unsupported kind). Hoist the identical pieces into a RegistryTF base (transfer_functions.py) exposing name, supports_layer, _check_supported, _set_context. Both TFs now inherit it. - Registries stay separate (distinct handlers; keysets intentionally differ by MEAN, guarded by test_tf_registry_parity). - HybridzTF.apply keeps 100% of its cache/HZ orchestration in its own override; no cache logic moved to the base. - dual_tf left independent (different architecture). Behavior-preserving; verified: per-layer Fact.bounds bit-identical (torch.equal atol=0) for interval+hybridz on MLP/CNN/transformer; --validate-soundness torchlp 222/0 + dual 141/0 unchanged; registry parity + serialization 74/74; lsp clean. --- act/back_end/hybridz_tf/hybridz_tf.py | 20 ++++----------- act/back_end/interval_tf/interval_tf.py | 23 +++++------------ act/back_end/transfer_functions.py | 34 +++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/act/back_end/hybridz_tf/hybridz_tf.py b/act/back_end/hybridz_tf/hybridz_tf.py index fc3a706d4..53e94f0b4 100644 --- a/act/back_end/hybridz_tf/hybridz_tf.py +++ b/act/back_end/hybridz_tf/hybridz_tf.py @@ -22,7 +22,7 @@ from typing import Dict, Optional from act.config.config import TFConfig 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, @@ -40,8 +40,9 @@ import act.back_end.interval_tf.tf_cnn as interval_cnn -class HybridzTF(TransferFunction): +class HybridzTF(RegistryTF): def __init__(self, config: Optional[TFConfig] = None): + super().__init__("HybridzTF") cfg = config or TFConfig() self._hz_cache: Dict[int, HZono] = {} self._sparse_hz_cache: Dict[int, SparseHZono] = {} @@ -155,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)) @@ -384,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: @@ -399,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/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/transfer_functions.py b/act/back_end/transfer_functions.py index 8a056435b..fb1294efb 100644 --- a/act/back_end/transfer_functions.py +++ b/act/back_end/transfer_functions.py @@ -75,6 +75,40 @@ 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: Optional[TransferFunction] = None From 403b831bda9362276072e5c3a7d2c067fae0ab98 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 09:36:29 +1000 Subject: [PATCH 16/27] refactor(config): fold TFConfig into HybridZConfig.max_input_dim The lone tf.hz_max_input_dim (TFConfig) was a HybridZ TF limit stranded in a generic tf: block beside a single-field hybridz: block. Merge into one HybridZConfig (timeout + max_input_dim); delete TFConfig and the tf: block. - config.py: HybridZConfig gains max_input_dim; TFConfig removed; from_yaml/ to_yaml/BackendConfig drop tf routing (the hybridz_ override prefix now covers it) - backend_cli.py: drop the tf- arg group + prefix; flag is now --hz-max-input-dim (was the double-prefixed --tf-hz-max-input-dim) - hybridz_tf.py: HybridzTF(config: HybridZConfig) reads cfg.max_input_dim - backend_config.yaml: max_input_dim moves under hybridz: Behavior-preserving (value unchanged 1024). Verified: parity OK, HybridzTF _HZ_MAX_INPUT_DIM==1024, lsp clean, 0 remaining TFConfig/hz_max_input_dim refs. --- act/back_end/hybridz_tf/hybridz_tf.py | 8 ++++---- act/config/backend_cli.py | 8 +++----- act/config/backend_config.yaml | 11 +++-------- act/config/config.py | 20 +------------------- 4 files changed, 11 insertions(+), 36 deletions(-) diff --git a/act/back_end/hybridz_tf/hybridz_tf.py b/act/back_end/hybridz_tf/hybridz_tf.py index 53e94f0b4..e332591de 100644 --- a/act/back_end/hybridz_tf/hybridz_tf.py +++ b/act/back_end/hybridz_tf/hybridz_tf.py @@ -20,7 +20,7 @@ import torch from typing import Dict, Optional -from act.config.config import TFConfig +from act.config.config import HybridZConfig from act.back_end.core import Bounds, Fact, Layer, Net, ConSet from act.back_end.transfer_functions import RegistryTF from act.back_end.layer_schema import LayerKind @@ -41,9 +41,9 @@ class HybridzTF(RegistryTF): - def __init__(self, config: Optional[TFConfig] = None): + def __init__(self, config: Optional[HybridZConfig] = None): super().__init__("HybridzTF") - cfg = config or TFConfig() + cfg = config or HybridZConfig() self._hz_cache: Dict[int, HZono] = {} self._sparse_hz_cache: Dict[int, SparseHZono] = {} self._sparse_drop_reasons: Dict[int, str] = {} @@ -51,7 +51,7 @@ def __init__(self, config: Optional[TFConfig] = None): self._tanh_K: int = 2 self._sigmoid_K: int = 2 self._var_id_stride: int = 1 - setattr(self, "_HZ_MAX_INPUT_DIM", cfg.hz_max_input_dim) + 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]] = {} diff --git a/act/config/backend_cli.py b/act/config/backend_cli.py index b460cfc61..e6becc44e 100644 --- a/act/config/backend_cli.py +++ b/act/config/backend_cli.py @@ -65,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.config.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig, SolverConfig, TFConfig + from act.config.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig, SolverConfig existing_options = { option @@ -121,13 +121,12 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk "Backend Config Overrides (generated)", "", "", - {"bab", "generation", "hybridz", "solver_config", "tf"}, + {"bab", "generation", "hybridz", "solver_config"}, ) add_group(BaBConfig, "BaB Config Overrides (generated)", "bab-", "bab_", set()) add_group(GenerationConfig, "Generation Config Overrides (generated)", "gen-", "gen_", {"net_factory"}) add_group(HybridZConfig, "HybridZ Config Overrides (generated)", "hz-", "hybridz_", set()) add_group(SolverConfig, "Solver Config Overrides (generated)", "solver-", "solver_", set()) - add_group(TFConfig, "TF Config Overrides (generated)", "tf-", "tf_", set()) # Backend YAML sub-section (== the BackendConfig nested-dataclass field name) -> @@ -140,7 +139,6 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk "generation": "gen_", "hybridz": "hybridz_", "solver_config": "solver_", - "tf": "tf_", } @@ -1329,7 +1327,7 @@ def main(): if tf_mode is not None: from act.back_end.analyze import initialize_tf_mode - initialize_tf_mode(tf_mode, backend_cfg.tf) + 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 diff --git a/act/config/backend_config.yaml b/act/config/backend_config.yaml index 44ffcba3b..d331f2c67 100644 --- a/act/config/backend_config.yaml +++ b/act/config/backend_config.yaml @@ -79,18 +79,13 @@ backend: feas_check_stride: 5 # iteration stride between feasibility checks # ───────────────────────────────────────────────────────────────────────── - # Transfer-function limits (tf) - # ───────────────────────────────────────────────────────────────────────── - tf: - # HybridZ generator/input dimension fallback limit. - hz_max_input_dim: 1024 - - # ───────────────────────────────────────────────────────────────────────── - # HybridZ solver (hybridz) + # HybridZ (hybridz) # ───────────────────────────────────────────────────────────────────────── hybridz: # Per-call wall-clock budget in seconds; null = no HybridZ-specific limit. timeout: null + # HybridZ generator/input dimension fallback limit (used by the HybridZ TF). + max_input_dim: 1024 # ═════════════════════════════════════════════════════════════════════════ # Branch-and-Bound refinement (bab) diff --git a/act/config/config.py b/act/config/config.py index 72b52b673..865c352d1 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -326,6 +326,7 @@ def __post_init__(self) -> None: @dataclass class HybridZConfig: timeout: Optional[float] = None + max_input_dim: int = 1024 @dataclass @@ -346,10 +347,6 @@ class SolverConfig: feas_check_stride: int = 5 -@dataclass -class TFConfig: - hz_max_input_dim: int = 1024 - # --------------------------------------------------------------------------- # BackendConfig — unified back-end configuration # --------------------------------------------------------------------------- @@ -399,7 +396,6 @@ class BackendConfig: generation: GenerationConfig = field(default_factory=GenerationConfig) hybridz: HybridZConfig = field(default_factory=HybridZConfig) solver_config: SolverConfig = field(default_factory=SolverConfig) - tf: TFConfig = field(default_factory=TFConfig) method: Optional[str] = field(default=None, metadata={"in_yaml": False}) p: float = field(default=2.0, metadata={"in_yaml": False}) @@ -485,7 +481,6 @@ def from_yaml( - ``gen_`` → ``GenerationConfig.`` - ``hybridz_`` → ``HybridZConfig.`` - ``solver_`` → ``SolverConfig.`` - - ``tf_`` → ``TFConfig.`` - ``bab_enabled`` → top-level ``bab_enabled`` """ path = Path(config_path) if config_path else _BACKEND_YAML @@ -502,7 +497,6 @@ def from_yaml( solver_raw: dict[str, Any] = backend_raw.pop("solver_config", {}) if isinstance(backend_raw.get("solver"), dict): solver_raw = backend_raw.pop("solver") - tf_raw: dict[str, Any] = backend_raw.pop("tf", {}) # Extract "enabled" from bab section → top-level bab_enabled bab_enabled = bab_raw.pop("enabled", None) @@ -512,12 +506,10 @@ def from_yaml( gen_fields = {fld.name for fld in fields(GenerationConfig)} hz_fields = {fld.name for fld in fields(HybridZConfig)} solver_fields = {fld.name for fld in fields(SolverConfig)} - tf_fields = {fld.name for fld in fields(TFConfig)} bab_overrides: dict[str, Any] = {} gen_overrides: dict[str, Any] = {} hz_overrides: dict[str, Any] = {} solver_overrides: dict[str, Any] = {} - tf_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: @@ -528,8 +520,6 @@ def from_yaml( hz_overrides[k[8:]] = v elif k.startswith("solver_") and k[7:] in solver_fields: solver_overrides[k[7:]] = v - elif k.startswith("tf_") and k[3:] in tf_fields: - tf_overrides[k[3:]] = v else: top_overrides[k] = v @@ -551,17 +541,12 @@ def from_yaml( solver_merged.update(solver_overrides) solver_config = SolverConfig(**solver_merged) - tf_merged = {k: v for k, v in tf_raw.items() if k in tf_fields} - tf_merged.update(tf_overrides) - tf_config = TFConfig(**tf_merged) - # Build top-level config top_fields = {fld.name for fld in fields(cls)} - { "bab", "generation", "hybridz", "solver_config", - "tf", } top_merged: dict[str, Any] = {} for k, v in backend_raw.items(): @@ -578,7 +563,6 @@ def from_yaml( generation=gen_config, hybridz=hz_config, solver_config=solver_config, - tf=tf_config, **top_merged, ) @@ -591,7 +575,6 @@ def to_yaml(self, path: Union[str, Path]) -> Path: gen_d = d.pop("generation") hz_d = d.pop("hybridz") solver_d = d.pop("solver_config") - tf_d = d.pop("tf") bab_enabled = d.pop("bab_enabled") bab_d["enabled"] = bab_enabled @@ -604,7 +587,6 @@ def to_yaml(self, path: Union[str, Path]) -> Path: "generation": gen_d, "hybridz": hz_d, "solver_config": solver_d, - "tf": tf_d, } }, f, From a0463cdace55f33c0fc33cc298894e1cfea54515 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 09:53:07 +1000 Subject: [PATCH 17/27] =?UTF-8?q?refactor(config):=20slim=20bab=20block=20?= =?UTF-8?q?=E2=80=94=20demote=20rarely-tuned=20fields=20to=20CLI-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend.bab YAML block mixed general BaB, dual-tier, LLM-probe and BERT-text settings. Demote the default-only / expert-only fields to CLI-only: they keep their dataclass default + --bab-, but leave the YAML. bab YAML: 48 -> 28 keys; what remains is the set actually tuned in CI / pipeline_config / vnncomp. Enabling mechanism (in_yaml=False was previously honored only for top-level BackendConfig fields): - config.py: mark 29 BaBConfig fields metadata={"in_yaml": False}; from_yaml now filters bab_raw to in_yaml=True keys (CLI overrides still apply). - check_parity.py: honor in_yaml=False on nested BaBConfig fields (bab CLI options absent from YAML must be in_yaml=False), mirroring the top-level rule. - backend_config.yaml: drop the demoted keys; keep the tuned knobs + the BERT text-method group, grouped by concern. Behavior-preserving (demoted YAML values equalled dataclass defaults). Verified: CONFIG PARITY OK; --bab-lr-beta override still works; torchlp + dual --validate-soundness exit 0 all SOUND; lsp clean. --- act/config/backend_config.yaml | 189 ++------------------------------- act/config/check_parity.py | 8 +- act/config/config.py | 65 ++++++------ 3 files changed, 50 insertions(+), 212 deletions(-) diff --git a/act/config/backend_config.yaml b/act/config/backend_config.yaml index d331f2c67..3a610643c 100644 --- a/act/config/backend_config.yaml +++ b/act/config/backend_config.yaml @@ -92,215 +92,42 @@ backend: # ═════════════════════════════════════════════════════════════════════════ bab: - # -- Enable + search-tree limits -------------------------------------- - - # Enable Branch-and-Bound refinement (true) or single execution (false). + # -- General BaB -------------------------------------------------------- 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 & bounding strategy ------------------------------------ - - # 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) - # "gain" – joint multi-neuron split by optimized bound gain - # "width" – width-based baseline - # NOTE: "babsr"/"fsb"/"gain" perform real neuron-split only on a dual tier - # (solver_tier "dual_alpha" or "dual_alpha_eta"); on "lp" they fall - # back to 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 (only used when bounding_method = "topk"). - # "depth_lb" (default) | "greedy" | "sa" (simulated annealing) bounding_order: "depth_lb" - - # Simulated-annealing cooling rate for bounding_order = "sa". sa_cooling_rate: 0.99 + intermediate_refine: "none" + reuse_root_bounds: false + multi_split_levels: 1 + provenance_enabled: false - # 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 α/η optimization (DualSolver BaB) ---------------------- - - # Solver tier for BaB bound computation. - # Source of truth: act.config.config.VALID_SOLVER_TIERS - # "lp" – LP/MILP backend - # "dual" – DualSolver (linear-relaxation dual, no iterative opt) - # "dual_alpha" – DualSolver + Lagrange-relaxed lower-slope opt (Adam on α ∈ [0,1]) - # "dual_alpha_eta" – DualSolver + joint α + η (split-constraint KKT multipliers) + # -- Dual-tier (solver_tier != lp) ------------------------------------- solver_tier: "lp" - - # Number of Adam iterations for α/η optimization (dual_alpha / dual_alpha_eta only). 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 - # Refine only η in child dual subproblems (freeze α inherited from parent). - eta_only_children: false - - # -- Intermediate-bound refinement ------------------------------------ - - # Number of root pre-split levels before the main loop. - presplit_levels: 0 - - # Root intermediate-bound refinement mode: "none" | "auto" | "all". - intermediate_refine: "none" - - # Width ratio threshold for intermediate_refine = "auto". - intermediate_refine_ratio: 10.0 - - # Reuse root intermediate bounds in descendant subproblems (dual tiers). - reuse_root_bounds: false - - # Per-subproblem intermediate refinement mode: "none" | "tail" | "all" - # (requires reuse_root_bounds). - per_subproblem_refine: "none" - - # Adam iterations for per-subproblem refinement rows (0 = single fixed-slope pass). - per_subproblem_refine_iters: 0 - - # Row cap for per-subproblem refinement (top-cap by interval width). - per_subproblem_refine_rows_cap: 64 - - # Number of simultaneous neuron split levels (gain branching only); 1 = single split. - multi_split_levels: 1 - - # -- Auto batch sizing (max_batch_size = "auto") ---------------------- - - # Fraction of available GPU memory targeted by auto batch sizing. - auto_batch_safety: 0.55 - - # Hard cap for auto-sized BaB batches (also the CPU fallback). - auto_batch_cap: 2048 - - # Floor for auto-sized BaB batches. - auto_batch_floor: 8 - - # -- LLM-probe controller (optional experimental branching aid) ------- - - # Enable the LLM-probe controller. + # -- LLM-probe controller ---------------------------------------------- llm_probe_enabled: false - - # Provider backend: "mock" | "openrouter" | "openai" | "glm" | "minimax" | "claude_cli". llm_probe_backend: "mock" - - # LLM-probe model name. - llm_probe_model: "" - - # LLM-probe API base URL override. - llm_probe_base_url: "" - - # Environment variable containing the LLM API key. - llm_probe_api_key_env: "" - - # LLM-probe sampling temperature. - llm_probe_temperature: 0.0 - - # LLM-probe per-request timeout in seconds. - llm_probe_timeout: 30.0 - - # Maximum candidates sent per LLM-probe domain. - llm_probe_max_candidates: 8 - - # Maximum candidates sent across all LLM-probe domains. - llm_probe_max_candidates_total: 1024 - - # Top-K neuron candidates retained before probing. - llm_probe_neuron_topk: 512 - - # LLM-probe consult cadence in waves. llm_probe_cadence: 1 - - # Number of prior wave outcomes included in prompts. - llm_probe_history: 8 - - # Consecutive probe failures before disabling the controller. - llm_probe_max_failures: 3 - - # Comma-separated decision types the LLM may steer: - # "split" | "frontier" | "refine" | "neuron" | "input_split". llm_probe_decisions: "split,frontier,refine" - - # Log LLM-probe decisions. llm_probe_log: false - # -- BERT / text verification ----------------------------------------- - - # Public BERT verification method selector: null (off) or one of - # "planar" | "rule" | "alpha" | "ibp" | "discrete". When set, it overrides - # baf / alpha_mode / solver_tier per the method's preset. + # -- BERT/text method --------------------------------------------------- method: null - - # Enable backward attention form for BERT methods. - baf: true - - # BERT alpha handling mode: "fixed" | "rule" | "optimized" | "none". - alpha_mode: "fixed" - - # Norm p for perturbation semantics. p: 2.0 - - # Number of perturbed words for text verification (1 or 2). - perturbed_words: 1 - - # Initial epsilon for text verification. eps: 0.00001 - - # Maximum epsilon for text verification. max_eps: 0.01 - - # Number of verification iterations for text methods. - num_verify_iters: 5 - - # Top-k value for text verification. k: 1 - # Alpha optimization steps for text methods. - alpha_opt_steps: 1000 - - # -- Diagnostics ------------------------------------------------------ - - # Track logical BaB node ids and parent ids in TopKBounding. - provenance_enabled: false - - # Print verbose BaB diagnostics. - verbose: false - # ═════════════════════════════════════════════════════════════════════════ # Network generation (net_factory) # ═════════════════════════════════════════════════════════════════════════ diff --git a/act/config/check_parity.py b/act/config/check_parity.py index 0ec4eb0cc..662b9c89b 100644 --- a/act/config/check_parity.py +++ b/act/config/check_parity.py @@ -52,7 +52,7 @@ def require(self, label: str, passed: bool, detail: str = "") -> None: def check_backend(report: ParityReport) -> None: - from act.config.config import _BACKEND_YAML, BackendConfig + from act.config.config import _BACKEND_YAML, BaBConfig, BackendConfig from act.config.backend_cli import ( _BACKEND_OVERRIDE_SPEC, _BACKEND_SUBCONFIG_PREFIX, @@ -85,6 +85,12 @@ def check_backend(report: ParityReport) -> None: 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) + } + cli_only_allowed |= bab_cli_only_allowed print("[backend]") report.require("CLI options are backed by a dataclass field", diff --git a/act/config/config.py b/act/config/config.py index 865c352d1..45fa7c539 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -90,8 +90,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 @@ -105,10 +105,10 @@ class BaBConfig: lr_alpha: float = 0.1 """Adam learning rate for α (slope) variables.""" - lr_beta: float = 0.1 + lr_beta: float = field(default=0.1, metadata={"in_yaml": False}) """Adam learning rate for η (split-constraint KKT multipliers). 0.1 default; tune per network.""" - lr_decay: float = 0.98 + lr_decay: float = field(default=0.98, metadata={"in_yaml": False}) """Multiplicative learning-rate decay applied each Adam iteration.""" incremental_start_enabled: bool = True @@ -120,13 +120,13 @@ class BaBConfig: 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 @@ -139,7 +139,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 @@ -152,7 +152,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 @@ -161,23 +161,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 @@ -190,17 +190,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), @@ -208,18 +208,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: @@ -524,7 +524,12 @@ def from_yaml( 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) From 6cd8d3eef4715454e57db22129319e7ffeed3c26 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 10:02:02 +1000 Subject: [PATCH 18/27] chore(tf): remove test_tf_registry_parity drift-guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the optional IntervalTF/HybridzTF registry parity guard (added in c27cbf9); not needed per review. Standalone leaf test — no importers. --- act/back_end/test_tf_registry_parity.py | 41 ------------------------- 1 file changed, 41 deletions(-) delete mode 100644 act/back_end/test_tf_registry_parity.py diff --git a/act/back_end/test_tf_registry_parity.py b/act/back_end/test_tf_registry_parity.py deleted file mode 100644 index 97f9983ae..000000000 --- a/act/back_end/test_tf_registry_parity.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Pin the intended keyset relationship between the IntervalTF and HybridzTF -layer registries so future divergence fails loudly. - -Both transfer functions keep a ``_LAYER_REGISTRY`` over the same ``LayerKind`` -keyset but map to different handlers. The keysets are intentionally asymmetric by -exactly one kind: ``MEAN`` is interval-only (HybridZ raises NotImplementedError -for it). This is a deliberate design choice, not a bug, so merging the two into a -single canonical list would silently change dispatch behavior. This guard asserts -the exact intended difference; any drift (a kind added/removed from one registry -only) makes it fail instead of silently changing which layers each TF supports. -""" - -from act.back_end.interval_tf.interval_tf import IntervalTF -from act.back_end.hybridz_tf.hybridz_tf import HybridzTF -from act.back_end.layer_schema import LayerKind - - -def _registry_keys(tf_cls) -> set[str]: - return set(tf_cls._LAYER_REGISTRY.keys()) - - -def test_interval_hybridz_registry_parity() -> None: - interval = _registry_keys(IntervalTF) - hybridz = _registry_keys(HybridzTF) - - interval_only = interval - hybridz - hybridz_only = hybridz - interval - - assert interval_only == {LayerKind.MEAN.value}, ( - "IntervalTF/HybridzTF _LAYER_REGISTRY drift: interval-only kinds changed " - f"from {{'MEAN'}} to {sorted(interval_only)}" - ) - assert hybridz_only == set(), ( - "HybridzTF gained registry kinds absent from IntervalTF: " - f"{sorted(hybridz_only)}" - ) - - -if __name__ == "__main__": - test_interval_hybridz_registry_parity() - print("OK: IntervalTF/HybridzTF registry parity guard passed (intended diff = {MEAN})") From 5b1d2987aa2b77f60456eb70065c32f5d836838e Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 11:36:26 +1000 Subject: [PATCH 19/27] refactor(config): rename tier config files to .yaml backend_config.yaml -> backend.yaml, frontend_config.yaml -> frontend.yaml, pipeline_config.yaml -> pipeline.yaml. Updated all references: config.py _*_YAML constants + docstrings/errors, check_parity, backend/pipeline CLIs, net_factory config_path, CI act-bab.yml hashFiles, and docs. Verified: 0 remaining old-name refs, CONFIG PARITY OK, all 3 CLIs load. --- .github/workflows/act-bab.yml | 2 +- act/README.md | 20 +++++++++---------- act/back_end/examples/README.md | 6 +++--- act/back_end/net_factory.py | 4 ++-- .../{backend_config.yaml => backend.yaml} | 0 act/config/backend_cli.py | 2 +- act/config/check_parity.py | 4 ++-- act/config/config.py | 18 ++++++++--------- .../{frontend_config.yaml => frontend.yaml} | 0 .../{pipeline_config.yaml => pipeline.yaml} | 4 ++-- act/config/pipeline_cli.py | 2 +- act/pipeline/fuzzing/README.md | 8 ++++---- act/pipeline/fuzzing/mutations.py | 2 +- 13 files changed, 36 insertions(+), 36 deletions(-) rename act/config/{backend_config.yaml => backend.yaml} (100%) rename act/config/{frontend_config.yaml => frontend.yaml} (100%) rename act/config/{pipeline_config.yaml => pipeline.yaml} (97%) diff --git a/.github/workflows/act-bab.yml b/.github/workflows/act-bab.yml index 758bc8c7b..b265f3e4c 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/config/backend_config.yaml') }} + key: act-nets-${{ hashFiles('act/back_end/examples/config_gen_act_net.yaml', 'act/back_end/net_factory.py', 'act/config/backend.yaml') }} restore-keys: | act-nets- diff --git a/act/README.md b/act/README.md index dd3a52d70..162782c42 100644 --- a/act/README.md +++ b/act/README.md @@ -35,9 +35,9 @@ act/ │ ├── config/ # Centralized Configuration │ ├── config.py # Global configuration dataclasses -│ ├── backend_config.yaml # Back-end specific configuration -│ ├── pipeline_config.yaml # Pipeline specific configuration -│ ├── frontend_config.yaml # Front-end specific configuration +│ ├── backend.yaml # Back-end specific configuration +│ ├── 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 @@ -151,7 +151,7 @@ All ACT modules now have unified CLI architecture with consistent device/dtype h #### **Back-End CLI** - **`act/config/backend_cli.py`**: Comprehensive back-end verification CLI - Commands: - - `--generate`: Generate example networks from YAML config (default: `backend_config.yaml`) + - `--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) @@ -246,7 +246,7 @@ 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/backend_config.yaml`** (`backend.generation.net_factory`): network-generation DSL for example networks +- **`../config/backend.yaml`** (`backend.generation.net_factory`): 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 @@ -290,9 +290,9 @@ All ACT modules now have unified CLI architecture with consistent device/dtype h - **`path_config.py`**: Project path configuration and management - **`config/`**: Centralized configuration and CLIs - **`config.py`**: PerformanceOptions, BackendConfig, SolverConfig, etc. - - **`backend_config.yaml`**: Network generation and backend defaults - - **`pipeline_config.yaml`**: Pipeline testing and fuzzing defaults - - **`frontend_config.yaml`**: Loader and spec creation defaults + - **`backend.yaml`**: Network generation and backend defaults + - **`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/` @@ -437,7 +437,7 @@ 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/config/backend_config.yaml` +These files are generated from the YAML configuration `act/config/backend.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 YAML configuration and the factory rather than hand-editing the JSON files. @@ -445,7 +445,7 @@ 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 `backend_config.yaml` +1. **Generate Networks**: `--generate` creates all networks defined in `backend.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/examples/README.md b/act/back_end/examples/README.md index 11272efbd..406164640 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 backend_config.yaml (backend.generation.net_factory) +# Generate all networks from backend.yaml (backend.generation.net_factory) python -m act.back_end --generate # This will create JSON network files in the nets/ subfolder @@ -14,13 +14,13 @@ python -m act.back_end --generate ## Files - `../net_factory.py` - Concise factory located in back_end folder -- `../../config/backend_config.yaml` (`backend.generation.net_factory`) - Complete network specifications with layer definitions +- `../../config/backend.yaml` (`backend.generation.net_factory`) - 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 `backend_config.yaml` (`backend.generation.net_factory`) +1. Reads complete network specifications from `backend.yaml` (`backend.generation.net_factory`) 2. Creates simplified network objects with proper graph structure 3. Saves networks as JSON files in the `nets/` subfolder diff --git a/act/back_end/net_factory.py b/act/back_end/net_factory.py index 2f70e4a63..612a2fff9 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 the DSL in backend_config.yaml (backend.generation.net_factory). +# Generates ACT networks from the DSL in backend.yaml (backend.generation.net_factory). # # Usage: # python -m act.back_end --generate # default 15 nets @@ -1127,7 +1127,7 @@ def __init__( registry_mode: str = "intersection", ): self.config = config - self.config_path = "backend_config.yaml:backend.generation.net_factory" + self.config_path = "backend.yaml:backend.generation.net_factory" common = self.config["common"] self.tf_targets = tf_targets diff --git a/act/config/backend_config.yaml b/act/config/backend.yaml similarity index 100% rename from act/config/backend_config.yaml rename to act/config/backend.yaml diff --git a/act/config/backend_cli.py b/act/config/backend_cli.py index e6becc44e..12c6507fa 100644 --- a/act/config/backend_cli.py +++ b/act/config/backend_cli.py @@ -1267,7 +1267,7 @@ def main(): type=str, default=None, dest="backend_config", - help="Path to backend YAML config (default: act/config/backend_config.yaml)", + help="Path to backend YAML config (default: act/config/backend.yaml)", ) # Common options diff --git a/act/config/check_parity.py b/act/config/check_parity.py index 662b9c89b..a15ec781b 100644 --- a/act/config/check_parity.py +++ b/act/config/check_parity.py @@ -7,7 +7,7 @@ The config.py dataclasses are the single source of truth. Two documented asymmetries are allowed: -* pipeline ``verification.bab`` is a SPARSE override of ``backend_config.yaml`` +* 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 @@ -141,7 +141,7 @@ def check_pipeline(report: ParityReport) -> None: bab_fields = _field_names(BaBConfig) bab_cli = set(_PIPELINE_BAB_OVERRIDE_FIELDS) bab_yaml = set(((pipeline_yaml.get("verification") or {}).get("bab") or {}).keys()) - print("[pipeline.verification.bab] (sparse override of backend_config.yaml)") + 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", diff --git a/act/config/config.py b/act/config/config.py index 45fa7c539..46b143287 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -8,9 +8,9 @@ import yaml -_BACKEND_YAML = Path(__file__).parent / "backend_config.yaml" -_PIPELINE_YAML = Path(__file__).parent / "pipeline_config.yaml" -_FRONTEND_YAML = Path(__file__).parent / "frontend_config.yaml" +_BACKEND_YAML = Path(__file__).parent / "backend.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"} @@ -78,7 +78,7 @@ class BaBConfig: Construction:: BaBConfig() # programmatic defaults - BaBConfig.from_yaml() # load from act/config/backend_config.yaml + BaBConfig.from_yaml() # load from act/config/backend.yaml BaBConfig.from_yaml(path, **kw) # custom YAML + overrides """ @@ -255,7 +255,7 @@ def from_yaml( if not path.exists(): raise FileNotFoundError( - f"Backend config not found: {path}\nExpected: act/config/backend_config.yaml" + f"Backend config not found: {path}\nExpected: act/config/backend.yaml" ) with open(path) as f: @@ -295,7 +295,7 @@ class GenerationConfig: Controls the simple knobs (how many, where, seed, TF filtering). The architecture-sampling DSL (families, sampling rules, input/output specs) is embedded in ``net_factory``, loaded from ``backend.generation.net_factory`` - in backend_config.yaml. + in backend.yaml. """ output_dir: str = "act/back_end/examples/nets" @@ -357,7 +357,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/config/backend_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:: @@ -743,7 +743,7 @@ def from_yaml( 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_config.yaml" + f"Pipeline config not found: {path}\nExpected: act/config/pipeline.yaml" ) FuzzingConfig = import_module("act.pipeline.fuzzing.actfuzzer").FuzzingConfig @@ -796,7 +796,7 @@ def read_fuzzing_section(config_path: Optional[str | Path] = None) -> dict[str, 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_config.yaml" + f"Pipeline config not found: {path}\nExpected: act/config/pipeline.yaml" ) with open(path) as f: yaml_data = yaml.safe_load(f) or {} diff --git a/act/config/frontend_config.yaml b/act/config/frontend.yaml similarity index 100% rename from act/config/frontend_config.yaml rename to act/config/frontend.yaml diff --git a/act/config/pipeline_config.yaml b/act/config/pipeline.yaml similarity index 97% rename from act/config/pipeline_config.yaml rename to act/config/pipeline.yaml index 223fedd81..8947eb46c 100644 --- a/act/config/pipeline_config.yaml +++ b/act/config/pipeline.yaml @@ -8,7 +8,7 @@ # # Sections: # fuzzing – ACTFuzzer counterexample search -# verification – Branch-and-Bound overrides (see backend_config.yaml for the +# 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 @@ -74,7 +74,7 @@ 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_config.yaml (backend.bab). + # 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 diff --git a/act/config/pipeline_cli.py b/act/config/pipeline_cli.py index eb171a0f5..52e4a1e7a 100644 --- a/act/config/pipeline_cli.py +++ b/act/config/pipeline_cli.py @@ -1658,7 +1658,7 @@ 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 backend_config.yaml " + "the ``validate.batch_sizes`` list in backend.yaml " "(backend.generation.net_factory), then to ``[None]`` (native only).", ) validation_group.add_argument( diff --git a/act/pipeline/fuzzing/README.md b/act/pipeline/fuzzing/README.md index cb69893ff..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 act/config/pipeline_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 `act/config/pipeline_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 `act/config/pipeline_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 act/config/pipeline_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/mutations.py b/act/pipeline/fuzzing/mutations.py index a8b2afbb5..7728afa50 100644 --- a/act/pipeline/fuzzing/mutations.py +++ b/act/pipeline/fuzzing/mutations.py @@ -60,7 +60,7 @@ ### Configuration -Set in `act/config/pipeline_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) From bd9a7a5bb62728af13f50cc7546159f56874e3be Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 11:49:27 +1000 Subject: [PATCH 20/27] refactor(config): move full network-generation config to networkGen.yaml Extract the entire generation config (control knobs + net_factory architecture DSL) out of backend.yaml into act/config/networkGen.yaml. backend.yaml now holds only verification/runtime/solver/hybridz/bab settings (601 -> 129 lines). - networkGen.yaml: generation control knobs (output_dir, num_instances, base_seed, tf_targets, coverage_*, write_manifest, ...) + net_factory: DSL (families, sampling rules, input/output specs) - config.py: from_yaml loads the generation section from networkGen.yaml; net_factory reverts to a normal field (drops the in_yaml=False special-casing) - check_parity: generation gen_* keys sourced from networkGen.yaml - backend.yaml: generation: section removed - --gen-* CLI overrides still work; docs/help repointed to networkGen.yaml Behavior-preserving. Verified: generation values load (num=15, seed=42), net_factory DSL deep-equal, --gen-num override, CONFIG PARITY OK, batch_sizes [None,1,4], --generate exit 0 (74 nets), to_yaml OK, lsp clean. --- act/README.md | 17 +- act/back_end/examples/README.md | 6 +- act/back_end/net_factory.py | 4 +- act/config/backend.yaml | 474 +------------------------------- act/config/check_parity.py | 6 +- act/config/config.py | 26 +- act/config/networkGen.yaml | 472 +++++++++++++++++++++++++++++++ act/config/pipeline_cli.py | 4 +- 8 files changed, 508 insertions(+), 501 deletions(-) create mode 100644 act/config/networkGen.yaml diff --git a/act/README.md b/act/README.md index 162782c42..7f85682c9 100644 --- a/act/README.md +++ b/act/README.md @@ -35,7 +35,8 @@ act/ │ ├── config/ # Centralized Configuration │ ├── config.py # Global configuration dataclasses -│ ├── backend.yaml # Back-end specific configuration +│ ├── backend.yaml # Back-end specific scalar configuration +│ ├── networkGen.yaml # NetFactory architecture-sampling DSL │ ├── pipeline.yaml # Pipeline specific configuration │ ├── frontend.yaml # Front-end specific configuration │ ├── backend_cli.py # Back-end CLI implementation @@ -246,7 +247,7 @@ 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/backend.yaml`** (`backend.generation.net_factory`): network-generation DSL for example networks +- **`../config/networkGen.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 @@ -290,7 +291,8 @@ All ACT modules now have unified CLI architecture with consistent device/dtype h - **`path_config.py`**: Project path configuration and management - **`config/`**: Centralized configuration and CLIs - **`config.py`**: PerformanceOptions, BackendConfig, SolverConfig, etc. - - **`backend.yaml`**: Network generation and backend defaults + - **`backend.yaml`**: Back-end runtime and generation scalar defaults + - **`networkGen.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 @@ -437,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/config/backend.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/networkGen.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 `backend.yaml` +1. **Generate Networks**: `--generate` creates all networks defined in `networkGen.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/examples/README.md b/act/back_end/examples/README.md index 406164640..0bebcd36d 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 backend.yaml (backend.generation.net_factory) +# Generate all networks from act/config/networkGen.yaml python -m act.back_end --generate # This will create JSON network files in the nets/ subfolder @@ -14,13 +14,13 @@ python -m act.back_end --generate ## Files - `../net_factory.py` - Concise factory located in back_end folder -- `../../config/backend.yaml` (`backend.generation.net_factory`) - Complete network specifications with layer definitions +- `../../config/networkGen.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 `backend.yaml` (`backend.generation.net_factory`) +1. Reads complete network specifications from `act/config/networkGen.yaml` 2. Creates simplified network objects with proper graph structure 3. Saves networks as JSON files in the `nets/` subfolder diff --git a/act/back_end/net_factory.py b/act/back_end/net_factory.py index 612a2fff9..c74c19015 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 the DSL in backend.yaml (backend.generation.net_factory). +# Generates ACT networks from the DSL in act/config/networkGen.yaml. # # Usage: # python -m act.back_end --generate # default 15 nets @@ -1127,7 +1127,7 @@ def __init__( registry_mode: str = "intersection", ): self.config = config - self.config_path = "backend.yaml:backend.generation.net_factory" + self.config_path = "networkGen.yaml" common = self.config["common"] self.tf_targets = tf_targets diff --git a/act/config/backend.yaml b/act/config/backend.yaml index 3a610643c..ca9cb8276 100644 --- a/act/config/backend.yaml +++ b/act/config/backend.yaml @@ -9,7 +9,7 @@ # # Layout (all under the single top-level `backend:` mapping): # runtime selectors → verification cascade → solver_config → tf → hybridz -# → bab (branch-and-bound) → generation (net_factory) +# → bab (branch-and-bound) backend: @@ -127,475 +127,3 @@ backend: eps: 0.00001 max_eps: 0.01 k: 1 - - # ═════════════════════════════════════════════════════════════════════════ - # Network generation (net_factory) - # ═════════════════════════════════════════════════════════════════════════ - # Controls how many networks to generate, where, and TF filtering. The - # architecture-sampling DSL (families, sampling rules, input/output specs) is - # embedded below under `net_factory:` and consumed by NetFactory via - # GenerationConfig.net_factory (was previously act/back_end/examples/config_gen_act_net.yaml). - generation: - # 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 - - # Architecture-sampling DSL consumed by NetFactory (families, sampling - # rules, input/output specs, generation controls, validate.batch_sizes). - net_factory: - # ============================================================================ - # 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/check_parity.py b/act/config/check_parity.py index a15ec781b..c1a403eb1 100644 --- a/act/config/check_parity.py +++ b/act/config/check_parity.py @@ -52,7 +52,7 @@ def require(self, label: str, passed: bool, detail: str = "") -> None: def check_backend(report: ParityReport) -> None: - from act.config.config import _BACKEND_YAML, BaBConfig, BackendConfig + from act.config.config import _BACKEND_YAML, _NETGEN_YAML, BaBConfig, BackendConfig from act.config.backend_cli import ( _BACKEND_OVERRIDE_SPEC, _BACKEND_SUBCONFIG_PREFIX, @@ -78,6 +78,10 @@ def check_backend(report: ParityReport) -> None: 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 diff --git a/act/config/config.py b/act/config/config.py index 46b143287..fb60e8fea 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -9,6 +9,7 @@ import yaml _BACKEND_YAML = Path(__file__).parent / "backend.yaml" +_NETGEN_YAML = Path(__file__).parent / "networkGen.yaml" _PIPELINE_YAML = Path(__file__).parent / "pipeline.yaml" _FRONTEND_YAML = Path(__file__).parent / "frontend.yaml" @@ -27,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.""" @@ -292,10 +298,8 @@ def to_yaml(self, path: Union[str, Path]) -> Path: class GenerationConfig: """Configuration for network generation via ``NetFactory``. - Controls the simple knobs (how many, where, seed, TF filtering). The - architecture-sampling DSL (families, sampling rules, input/output specs) is - embedded in ``net_factory``, loaded from ``backend.generation.net_factory`` - in backend.yaml. + Controls network generation knobs and the architecture-sampling DSL loaded + from ``act/config/networkGen.yaml``. """ output_dir: str = "act/back_end/examples/nets" @@ -472,9 +476,7 @@ def from_yaml( bab: enabled: true ... - generation: - num_instances: 15 - ... + generation settings are loaded from act/config/networkGen.yaml Override naming: - ``bab_`` → ``BaBConfig.`` @@ -487,12 +489,11 @@ def from_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", {}) solver_raw: dict[str, Any] = backend_raw.pop("solver_config", {}) if isinstance(backend_raw.get("solver"), dict): @@ -537,7 +538,7 @@ 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) @@ -577,7 +578,7 @@ 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") solver_d = d.pop("solver_config") bab_enabled = d.pop("bab_enabled") @@ -589,7 +590,6 @@ def to_yaml(self, path: Union[str, Path]) -> Path: "backend": { **d, "bab": bab_d, - "generation": gen_d, "hybridz": hz_d, "solver_config": solver_d, } diff --git a/act/config/networkGen.yaml b/act/config/networkGen.yaml new file mode 100644 index 000000000..f941265e1 --- /dev/null +++ b/act/config/networkGen.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/networkGen.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_cli.py b/act/config/pipeline_cli.py index 52e4a1e7a..6ba932435 100644 --- a/act/config/pipeline_cli.py +++ b/act/config/pipeline_cli.py @@ -1658,8 +1658,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 backend.yaml " - "(backend.generation.net_factory), then to ``[None]`` (native only).", + "the ``validate.batch_sizes`` list in act/config/networkGen.yaml, then " + "to ``[None]`` (native only).", ) validation_group.add_argument( "--ignore-errors", From 47b095d144cc7039f5ad99d351f9288c7ff5b36a Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 11:56:31 +1000 Subject: [PATCH 21/27] refactor(config): rename networkGen.yaml -> gen_act_net.yaml; fix stale CI cache key - Rename act/config/networkGen.yaml -> act/config/gen_act_net.yaml; update all references (config.py _NETGEN_YAML, net_factory config_path, pipeline_cli help, act/README.md + examples/README.md docs). - act-bab.yml: fix the generated-nets cache key. It still hashed act/back_end/examples/config_gen_act_net.yaml (deleted back in P0), so the cache never invalidated when the generation config changed. Point it at the real source act/config/gen_act_net.yaml. Verified: 0 stale config-path refs (incl .github), CONFIG PARITY OK, generation config loads from gen_act_net.yaml, batch_sizes [None,1,4], lsp clean. --- .github/workflows/act-bab.yml | 2 +- act/README.md | 10 +++++----- act/back_end/examples/README.md | 6 +++--- act/back_end/net_factory.py | 4 ++-- act/config/config.py | 6 +++--- act/config/{networkGen.yaml => gen_act_net.yaml} | 2 +- act/config/pipeline_cli.py | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) rename act/config/{networkGen.yaml => gen_act_net.yaml} (99%) diff --git a/.github/workflows/act-bab.yml b/.github/workflows/act-bab.yml index b265f3e4c..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/config/backend.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/act/README.md b/act/README.md index 7f85682c9..6cc473431 100644 --- a/act/README.md +++ b/act/README.md @@ -36,7 +36,7 @@ act/ ├── config/ # Centralized Configuration │ ├── config.py # Global configuration dataclasses │ ├── backend.yaml # Back-end specific scalar configuration -│ ├── networkGen.yaml # NetFactory architecture-sampling DSL +│ ├── 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 @@ -247,7 +247,7 @@ 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/networkGen.yaml`**: network-generation DSL for example networks +- **`../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 @@ -292,7 +292,7 @@ All ACT modules now have unified CLI architecture with consistent device/dtype h - **`config/`**: Centralized configuration and CLIs - **`config.py`**: PerformanceOptions, BackendConfig, SolverConfig, etc. - **`backend.yaml`**: Back-end runtime and generation scalar defaults - - **`networkGen.yaml`**: NetFactory architecture-sampling DSL + - **`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 @@ -439,7 +439,7 @@ 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 architecture-sampling DSL in `act/config/networkGen.yaml` +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 @@ -448,7 +448,7 @@ 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 `networkGen.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/examples/README.md b/act/back_end/examples/README.md index 0bebcd36d..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 act/config/networkGen.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,13 +14,13 @@ python -m act.back_end --generate ## Files - `../net_factory.py` - Concise factory located in back_end folder -- `../../config/networkGen.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 `act/config/networkGen.yaml` +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 diff --git a/act/back_end/net_factory.py b/act/back_end/net_factory.py index c74c19015..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 the DSL in act/config/networkGen.yaml. +# Generates ACT networks from the DSL in act/config/gen_act_net.yaml. # # Usage: # python -m act.back_end --generate # default 15 nets @@ -1127,7 +1127,7 @@ def __init__( registry_mode: str = "intersection", ): self.config = config - self.config_path = "networkGen.yaml" + self.config_path = "gen_act_net.yaml" common = self.config["common"] self.tf_targets = tf_targets diff --git a/act/config/config.py b/act/config/config.py index fb60e8fea..9c4ece98d 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -9,7 +9,7 @@ import yaml _BACKEND_YAML = Path(__file__).parent / "backend.yaml" -_NETGEN_YAML = Path(__file__).parent / "networkGen.yaml" +_NETGEN_YAML = Path(__file__).parent / "gen_act_net.yaml" _PIPELINE_YAML = Path(__file__).parent / "pipeline.yaml" _FRONTEND_YAML = Path(__file__).parent / "frontend.yaml" @@ -299,7 +299,7 @@ class GenerationConfig: """Configuration for network generation via ``NetFactory``. Controls network generation knobs and the architecture-sampling DSL loaded - from ``act/config/networkGen.yaml``. + from ``act/config/gen_act_net.yaml``. """ output_dir: str = "act/back_end/examples/nets" @@ -476,7 +476,7 @@ def from_yaml( bab: enabled: true ... - generation settings are loaded from act/config/networkGen.yaml + generation settings are loaded from act/config/gen_act_net.yaml Override naming: - ``bab_`` → ``BaBConfig.`` diff --git a/act/config/networkGen.yaml b/act/config/gen_act_net.yaml similarity index 99% rename from act/config/networkGen.yaml rename to act/config/gen_act_net.yaml index f941265e1..2e8375072 100644 --- a/act/config/networkGen.yaml +++ b/act/config/gen_act_net.yaml @@ -45,7 +45,7 @@ net_factory: # NetFactory architecture-sampling DSL ("the different networks") # ============================================================================ # Consumed via GenerationConfig.net_factory and loaded by - # BackendConfig.from_yaml from act/config/networkGen.yaml. + # BackendConfig.from_yaml from act/config/gen_act_net.yaml. # ============================================================================ # ACT Network Generation Configuration diff --git a/act/config/pipeline_cli.py b/act/config/pipeline_cli.py index 6ba932435..7bc89069b 100644 --- a/act/config/pipeline_cli.py +++ b/act/config/pipeline_cli.py @@ -1658,7 +1658,7 @@ 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 act/config/networkGen.yaml, then " + "the ``validate.batch_sizes`` list in act/config/gen_act_net.yaml, then " "to ``[None]`` (native only).", ) validation_group.add_argument( From f1a3537e7c5d5424110809f6ccecc6fb227435a3 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 13:25:41 +1000 Subject: [PATCH 22/27] refactor(schema): merge layer_torch_map into layer_schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move ACT_TO_TORCH (LayerKind -> nn.Module) and the 5 custom activation modules (_Erf/_Sqrt/_Quantize/_Sin/_Cos) from layer_torch_map.py into layer_schema.py (after REGISTRY), so all LayerKind metadata -- param schema + torch restoration map -- lives in one file. Delete layer_torch_map.py. - act2torch.py / torch2act.py now import ACT_TO_TORCH from layer_schema - fix stale comments in layer_schema that pointed at act2torch._ACT_TO_TORCH (WS6.2 had already relocated it) No dedup (schema and torch-map are orthogonal LayerKind metadata) — this is co-location. Behavior-preserving; verified --verify act2torch (float64) and torch2act (float32) exit 0, ACT_TO_TORCH 38 entries, no import cycle, lsp clean. --- act/back_end/layer_schema.py | 99 +++++++++++++++++++++++--- act/back_end/layer_torch_map.py | 97 ------------------------- act/pipeline/verification/act2torch.py | 3 +- act/pipeline/verification/torch2act.py | 3 +- 4 files changed, 92 insertions(+), 110 deletions(-) delete mode 100644 act/back_end/layer_torch_map.py 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/layer_torch_map.py b/act/back_end/layer_torch_map.py deleted file mode 100644 index 688461cc4..000000000 --- a/act/back_end/layer_torch_map.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env python3 -# ===- act/back_end/layer_torch_map.py - ACT/Torch layer mapping ---------====# - -"""Canonical LayerKind <-> torch.nn.Module correspondence.""" - -from typing import override - -import torch -import torch.nn as nn - -from act.back_end.layer_schema import LayerKind - - -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, -} - - -__all__ = ["ACT_TO_TORCH"] diff --git a/act/pipeline/verification/act2torch.py b/act/pipeline/verification/act2torch.py index 2f987d5ba..4bd0f0abf 100644 --- a/act/pipeline/verification/act2torch.py +++ b/act/pipeline/verification/act2torch.py @@ -29,8 +29,7 @@ import logging from act.back_end.core import Net, Layer -from act.back_end.layer_schema import LayerKind, REGISTRY -from act.back_end.layer_torch_map import ACT_TO_TORCH +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__) diff --git a/act/pipeline/verification/torch2act.py b/act/pipeline/verification/torch2act.py index 359c8d3e8..a00bd515e 100644 --- a/act/pipeline/verification/torch2act.py +++ b/act/pipeline/verification/torch2act.py @@ -65,8 +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_torch_map import ACT_TO_TORCH +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, From c4cf2d06b99bdc3d0c0aa68a7fae4b0e90a03821 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 15:21:53 +1000 Subject: [PATCH 23/27] refactor(config): rename SolverConfig -> TorchLPConfig (per-solver naming) SolverConfig was TorchLP-only despite the generic name. Rename to TorchLPConfig; yaml solver_config: -> torchlp:; CLI --solver-* -> --torchlp-*. First step of organizing solver params under each solver's own block. Behavior-preserving. Verified: 0 SolverConfig/solver_config refs, CONFIG PARITY OK, torchlp values + --torchlp-max-iter override work, torchlp --validate-soundness 222/0 exit 0, lsp clean. --- act/README.md | 2 +- act/back_end/solver/solver_torchlp.py | 6 ++--- act/config/backend.yaml | 6 ++--- act/config/backend_cli.py | 22 +++++++++--------- act/config/config.py | 32 +++++++++++++-------------- 5 files changed, 33 insertions(+), 35 deletions(-) diff --git a/act/README.md b/act/README.md index 6cc473431..65afc9e24 100644 --- a/act/README.md +++ b/act/README.md @@ -290,7 +290,7 @@ All ACT modules now have unified CLI architecture with consistent device/dtype h - **`path_config.py`**: Project path configuration and management - **`config/`**: Centralized configuration and CLIs - - **`config.py`**: PerformanceOptions, BackendConfig, SolverConfig, etc. + - **`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 diff --git a/act/back_end/solver/solver_torchlp.py b/act/back_end/solver/solver_torchlp.py index 28d0a4282..efd130114 100644 --- a/act/back_end/solver/solver_torchlp.py +++ b/act/back_end/solver/solver_torchlp.py @@ -3,7 +3,7 @@ from typing import Optional import torch -from act.config.config import SolverConfig +from act.config.config import TorchLPConfig from act.back_end.solver.solver_base import ( Solver, SolveStatus, @@ -20,8 +20,8 @@ class TorchLPSolver(Solver): - Supports GPU via device hint in begin(...). - LP-only: no integrality constraints (no binary vars, no SOS2). """ - def __init__(self, config: Optional[SolverConfig] = None): - cfg = config or SolverConfig() + def __init__(self, config: Optional[TorchLPConfig] = None): + cfg = config or TorchLPConfig() self._device = get_default_device() self._dtype = get_default_dtype() # parameters diff --git a/act/config/backend.yaml b/act/config/backend.yaml index ca9cb8276..06af3c21a 100644 --- a/act/config/backend.yaml +++ b/act/config/backend.yaml @@ -8,7 +8,7 @@ # Env vars: ACT_SOLVER / ACT_DEVICE / ACT_DTYPE # # Layout (all under the single top-level `backend:` mapping): -# runtime selectors → verification cascade → solver_config → tf → hybridz +# runtime selectors → verification cascade → torchlp → tf → hybridz # → bab (branch-and-bound) backend: @@ -56,9 +56,9 @@ backend: bab_max_batch_size: 8 # ───────────────────────────────────────────────────────────────────────── - # TorchLP solver tuning (solver_config) + # TorchLP solver tuning (torchlp) # ───────────────────────────────────────────────────────────────────────── - solver_config: + torchlp: # -- Penalty weights -- rho_eq: 10.0 # equality-constraint penalty weight rho_ineq: 10.0 # inequality-constraint penalty weight diff --git a/act/config/backend_cli.py b/act/config/backend_cli.py index 12c6507fa..917aabdcc 100644 --- a/act/config/backend_cli.py +++ b/act/config/backend_cli.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import Any, Dict, List, NamedTuple, Optional, Union, cast, get_args, get_origin, get_type_hints -from act.config.config import VALID_BERT_METHODS, _VALID_SOLVERS +from act.config.config import 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 @@ -65,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.config.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig, SolverConfig + from act.config.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig, TorchLPConfig existing_options = { option @@ -121,12 +121,12 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk "Backend Config Overrides (generated)", "", "", - {"bab", "generation", "hybridz", "solver_config"}, + {"bab", "generation", "hybridz", "torchlp"}, ) add_group(BaBConfig, "BaB Config Overrides (generated)", "bab-", "bab_", set()) add_group(GenerationConfig, "Generation Config Overrides (generated)", "gen-", "gen_", {"net_factory"}) add_group(HybridZConfig, "HybridZ Config Overrides (generated)", "hz-", "hybridz_", set()) - add_group(SolverConfig, "Solver Config Overrides (generated)", "solver-", "solver_", set()) + add_group(TorchLPConfig, "TorchLP Config Overrides (generated)", "torchlp-", "torchlp_", set()) # Backend YAML sub-section (== the BackendConfig nested-dataclass field name) -> @@ -138,7 +138,7 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk "bab": "bab_", "generation": "gen_", "hybridz": "hybridz_", - "solver_config": "solver_", + "torchlp": "torchlp_", } @@ -169,7 +169,7 @@ class _SkipUnsupported(NamedTuple): kinds: tuple[str, ...] -def _make_solver(solver_name: str, solver_config=None): +def _make_solver(solver_name: str, torchlp_config: Optional[TorchLPConfig] = 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``. @@ -181,14 +181,14 @@ def _make_solver(solver_name: str, solver_config=None): return GurobiSolver() if solver_name == "torchlp": - return TorchLPSolver(config=solver_config) + return TorchLPSolver(config=torchlp_config) # "auto": try Gurobi, fall back to TorchLP try: from act.back_end.solver.solver_gurobi import GurobiSolver return GurobiSolver() except Exception: - return TorchLPSolver(config=solver_config) + return TorchLPSolver(config=torchlp_config) def _verify_one_net( @@ -278,7 +278,7 @@ def _verify_one_net( try: lp_results = verify_lp_batched( net, - solver_factory=lambda: _make_solver(backend_cfg.solver, backend_cfg.solver_config), + solver_factory=lambda: _make_solver(backend_cfg.solver, backend_cfg.torchlp), timelimit=backend_cfg.timeout, ) results = [ @@ -323,7 +323,7 @@ def _verify_one_net( results = [ _vbb( slice_net_to_sample(net, i), - solver_factory=lambda: _make_solver(backend_cfg.solver, backend_cfg.solver_config), + solver_factory=lambda: _make_solver(backend_cfg.solver, backend_cfg.torchlp), config=bab_cfg, max_batch_size=backend_cfg.bab_max_batch_size, time_budget_s=backend_cfg.timeout, @@ -1524,7 +1524,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, cfg.solver_config), + solver_factory=lambda: _make_solver(cfg.solver, cfg.torchlp), timelimit=cfg.timeout, ) assert len(lp_results) == 1 diff --git a/act/config/config.py b/act/config/config.py index 9c4ece98d..4f69349ec 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -334,7 +334,7 @@ class HybridZConfig: @dataclass -class SolverConfig: +class TorchLPConfig: rho_eq: float = 10.0 rho_ineq: float = 10.0 max_iter: int = 2000 @@ -399,7 +399,7 @@ class BackendConfig: generation: GenerationConfig = field(default_factory=GenerationConfig) hybridz: HybridZConfig = field(default_factory=HybridZConfig) - solver_config: SolverConfig = field(default_factory=SolverConfig) + torchlp: TorchLPConfig = field(default_factory=TorchLPConfig) method: Optional[str] = field(default=None, metadata={"in_yaml": False}) p: float = field(default=2.0, metadata={"in_yaml": False}) @@ -482,7 +482,7 @@ def from_yaml( - ``bab_`` → ``BaBConfig.`` - ``gen_`` → ``GenerationConfig.`` - ``hybridz_`` → ``HybridZConfig.`` - - ``solver_`` → ``SolverConfig.`` + - ``torchlp_`` → ``TorchLPConfig.`` - ``bab_enabled`` → top-level ``bab_enabled`` """ path = Path(config_path) if config_path else _BACKEND_YAML @@ -495,9 +495,7 @@ def from_yaml( bab_raw: dict[str, Any] = backend_raw.pop("bab", {}) gen_raw: dict[str, Any] = _load_yaml(_NETGEN_YAML) if _NETGEN_YAML.exists() else {} hz_raw: dict[str, Any] = backend_raw.pop("hybridz", {}) - solver_raw: dict[str, Any] = backend_raw.pop("solver_config", {}) - if isinstance(backend_raw.get("solver"), dict): - solver_raw = backend_raw.pop("solver") + torchlp_raw: dict[str, Any] = backend_raw.pop("torchlp", {}) # Extract "enabled" from bab section → top-level bab_enabled bab_enabled = bab_raw.pop("enabled", None) @@ -506,11 +504,11 @@ 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)} - solver_fields = {fld.name for fld in fields(SolverConfig)} + torchlp_fields = {fld.name for fld in fields(TorchLPConfig)} bab_overrides: dict[str, Any] = {} gen_overrides: dict[str, Any] = {} hz_overrides: dict[str, Any] = {} - solver_overrides: dict[str, Any] = {} + torchlp_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: @@ -519,8 +517,8 @@ 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("solver_") and k[7:] in solver_fields: - solver_overrides[k[7:]] = v + elif k.startswith("torchlp_") and k[8:] in torchlp_fields: + torchlp_overrides[k[8:]] = v else: top_overrides[k] = v @@ -543,16 +541,16 @@ def from_yaml( hz_merged.update(hz_overrides) hz_config = HybridZConfig(**hz_merged) - solver_merged = {k: v for k, v in solver_raw.items() if k in solver_fields} - solver_merged.update(solver_overrides) - solver_config = SolverConfig(**solver_merged) + 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) # Build top-level config top_fields = {fld.name for fld in fields(cls)} - { "bab", "generation", "hybridz", - "solver_config", + "torchlp", } top_merged: dict[str, Any] = {} for k, v in backend_raw.items(): @@ -568,7 +566,7 @@ def from_yaml( bab=bab_config, generation=gen_config, hybridz=hz_config, - solver_config=solver_config, + torchlp=torchlp_config, **top_merged, ) @@ -580,7 +578,7 @@ def to_yaml(self, path: Union[str, Path]) -> Path: bab_d = d.pop("bab") d.pop("generation") hz_d = d.pop("hybridz") - solver_d = d.pop("solver_config") + torchlp_d = d.pop("torchlp") bab_enabled = d.pop("bab_enabled") bab_d["enabled"] = bab_enabled @@ -591,7 +589,7 @@ def to_yaml(self, path: Union[str, Path]) -> Path: **d, "bab": bab_d, "hybridz": hz_d, - "solver_config": solver_d, + "torchlp": torchlp_d, } }, f, From 00fc28eec5e7c8c179f1334d83563ac31fa9e1b9 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 15:32:35 +1000 Subject: [PATCH 24/27] feat(config): add GurobiConfig (gurobi: block) + wire GurobiSolver Gurobi had no config -- all knobs hardcoded/defaulted. Add a per-solver gurobi: block (time_limit, mip_gap, threads, output_flag) and wire GurobiSolver(config) to apply them (OutputFlag/MIPGap/Threads; optional TimeLimit override). Also add a comment under solver: clarifying the selector-vs-per-solver-tuning-block layout. Config plumbing verified (the gurobi solve path needs a license, untestable locally): CONFIG PARITY OK, gurobi config loads + --gurobi-threads override, --gurobi-* flags present, solver imports, lsp clean. --- act/back_end/solver/solver_gurobi.py | 23 ++++++++++++++++------- act/config/backend.yaml | 11 +++++++++++ act/config/backend_cli.py | 28 +++++++++++++++++++--------- act/config/config.py | 23 +++++++++++++++++++++++ 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/act/back_end/solver/solver_gurobi.py b/act/back_end/solver/solver_gurobi.py index 5ce2c3288..93bf58f81 100644 --- a/act/back_end/solver/solver_gurobi.py +++ b/act/back_end/solver/solver_gurobi.py @@ -11,6 +11,7 @@ _problem, _make_problem_n1, ) +from act.config.config import GurobiConfig from act.util.path_config import get_project_root if TYPE_CHECKING: @@ -60,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]) @@ -82,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: @@ -138,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. @@ -169,8 +177,9 @@ 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 diff --git a/act/config/backend.yaml b/act/config/backend.yaml index 06af3c21a..369c818dd 100644 --- a/act/config/backend.yaml +++ b/act/config/backend.yaml @@ -24,6 +24,8 @@ backend: # 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. @@ -78,6 +80,15 @@ backend: 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) # ───────────────────────────────────────────────────────────────────────── diff --git a/act/config/backend_cli.py b/act/config/backend_cli.py index 917aabdcc..25cf6d28b 100644 --- a/act/config/backend_cli.py +++ b/act/config/backend_cli.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import Any, Dict, List, NamedTuple, Optional, Union, cast, get_args, get_origin, get_type_hints -from act.config.config import TorchLPConfig, VALID_BERT_METHODS, _VALID_SOLVERS +from act.config.config import 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 @@ -65,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.config.config import BackendConfig, BaBConfig, GenerationConfig, HybridZConfig, TorchLPConfig + from act.config.config import BackendConfig, BaBConfig, GenerationConfig, GurobiConfig, HybridZConfig, TorchLPConfig existing_options = { option @@ -121,11 +121,12 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk "Backend Config Overrides (generated)", "", "", - {"bab", "generation", "hybridz", "torchlp"}, + {"bab", "generation", "hybridz", "gurobi", "torchlp"}, ) add_group(BaBConfig, "BaB Config Overrides (generated)", "bab-", "bab_", 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()) @@ -138,6 +139,7 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk "bab": "bab_", "generation": "gen_", "hybridz": "hybridz_", + "gurobi": "gurobi_", "torchlp": "torchlp_", } @@ -169,7 +171,11 @@ class _SkipUnsupported(NamedTuple): kinds: tuple[str, ...] -def _make_solver(solver_name: str, torchlp_config: Optional[TorchLPConfig] = None): +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``. @@ -179,14 +185,14 @@ def _make_solver(solver_name: str, torchlp_config: Optional[TorchLPConfig] = Non 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(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(config=torchlp_config) @@ -278,7 +284,9 @@ def _verify_one_net( try: lp_results = verify_lp_batched( net, - solver_factory=lambda: _make_solver(backend_cfg.solver, backend_cfg.torchlp), + solver_factory=lambda: _make_solver( + backend_cfg.solver, backend_cfg.torchlp, backend_cfg.gurobi + ), timelimit=backend_cfg.timeout, ) results = [ @@ -323,7 +331,9 @@ def _verify_one_net( results = [ _vbb( slice_net_to_sample(net, i), - solver_factory=lambda: _make_solver(backend_cfg.solver, backend_cfg.torchlp), + 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, @@ -1524,7 +1534,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, cfg.torchlp), + solver_factory=lambda: _make_solver(cfg.solver, cfg.torchlp, cfg.gurobi), timelimit=cfg.timeout, ) assert len(lp_results) == 1 diff --git a/act/config/config.py b/act/config/config.py index 4f69349ec..7c9f8e09c 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -333,6 +333,14 @@ class HybridZConfig: 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 TorchLPConfig: rho_eq: float = 10.0 @@ -399,6 +407,7 @@ class BackendConfig: generation: GenerationConfig = field(default_factory=GenerationConfig) hybridz: HybridZConfig = field(default_factory=HybridZConfig) + gurobi: GurobiConfig = field(default_factory=GurobiConfig) torchlp: TorchLPConfig = field(default_factory=TorchLPConfig) method: Optional[str] = field(default=None, metadata={"in_yaml": False}) @@ -482,6 +491,7 @@ def from_yaml( - ``bab_`` → ``BaBConfig.`` - ``gen_`` → ``GenerationConfig.`` - ``hybridz_`` → ``HybridZConfig.`` + - ``gurobi_`` → ``GurobiConfig.`` - ``torchlp_`` → ``TorchLPConfig.`` - ``bab_enabled`` → top-level ``bab_enabled`` """ @@ -495,6 +505,7 @@ def from_yaml( bab_raw: dict[str, Any] = backend_raw.pop("bab", {}) 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", {}) # Extract "enabled" from bab section → top-level bab_enabled @@ -504,10 +515,12 @@ 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)} 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] = {} top_overrides: dict[str, Any] = {} for k, v in overrides.items(): @@ -517,6 +530,8 @@ 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 else: @@ -541,6 +556,10 @@ def from_yaml( 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) @@ -550,6 +569,7 @@ def from_yaml( "bab", "generation", "hybridz", + "gurobi", "torchlp", } top_merged: dict[str, Any] = {} @@ -566,6 +586,7 @@ def from_yaml( bab=bab_config, generation=gen_config, hybridz=hz_config, + gurobi=gurobi_config, torchlp=torchlp_config, **top_merged, ) @@ -578,6 +599,7 @@ def to_yaml(self, path: Union[str, Path]) -> Path: bab_d = d.pop("bab") d.pop("generation") hz_d = d.pop("hybridz") + gurobi_d = d.pop("gurobi") torchlp_d = d.pop("torchlp") bab_enabled = d.pop("bab_enabled") bab_d["enabled"] = bab_enabled @@ -589,6 +611,7 @@ def to_yaml(self, path: Union[str, Path]) -> Path: **d, "bab": bab_d, "hybridz": hz_d, + "gurobi": gurobi_d, "torchlp": torchlp_d, } }, From 171ebf0c86a79b82ac9d041bb0f94e1b84dacde6 Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 15:40:40 +1000 Subject: [PATCH 25/27] feat(config): wire HybridZ solver tunables into HybridZConfig HZSolver had hardcoded time_limit=30.0 and tolerance=1e-7 with no config home, and HybridZConfig.timeout was not reaching the solver. Add HybridZConfig.tolerance and wire timeout -> HZSolver.time_limit + tolerance -> HZSolver.tolerance at the construction site, so the hybridz: block actually configures the solver. Behavior-preserving (defaults unchanged: 30.0 / 1e-7 fallback). Verified: CONFIG PARITY OK, config->solver wiring proven (timeout=2.5 / tolerance=1e-6 reach HZSolver), --hz-tolerance override works, hybridz --verify runs, lsp clean. --- act/back_end/verifier.py | 11 ++++++++++- act/config/backend.yaml | 2 ++ act/config/backend_cli.py | 10 +++++++++- act/config/config.py | 1 + act/config/pipeline_cli.py | 24 +++++++++++++++++------- 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/act/back_end/verifier.py b/act/back_end/verifier.py index 7fa111f19..f43c7dd26 100644 --- a/act/back_end/verifier.py +++ b/act/back_end/verifier.py @@ -478,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]: ... @@ -489,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]: ... @@ -500,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]]]: ... @@ -511,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. @@ -542,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 @@ -658,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/backend.yaml b/act/config/backend.yaml index 369c818dd..c3f48d019 100644 --- a/act/config/backend.yaml +++ b/act/config/backend.yaml @@ -95,6 +95,8 @@ backend: 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 diff --git a/act/config/backend_cli.py b/act/config/backend_cli.py index 25cf6d28b..f3e55c0a4 100644 --- a/act/config/backend_cli.py +++ b/act/config/backend_cli.py @@ -272,9 +272,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) diff --git a/act/config/config.py b/act/config/config.py index 7c9f8e09c..8fe140801 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -330,6 +330,7 @@ def __post_init__(self) -> None: @dataclass class HybridZConfig: timeout: Optional[float] = None + tolerance: float = 1e-7 max_input_dim: int = 1024 diff --git a/act/config/pipeline_cli.py b/act/config/pipeline_cli.py index 7bc89069b..813d765c7 100644 --- a/act/config/pipeline_cli.py +++ b/act/config/pipeline_cli.py @@ -790,15 +790,25 @@ def _verify_and_validate_cell( """ from act.back_end.verifier import verify_once - verify_kwargs = ( - {"timelimit": getattr(args, "timeout", None)} - if solver == "hybridz" - else {} - ) if args.validate_soundness: - results, facts = verify_once(net, collect_facts=True, **verify_kwargs) + 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, **verify_kwargs) + 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}") From c8f1bdd4c93d69bc33ab2c7a6a7f7d9e5a500efe Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 15:44:14 +1000 Subject: [PATCH 26/27] docs(config): clarify dual knobs are BaB-optimized-only (single-shot dual is param-free) Traced the dual solver: single-shot `solver: dual` (verify_once -> DualSolver.evaluate_spec -> compute_certified_bound(optimize=False)) is a parameter-free single fixed-slope backward pass. The 6 dual knobs (dual_n_iters, lr_alpha, lr_beta, lr_decay, per_class_alpha, incremental_start_enabled) are consumed ONLY by the BaB-optimized dual (bab.py, optimize=True; solver_tier dual_alpha/dual_alpha_eta). Conclusion: no DualConfig/dual: block is warranted -- those knobs are correctly placed in bab:. Add a comment so config users are not misled into tuning them under `solver: dual`. (Completes the per-solver config reorg: torchlp:/gurobi:/ hybridz: hold real solver params; dual's only tunables are BaB-loop behavior.) --- act/config/backend.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/act/config/backend.yaml b/act/config/backend.yaml index c3f48d019..749ff3a6c 100644 --- a/act/config/backend.yaml +++ b/act/config/backend.yaml @@ -121,6 +121,9 @@ backend: provenance_enabled: false # -- Dual-tier (solver_tier != lp) ------------------------------------- + # These tune the BaB-OPTIMIZED dual (solver_tier dual_alpha/dual_alpha_eta). + # The standalone `solver: dual` single-shot verify is parameter-free + # (one fixed-slope backward pass) and ignores the knobs below. solver_tier: "lp" dual_n_iters: 50 lr_alpha: 0.1 From b34a58b3731764f4149fd649209ce5edb001f1ba Mon Sep 17 00:00:00 2001 From: guanqin-123 Date: Tue, 14 Jul 2026 16:28:54 +1000 Subject: [PATCH 27/27] refactor(config): extract DualConfig (dual: block) from BaBConfig Move the dual-solver optimization knobs out of bab: into a top-level dual: block (sibling of torchlp:/gurobi:/hybridz:) so each solver's params live in its own block. Moved: n_iters (was dual_n_iters), lr_alpha, lr_beta, lr_decay, per_class_alpha, incremental_start_enabled. bab: keeps enabled, solver_tier (the tier selector), and search/refinement params. - config.py: new DualConfig; BackendConfig.dual; from_yaml/to_yaml; build_vnncomp_ bab_config now returns (BaBConfig, DualConfig) - bab.py: verify_bab_batched(dual_config=...); dual paths read dual_config.* - backend_cli/pipeline_cli/vnncomp: thread dual_config through - backend.yaml/pipeline.yaml: add dual: block; trim bab: dual-tier section - check_parity: dual sub-config parity (lr_beta/lr_decay in_yaml=False) Behavior-preserving. Verified: CONFIG PARITY OK, dual.n_iters load + --dual-n-iters override, --validate-soundness torchlp 222/0 + dual 141/0 UNCHANGED before/after, build_vnncomp_bab_config OK, lsp clean on bab.py/config.py. --- act/back_end/bab/bab.py | 38 +++++++++++----- act/config/backend.yaml | 18 ++++---- act/config/backend_cli.py | 9 ++-- act/config/check_parity.py | 27 +++++++++--- act/config/config.py | 88 +++++++++++++++++++++++++------------ act/config/pipeline.yaml | 1 + act/config/pipeline_cli.py | 17 ++++--- vnncomp/act_run_instance.py | 7 +-- 8 files changed, 141 insertions(+), 64 deletions(-) diff --git a/act/back_end/bab/bab.py b/act/back_end/bab/bab.py index 7fc6c9aaf..23315e811 100644 --- a/act/back_end/bab/bab.py +++ b/act/back_end/bab/bab.py @@ -26,7 +26,7 @@ import torch -from act.config.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/config/backend.yaml b/act/config/backend.yaml index 749ff3a6c..1a04933f0 100644 --- a/act/config/backend.yaml +++ b/act/config/backend.yaml @@ -100,6 +100,15 @@ backend: # 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) # ═════════════════════════════════════════════════════════════════════════ @@ -120,15 +129,8 @@ backend: multi_split_levels: 1 provenance_enabled: false - # -- Dual-tier (solver_tier != lp) ------------------------------------- - # These tune the BaB-OPTIMIZED dual (solver_tier dual_alpha/dual_alpha_eta). - # The standalone `solver: dual` single-shot verify is parameter-free - # (one fixed-slope backward pass) and ignores the knobs below. + # -- Dual-tier selector (solver_tier != lp) ---------------------------- solver_tier: "lp" - dual_n_iters: 50 - lr_alpha: 0.1 - incremental_start_enabled: true - per_class_alpha: true # -- LLM-probe controller ---------------------------------------------- llm_probe_enabled: false diff --git a/act/config/backend_cli.py b/act/config/backend_cli.py index f3e55c0a4..887d48d11 100644 --- a/act/config/backend_cli.py +++ b/act/config/backend_cli.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import Any, Dict, List, NamedTuple, Optional, Union, cast, get_args, get_origin, get_type_hints -from act.config.config import GurobiConfig, TorchLPConfig, 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 @@ -65,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.config.config import BackendConfig, BaBConfig, GenerationConfig, GurobiConfig, HybridZConfig, TorchLPConfig + from act.config.config import BackendConfig, BaBConfig, DualConfig, GenerationConfig, GurobiConfig, HybridZConfig, TorchLPConfig existing_options = { option @@ -121,9 +121,10 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk "Backend Config Overrides (generated)", "", "", - {"bab", "generation", "hybridz", "gurobi", "torchlp"}, + {"bab", "generation", "hybridz", "gurobi", "torchlp", "dual"}, ) add_group(BaBConfig, "BaB Config Overrides (generated)", "bab-", "bab_", 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()) @@ -141,6 +142,7 @@ def add_group(cls: type[Any], title: str, flag_prefix: str, dest_prefix: str, sk "hybridz": "hybridz_", "gurobi": "gurobi_", "torchlp": "torchlp_", + "dual": "dual_", } @@ -345,6 +347,7 @@ def _verify_one_net( 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] diff --git a/act/config/check_parity.py b/act/config/check_parity.py index c1a403eb1..f00eaa787 100644 --- a/act/config/check_parity.py +++ b/act/config/check_parity.py @@ -52,7 +52,7 @@ def require(self, label: str, passed: bool, detail: str = "") -> None: def check_backend(report: ParityReport) -> None: - from act.config.config import _BACKEND_YAML, _NETGEN_YAML, BaBConfig, BackendConfig + from act.config.config import _BACKEND_YAML, _NETGEN_YAML, BaBConfig, BackendConfig, DualConfig from act.config.backend_cli import ( _BACKEND_OVERRIDE_SPEC, _BACKEND_SUBCONFIG_PREFIX, @@ -94,7 +94,12 @@ def check_backend(report: ParityReport) -> None: for f in fields(BaBConfig) if not f.metadata.get("in_yaml", True) } - cli_only_allowed |= bab_cli_only_allowed + 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", @@ -111,10 +116,10 @@ def check_backend(report: ParityReport) -> None: def check_pipeline(report: ParityReport) -> None: - from act.config.config import _PIPELINE_YAML, BaBConfig, ValidationConfig + 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_VAL_ATTR_MAP, + _FUZZ_OVERRIDE_SPEC, _PIPELINE_BAB_OVERRIDE_FIELDS, _PIPELINE_DUAL_OVERRIDE_FIELDS, _PIPELINE_VAL_ATTR_MAP, ) pipeline_yaml = _load_yaml(_PIPELINE_YAML) @@ -144,7 +149,8 @@ def check_pipeline(report: ParityReport) -> None: bab_fields = _field_names(BaBConfig) bab_cli = set(_PIPELINE_BAB_OVERRIDE_FIELDS) - bab_yaml = set(((pipeline_yaml.get("verification") or {}).get("bab") or {}).keys()) + 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)) @@ -153,6 +159,17 @@ def check_pipeline(report: ParityReport) -> None: 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 diff --git a/act/config/config.py b/act/config/config.py index 8fe140801..8fff89e18 100644 --- a/act/config/config.py +++ b/act/config/config.py @@ -105,24 +105,6 @@ 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 = field(default=0.1, metadata={"in_yaml": False}) - """Adam learning rate for η (split-constraint KKT multipliers). 0.1 default; tune per network.""" - - lr_decay: float = field(default=0.98, metadata={"in_yaml": False}) - """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.""" @@ -342,6 +324,27 @@ class GurobiConfig: 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 @@ -410,6 +413,7 @@ class BackendConfig: hybridz: HybridZConfig = field(default_factory=HybridZConfig) 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}) @@ -494,6 +498,7 @@ def from_yaml( - ``hybridz_`` → ``HybridZConfig.`` - ``gurobi_`` → ``GurobiConfig.`` - ``torchlp_`` → ``TorchLPConfig.`` + - ``dual_`` → ``DualConfig.`` - ``bab_enabled`` → top-level ``bab_enabled`` """ path = Path(config_path) if config_path else _BACKEND_YAML @@ -508,6 +513,7 @@ def from_yaml( 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) @@ -518,11 +524,13 @@ def from_yaml( 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: @@ -535,6 +543,8 @@ def from_yaml( 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 @@ -565,6 +575,15 @@ def from_yaml( 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", @@ -572,6 +591,7 @@ def from_yaml( "hybridz", "gurobi", "torchlp", + "dual", } top_merged: dict[str, Any] = {} for k, v in backend_raw.items(): @@ -589,6 +609,7 @@ def from_yaml( hybridz=hz_config, gurobi=gurobi_config, torchlp=torchlp_config, + dual=dual_config, **top_merged, ) @@ -602,6 +623,7 @@ def to_yaml(self, path: Union[str, Path]) -> Path: 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 @@ -614,6 +636,7 @@ def to_yaml(self, path: Union[str, Path]) -> Path: "hybridz": hz_d, "gurobi": gurobi_d, "torchlp": torchlp_d, + "dual": dual_d, } }, f, @@ -690,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.""" @@ -703,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, @@ -729,7 +754,7 @@ def build_vnncomp_bab_config( ) if llm_model: cfg.llm_probe_model = llm_model - return cfg + return cfg, dual_cfg # --------------------------------------------------------------------------- @@ -754,6 +779,7 @@ class ValidationConfig: class PipelineConfig: fuzzing: FuzzingConfig bab: BaBConfig + dual: DualConfig validation: ValidationConfig @classmethod @@ -775,19 +801,23 @@ def from_yaml( 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 ) - bab_data = ((yaml_data.get("verification") or {}).get("bab") or {}) + 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, validation=validation) + return cls(fuzzing=fuzzing, bab=bab, dual=dual, validation=validation) def _strip_prefixed_overrides(overrides: dict[str, Any], prefix: str) -> dict[str, Any]: diff --git a/act/config/pipeline.yaml b/act/config/pipeline.yaml index 8947eb46c..da8c8c2bf 100644 --- a/act/config/pipeline.yaml +++ b/act/config/pipeline.yaml @@ -78,6 +78,7 @@ verification: 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) diff --git a/act/config/pipeline_cli.py b/act/config/pipeline_cli.py index 813d765c7..0c5915e85 100644 --- a/act/config/pipeline_cli.py +++ b/act/config/pipeline_cli.py @@ -194,7 +194,8 @@ def _collect_fuzzing_overrides(args: Any) -> dict[str, Any]: # 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_BAB_OVERRIDE_FIELDS: tuple[str, ...] = tuple(_PIPELINE_BAB_ATTR_MAP) +_PIPELINE_DUAL_OVERRIDE_FIELDS: tuple[str, ...] = ( "per_class_alpha", "incremental_start_enabled", ) @@ -217,9 +218,9 @@ def _collect_pipeline_config_overrides(args: Any) -> dict[str, Any]: if value is not None: overrides[f"bab_{key}"] = value if getattr(args, "bab_per_class_alpha", None) is not None: - overrides["bab_per_class_alpha"] = str(args.bab_per_class_alpha).lower() == "true" + 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["bab_incremental_start_enabled"] = not args.bab_no_incremental_start + 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) @@ -239,8 +240,8 @@ def _apply_pipeline_config_defaults(args: Any) -> PipelineConfig: 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.bab.per_class_alpha else "false" - args.bab_no_incremental_start = not config.bab.incremental_start_enabled + 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 @@ -985,7 +986,9 @@ def _run_bab_on_net(net, args, bab_first_sample_only: bool = False): seed_from_input_specs, ) - config = PipelineConfig.from_yaml(**_collect_pipeline_config_overrides(args)).bab + 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) @@ -999,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 @@ -1013,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 diff --git a/vnncomp/act_run_instance.py b/vnncomp/act_run_instance.py index 2881552d5..91555e0ff 100755 --- a/vnncomp/act_run_instance.py +++ b/vnncomp/act_run_instance.py @@ -138,8 +138,8 @@ 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) + 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 @@ -154,7 +154,8 @@ def _verify(tier, budget): 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)) + 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: