Skip to content

Commit 6e3bd7f

Browse files
José Ángel Galindo DuarteJosé Ángel Galindo Duarte
authored andcommitted
feat(z3): support the Tseytin CNF encoding as an opt-in
Add cnf_method='tseytin' to FmToZ3, applied only to purely propositional boolean constraints (arithmetic, typed and aggregation constraints fall back to the direct translation). Auxiliary variables are fresh, globally-unique Z3 booleans kept out of the feature map so enumeration and bounds operations ignore them. Z3 already clausifies internally, so this is provided for cross-backend uniformity and experimentation rather than performance. Add parity and fallback tests.
1 parent 7d6165e commit 6e3bd7f

3 files changed

Lines changed: 124 additions & 1 deletion

File tree

flamapy/metamodels/z3_metamodel/models/z3_model.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ def __init__(self) -> None:
3535
self.attributes: dict[str, list[Any]] = {} # attr_name -> [z3var]
3636
self.attributes_types: dict[str, AttributeType] = {} # attr_name -> AttributeType
3737
self.constraints: list[Any] = [] # list of z3 expressions
38+
# Auxiliary (Tseytin) boolean variables. Kept out of ``features`` so that feature
39+
# enumeration/counting/bounds operations ignore them.
40+
self.auxiliary_variables: list[Any] = []
3841
self.original_model: Optional[VariabilityModel] = None
3942

4043
def create_const(self, ftype: FeatureType | AttributeType, value: Any) -> Any:

flamapy/metamodels/z3_metamodel/transformations/fm_to_z3.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,16 @@ def get_source_extension() -> str:
6161
def get_destination_extension() -> str:
6262
return "z3"
6363

64-
def __init__(self, source_model: FeatureModel) -> None:
64+
def __init__(self, source_model: FeatureModel, cnf_method: str = 'direct') -> None:
6565
self.source_model = source_model
6666
self.destination_model: Z3Model = Z3Model()
6767
self._counter: int = 0
68+
# 'direct' (default) builds Z3 boolean expressions straight from the AST. 'tseytin'
69+
# applies the Tseytin CNF encoding to purely propositional boolean constraints
70+
# (others fall back to 'direct'). Framework-only opt-in; Z3 already clausifies
71+
# internally, so this exists for cross-backend uniformity/experimentation, not speed.
72+
self.cnf_method = cnf_method
73+
self._aux_counter: int = 0
6874

6975
def transform(self) -> Z3Model:
7076
self.destination_model = Z3Model()
@@ -258,9 +264,54 @@ def _add_cardinality_formula(self, relation: Relation) -> None:
258264
self._add_inactive_parent_constraints(parent, children)
259265

260266
def _add_constraint_formula(self, ctc: Constraint) -> None:
267+
if self.cnf_method == 'tseytin' and self._is_propositional_boolean(ctc):
268+
self._add_tseytin_constraint(ctc)
269+
return
261270
expr = self._get_expression(ctc.ast.root, None)
262271
self.destination_model.add_constraint(expr)
263272

273+
def _is_propositional_boolean(self, ctc: Constraint) -> bool:
274+
"""Whether a constraint is purely propositional over boolean features, i.e. safe
275+
for the Tseytin CNF encoding. Arithmetic/aggregation/typed constraints are not."""
276+
if any(op not in LOGICAL_OPERATORS for op in ctc.ast.get_operators()):
277+
return False
278+
for operand in ctc.ast.get_operands():
279+
if not isinstance(operand, str):
280+
return False
281+
info = self.destination_model.get_variable(operand)
282+
if info is None or info.ftype != FeatureType.BOOLEAN:
283+
return False
284+
return True
285+
286+
def _add_tseytin_constraint(self, ctc: Constraint) -> None:
287+
clauses, aux_names = ctc.ast.get_clauses_with_aux(method='tseytin')
288+
ctx = self.destination_model.ctx
289+
# Fresh, globally-unique Z3 Bool per auxiliary name (same-named Bools in one Z3
290+
# context are the same variable, so per-constraint names must not collide).
291+
local: dict[str, Any] = {}
292+
for name in aux_names:
293+
self._aux_counter += 1
294+
aux_var = z3.Bool(f'__tseytin_aux_{self._aux_counter}', ctx=ctx)
295+
local[name] = aux_var
296+
self.destination_model.auxiliary_variables.append(aux_var)
297+
298+
def literal_expr(term: str) -> Any:
299+
negated = term.startswith('-')
300+
name = term[1:] if negated else term
301+
if name in local:
302+
expr = local[name]
303+
else:
304+
info = self.destination_model.get_variable(name)
305+
if info is None:
306+
raise FlamaException(f'Unsupported feature: {name}')
307+
expr = info.sel
308+
return z3.Not(expr) if negated else expr
309+
310+
for clause in clauses:
311+
literals = [literal_expr(term) for term in clause]
312+
formula = literals[0] if len(literals) == 1 else z3.Or(*literals)
313+
self.destination_model.add_constraint(formula)
314+
264315
def _get_expression(self, node: Node, parent: Optional[Node]) -> z3.ExprRef:
265316
if node.is_term():
266317
return self._get_term_expression(node, parent)

tests/test_tseytin.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""The opt-in Tseytin CNF encoding must yield the same Z3 analysis results as the
2+
default direct translation, and must fall back to the direct translation for any
3+
constraint that is not purely propositional over boolean features."""
4+
import os
5+
import tempfile
6+
7+
from flamapy.metamodels.fm_metamodel.transformations import UVLReader
8+
from flamapy.metamodels.z3_metamodel.transformations import FmToZ3
9+
from flamapy.metamodels.z3_metamodel.operations.z3_satisfiable import Z3Satisfiable
10+
from flamapy.metamodels.z3_metamodel.operations.z3_configurations import Z3Configurations
11+
from flamapy.metamodels.z3_metamodel.operations.z3_configurations_number import (
12+
Z3ConfigurationsNumber,
13+
)
14+
15+
16+
# Purely propositional model: every cross-tree constraint is boolean.
17+
_PROP_UVL = """features
18+
Root {abstract}
19+
optional
20+
A
21+
B
22+
C
23+
D
24+
constraints
25+
(A | B) => (C <=> D)
26+
A => !B
27+
"""
28+
29+
# All-arithmetic constraints; the Tseytin path must fall back to direct translation.
30+
_ARITH_MODEL = 'resources/models/uvl_models/fm03_integer_conditional_bounded.uvl'
31+
32+
33+
def _build_from_uvl_text(text, cnf_method):
34+
handle, path = tempfile.mkstemp(suffix='.uvl')
35+
try:
36+
with os.fdopen(handle, 'w') as file:
37+
file.write(text)
38+
return FmToZ3(UVLReader(path).transform(), cnf_method=cnf_method).transform()
39+
finally:
40+
os.remove(path)
41+
42+
43+
def _projected(model):
44+
configs = Z3Configurations().execute(model).get_result()
45+
return {frozenset(k for k, v in c.elements.items() if v is True) for c in configs}
46+
47+
48+
def test_tseytin_matches_direct_on_propositional_model() -> None:
49+
direct = _build_from_uvl_text(_PROP_UVL, 'direct')
50+
tseytin = _build_from_uvl_text(_PROP_UVL, 'tseytin')
51+
52+
assert Z3ConfigurationsNumber().execute(direct).get_result() == \
53+
Z3ConfigurationsNumber().execute(tseytin).get_result()
54+
assert _projected(direct) == _projected(tseytin)
55+
assert tseytin.auxiliary_variables # gates were introduced
56+
# Auxiliary variables must not have been registered as features.
57+
assert not set(str(a) for a in tseytin.auxiliary_variables) & set(tseytin.features.keys())
58+
59+
60+
def test_tseytin_falls_back_for_arithmetic_constraints() -> None:
61+
direct = FmToZ3(UVLReader(_ARITH_MODEL).transform(), cnf_method='direct').transform()
62+
tseytin = FmToZ3(UVLReader(_ARITH_MODEL).transform(), cnf_method='tseytin').transform()
63+
64+
# Nothing was propositional, so no auxiliary variables were created.
65+
assert tseytin.auxiliary_variables == []
66+
assert Z3Satisfiable().execute(direct).get_result() == \
67+
Z3Satisfiable().execute(tseytin).get_result()
68+
assert Z3ConfigurationsNumber().execute(direct).get_result() == \
69+
Z3ConfigurationsNumber().execute(tseytin).get_result()

0 commit comments

Comments
 (0)