flamapy-bdd 2.6.0.dev12__py3-none-any.whl → 2.9.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. flamapy/metamodels/bdd_metamodel/__init__.py +49 -0
  2. flamapy/metamodels/bdd_metamodel/models/bdd_model.py +120 -2
  3. flamapy/metamodels/bdd_metamodel/models/utils/logic2bdd_writer.py +125 -0
  4. flamapy/metamodels/bdd_metamodel/models/utils/ordering.py +130 -0
  5. flamapy/metamodels/bdd_metamodel/models/utils/pl_model.py +96 -52
  6. flamapy/metamodels/bdd_metamodel/operations/__init__.py +4 -1
  7. flamapy/metamodels/bdd_metamodel/operations/bdd_backbone.py +61 -0
  8. flamapy/metamodels/bdd_metamodel/operations/bdd_commonality_factor.py +32 -0
  9. flamapy/metamodels/bdd_metamodel/operations/bdd_configurations_number.py +1 -1
  10. flamapy/metamodels/bdd_metamodel/operations/bdd_core_features.py +2 -5
  11. flamapy/metamodels/bdd_metamodel/operations/bdd_dead_features.py +2 -5
  12. flamapy/metamodels/bdd_metamodel/operations/bdd_false_optional_features.py +9 -10
  13. flamapy/metamodels/bdd_metamodel/operations/bdd_feature_inclusion_probability.py +96 -34
  14. flamapy/metamodels/bdd_metamodel/operations/bdd_homogeneity.py +12 -11
  15. flamapy/metamodels/bdd_metamodel/operations/bdd_metrics.py +36 -16
  16. flamapy/metamodels/bdd_metamodel/operations/bdd_pure_optional_features.py +6 -5
  17. flamapy/metamodels/bdd_metamodel/operations/bdd_sampling.py +1 -1
  18. flamapy/metamodels/bdd_metamodel/operations/bdd_satisfiable_configuration.py +9 -13
  19. flamapy/metamodels/bdd_metamodel/operations/bdd_traversals.py +211 -0
  20. flamapy/metamodels/bdd_metamodel/operations/bdd_unique_features.py +33 -29
  21. flamapy/metamodels/bdd_metamodel/operations/bdd_variability.py +6 -0
  22. flamapy/metamodels/bdd_metamodel/operations/bdd_variant_features.py +5 -5
  23. flamapy/metamodels/bdd_metamodel/transformations/dddmp_reader.py +142 -19
  24. flamapy/metamodels/bdd_metamodel/transformations/fm_to_bdd.py +210 -16
  25. flamapy_bdd-2.9.0.dev0.dist-info/METADATA +70 -0
  26. {flamapy_bdd-2.6.0.dev12.dist-info → flamapy_bdd-2.9.0.dev0.dist-info}/RECORD +30 -25
  27. {flamapy_bdd-2.6.0.dev12.dist-info → flamapy_bdd-2.9.0.dev0.dist-info}/WHEEL +1 -1
  28. flamapy_bdd-2.9.0.dev0.dist-info/entry_points.txt +2 -0
  29. flamapy_bdd-2.6.0.dev12.dist-info/METADATA +0 -34
  30. {flamapy_bdd-2.6.0.dev12.dist-info → flamapy_bdd-2.9.0.dev0.dist-info}/licenses/LICENSE +0 -0
  31. {flamapy_bdd-2.6.0.dev12.dist-info → flamapy_bdd-2.9.0.dev0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,49 @@
1
+ from flamapy.core.manifest import OperationCapability, PluginManifest
2
+ from flamapy.core.models.language_level import LanguageLevel, MajorLevel, MinorLevel
3
+
4
+ # Costs are estimated milliseconds from the 2026-11-06 calibration with the pure-Python dd
5
+ # engine (what 2.9 ships): betty synthetic models at 1% and 30% constraint density plus
6
+ # JHipster and busybox, worst case of the two densities, never decreasing. Compiling the BDD is
7
+ # the wall, and it is erratic: 0.2 s for both 200-feature models, 0.4 s for the sparse
8
+ # 500-feature model but more than 5 minutes for the dense one, 1.7 s / 32 s at 1,000, and
9
+ # busybox (1,108 features) did not compile in 10 minutes. The journal benchmark with the same
10
+ # engine (300 s cap, real UVLHub models) compiled 29 of 41 models of 501-1,000 features and 2
11
+ # of 120 beyond, hence the 1,000-feature ceiling. Once compiled, every query is cheap (counting
12
+ # 29 ms, core/dead 140 ms, false-optional 300 ms at 1,000 features). The experimental native
13
+ # builder (flamapy-cudd, not released) is opt-in and not reflected here.
14
+ _MAX_FEATURES = 1000
15
+
16
+ MANIFEST = PluginManifest(
17
+ name='bdd',
18
+ calibration='2026-11-06',
19
+ extension='bdd',
20
+ supported_level=LanguageLevel(MajorLevel.BOOLEAN, {MinorLevel.GROUP_CARDINALITY}),
21
+ # Every operation needs the compiled BDD, so the ceiling is plugin-wide.
22
+ max_features=_MAX_FEATURES,
23
+ transformation_cost_curve=((50, 11), (100, 23), (200, 200), (1000, 300000)),
24
+ operations={
25
+ 'satisfiable': OperationCapability(cost=1),
26
+ 'satisfiable_configuration': OperationCapability(cost=1),
27
+ 'configurations_number': OperationCapability(
28
+ cost_curve=((100, 1), (200, 4), (500, 9), (1000, 29))),
29
+ # Two counts: all the products and those that include the configuration.
30
+ 'commonality': OperationCapability(
31
+ cost_curve=((100, 2), (200, 8), (500, 18), (1000, 58))),
32
+ 'core_features': OperationCapability(
33
+ cost_curve=((100, 1), (200, 20), (500, 33), (1000, 130))),
34
+ 'dead_features': OperationCapability(
35
+ cost_curve=((100, 1), (200, 20), (500, 30), (1000, 140))),
36
+ 'false_optional_features': OperationCapability(
37
+ cost_curve=((50, 2), (100, 3), (200, 42), (500, 72), (1000, 300))),
38
+ # Uniform random sampling over the compiled BDD.
39
+ 'sampling': OperationCapability(
40
+ cost_curve=((50, 2), (100, 3), (200, 29), (500, 57), (1000, 200))),
41
+ # Listing is bounded by the number of configurations, not by the BDD: about 3 us and
42
+ # 1 KB per configuration (measured 2026-11-08 on 16 UVLHub models of 1e3-1.5e6
43
+ # configurations), so a million already takes ~1 GB. Using the exact counts of the SoSyM
44
+ # benchmark (rq2_bdd_final), the share of UVLHub models with at most a million
45
+ # configurations is 100% up to 30 features, 76% at 31-50, 36% at 51-75 and under 25%
46
+ # beyond; with the same ~70% rule as the compilation ceiling, the limit is 50 features.
47
+ 'configurations': OperationCapability(max_features=50),
48
+ },
49
+ )
@@ -1,6 +1,8 @@
1
+ import importlib
1
2
  import logging
3
+ import sys
2
4
  from enum import Enum
3
- from typing import Any, Optional
5
+ from typing import Any, Iterable, Optional
4
6
 
5
7
  try:
6
8
  from dd.cudd import BDD
@@ -11,6 +13,77 @@ from flamapy.core.models import VariabilityModel
11
13
 
12
14
  logger = logging.getLogger(__name__)
13
15
 
16
+ # The dd engine in use: 'cudd' (C extension, only when dd is built against CUDD) or the
17
+ # pure-Python 'autoref' that a plain `pip install dd` provides.
18
+ DD_BACKEND = BDD.__module__.rsplit('.', 1)[-1]
19
+ _reorder = importlib.import_module(BDD.__module__).reorder # sifting, in both engines
20
+
21
+ # Dynamic reordering while building (see BDDModel.build_bdd_incremental): sift once the
22
+ # manager holds REORDER_MIN_NODES nodes, then again each time it grows by REORDER_GROWTH
23
+ # since the last reordering. Small BDDs are never reordered: sifting thousands of variables
24
+ # costs more than it saves on a BDD that stays small.
25
+ REORDER_MIN_NODES = 50_000
26
+ REORDER_GROWTH = 2.0
27
+
28
+ # The pure-Python engine recurses once per BDD level (and the node-walking operations too),
29
+ # so Python's recursion limit must grow with the number of variables.
30
+ _RECURSION_BASE = 1_000
31
+ _RECURSION_PER_VARIABLE = 4
32
+
33
+
34
+ def exact_count(bdd: Any, node: Any, nvars: int) -> int:
35
+ """Exact model count of `node` over `nvars` variables, with Python integers.
36
+
37
+ Works on any dd engine with complement edges: the nodes of the support are counted
38
+ bottom-up (without recursion) over the levels of the support, and the variables outside
39
+ the support double the count.
40
+ """
41
+ def regular(edge: Any) -> Any:
42
+ return ~edge if edge.negated else edge
43
+
44
+ nodes: list[Any] = []
45
+ seen: set[Any] = set()
46
+ top = regular(node)
47
+ stack = [top] if top.var is not None else []
48
+ while stack:
49
+ current = stack.pop()
50
+ if current in seen:
51
+ continue
52
+ seen.add(current)
53
+ nodes.append(current)
54
+ for edge in (current.low, current.high):
55
+ child = regular(edge)
56
+ if child.var is not None and child not in seen:
57
+ stack.append(child)
58
+ level = {var: bdd.level_of_var(var) for var in {n.var for n in nodes}}
59
+ rank = {var: i for i, var in enumerate(sorted(level, key=level.__getitem__))}
60
+ n_support = len(rank)
61
+ counts: dict[Any, int] = {}
62
+
63
+ def edge_count(edge: Any, parent_rank: int) -> int:
64
+ """Models of `edge` over the support variables below `parent_rank`."""
65
+ if edge.var is None:
66
+ value, child_rank = (0 if edge.negated else 1), n_support
67
+ else:
68
+ child_rank = rank[edge.var]
69
+ value = counts[regular(edge)]
70
+ if edge.negated:
71
+ value = 2 ** (n_support - child_rank) - value
72
+ return value * 2 ** (child_rank - parent_rank - 1)
73
+
74
+ for current in sorted(nodes, key=lambda n: rank[n.var], reverse=True):
75
+ current_rank = rank[current.var]
76
+ counts[current] = (edge_count(current.low, current_rank)
77
+ + edge_count(current.high, current_rank))
78
+ return edge_count(node, -1) * 2 ** max(nvars - n_support, 0)
79
+
80
+
81
+ def ensure_recursion_limit(n_variables: int) -> None:
82
+ """Raise Python's recursion limit, if needed, for BDDs over `n_variables` variables."""
83
+ needed = _RECURSION_BASE + _RECURSION_PER_VARIABLE * n_variables
84
+ if sys.getrecursionlimit() < needed:
85
+ sys.setrecursionlimit(needed)
86
+
14
87
 
15
88
  class BDDModel(VariabilityModel):
16
89
  """A Binary Decision Diagram (BDD) representation of the feature model.
@@ -39,12 +112,43 @@ class BDDModel(VariabilityModel):
39
112
 
40
113
  def build_bdd(self, expression: str, variables: list[str]) -> None:
41
114
  """Built a BDD from an expression representing a logical formula."""
115
+ ensure_recursion_limit(len(variables))
42
116
  self.bdd.configure(reordering=False) # Disable dynamic reordering for consistency
43
117
  for var in variables:
44
118
  self.bdd.declare(var) # Declare variables in the BDD manager
45
119
  self.vars_order = variables # The order is crucial to detect skipped variables
46
120
  self.root = self.bdd.add_expr(expression) # Build the logical formula (root node)
47
121
 
122
+ def build_bdd_incremental(self,
123
+ clauses: Iterable[str],
124
+ variables: list[str],
125
+ reorder: bool = False) -> None:
126
+ """Build a BDD as the conjunction of `clauses`, one clause at a time.
127
+
128
+ The variables are declared in the given order (the first one is the top level).
129
+ Conjoining clause by clause keeps the intermediate BDDs small when the clauses are
130
+ scheduled well, instead of parsing one huge expression. With `reorder`, the variables
131
+ are sifted between clauses when the BDD grows (REORDER_MIN_NODES, REORDER_GROWTH);
132
+ the engine's own automatic reordering stays disabled so that levels never change in
133
+ the middle of an operation, and `vars_order` is set to the final level order.
134
+ """
135
+ ensure_recursion_limit(len(variables))
136
+ self.bdd.configure(reordering=False)
137
+ for var in variables:
138
+ self.bdd.declare(var)
139
+ threshold = REORDER_MIN_NODES
140
+ root = self.bdd.true
141
+ for clause in clauses:
142
+ root = root & self.bdd.add_expr(clause)
143
+ if root == self.bdd.false:
144
+ break # The model is void: further clauses cannot change the result
145
+ if reorder and len(self.bdd) >= threshold:
146
+ _reorder(self.bdd)
147
+ threshold = max(REORDER_MIN_NODES, int(REORDER_GROWTH * len(self.bdd)))
148
+ self.root = root
149
+ # Operations rely on vars_order[i] being the variable at level i.
150
+ self.vars_order = sorted(variables, key=self.bdd.level_of_var)
151
+
48
152
  def get_expression(self) -> str:
49
153
  """ Converts the BDD to a readable Boolean expression string."""
50
154
  return self.bdd.to_expr(self.root)
@@ -55,7 +159,19 @@ class BDDModel(VariabilityModel):
55
159
 
56
160
  def count_solutions(self) -> int:
57
161
  """Count the number of valid configurations (SAT)."""
58
- return self.bdd.count(self.root, len(self.vars_order)) # count(node, n_vars)
162
+ return self.count_models(self.root, len(self.vars_order))
163
+
164
+ def count_models(self, node: Any, nvars: int) -> int:
165
+ """Exact number of assignments of `nvars` variables (a superset of the support of
166
+ `node`) that satisfy `node`.
167
+
168
+ dd's pure-Python engine counts with Python integers; the CUDD engine counts with
169
+ floating point, which is approximate beyond 2**53 and overflows beyond ~1e308, so for
170
+ CUDD the count is computed here with integers.
171
+ """
172
+ if DD_BACKEND != "cudd":
173
+ return int(self.bdd.count(node, nvars))
174
+ return exact_count(self.bdd, node, nvars)
59
175
 
60
176
  def dump_structure(self) -> None:
61
177
  """Prints the structure of the BDD for debugging purposes."""
@@ -102,6 +218,7 @@ class BDDModel(VariabilityModel):
102
218
  def load_bdd(path: str, vars: Optional[list[str]] = None) -> 'BDDModel':
103
219
  bdd_model = BDDModel()
104
220
  if vars is not None:
221
+ ensure_recursion_limit(len(vars))
105
222
  for var in vars:
106
223
  bdd_model.bdd.declare(var)
107
224
  roots = bdd_model.bdd.load(path)
@@ -112,6 +229,7 @@ class BDDModel(VariabilityModel):
112
229
  else:
113
230
  bdd_model.root = roots
114
231
  all_vars = list(bdd_model.bdd.vars)
232
+ ensure_recursion_limit(len(all_vars))
115
233
  sorted_vars = sorted(all_vars, key=bdd_model.bdd.level_of_var)
116
234
  bdd_model.vars_order = sorted_vars
117
235
  bdd_model.vars_features = {var: var for var in bdd_model.vars_order}
@@ -0,0 +1,125 @@
1
+ """Write a feature model as Logic2BDD input (a variable file and an expression file).
2
+
3
+ Logic2BDD (D. Fernández-Amorós, UNED) reads the variables, one order, from the variable file
4
+ and one constraint per line from the expression file. Its grammar has some traps that this
5
+ writer avoids:
6
+
7
+ * `<->` binds tighter than `and`/`or`, so every binary operation is written in parentheses.
8
+ * Operators must be separated by spaces (`a->b` would be read as one symbol).
9
+ * `and`, `or`, `not`, `xor`, `mux`, `if`... are keywords, so variables are written with
10
+ neutral names (`v0`, `v1`, ...) and the mapping to the real names is returned.
11
+ * Every line, the last one included, ends with a newline and there are no blank lines.
12
+
13
+ Groups use Logic2BDD's n-ary operators: `mux(...)` (at most one) for alternative and mutex
14
+ groups, which CUDD builds directly instead of from pairwise clauses.
15
+ """
16
+ import itertools
17
+ from dataclasses import dataclass, field
18
+
19
+ from flamapy.core.models.ast import ASTOperation, Node
20
+ from flamapy.metamodels.fm_metamodel.models import Constraint, FeatureModel, Relation
21
+
22
+
23
+ @dataclass
24
+ class Logic2BDDInput:
25
+ """Logic2BDD input files and the mapping between their names and the variables."""
26
+ variables: list[str] # variable names (the model's), in order
27
+ names: dict[str, str] # Logic2BDD name -> variable name
28
+ expressions: list[str] = field(default_factory=list)
29
+
30
+ @property
31
+ def var_text(self) -> str:
32
+ to_l2b = {var: name for name, var in self.names.items()}
33
+ return " ".join(to_l2b[var] for var in self.variables) + "\n"
34
+
35
+ @property
36
+ def exp_text(self) -> str:
37
+ return "".join(f"{line}\n" for line in self.expressions)
38
+
39
+
40
+ def feature_model_to_logic2bdd(feature_model: FeatureModel,
41
+ order: list[str]) -> Logic2BDDInput:
42
+ """Translate `feature_model` (with sanitized names) using the variable order `order`."""
43
+ names = {f"v{index}": var for index, var in enumerate(order)}
44
+ to_l2b = {var: name for name, var in names.items()}
45
+ result = Logic2BDDInput(variables=list(order), names=names)
46
+ if feature_model.root is None:
47
+ return result
48
+ result.expressions.append(to_l2b[feature_model.root.name])
49
+ for feature in feature_model.get_features():
50
+ for relation in feature.get_relations():
51
+ result.expressions.extend(_relation_lines(relation, to_l2b))
52
+ for constraint in feature_model.get_logical_constraints():
53
+ result.expressions.append(_constraint_line(constraint, to_l2b))
54
+ return result
55
+
56
+
57
+ def _or(items: list[str]) -> str:
58
+ return items[0] if len(items) == 1 else "(" + " or ".join(items) + ")"
59
+
60
+
61
+ def _relation_lines(relation: Relation, to_l2b: dict[str, str]) -> list[str]:
62
+ parent = to_l2b[relation.parent.name]
63
+ children = list(dict.fromkeys(to_l2b[child.name] for child in relation.children))
64
+ at_most_one = [f"mux({', '.join(children)})"] if len(children) > 1 else []
65
+ lines: list[str] = []
66
+ if relation.is_mandatory():
67
+ lines = [f"({parent} <-> {children[0]})"]
68
+ elif relation.is_optional():
69
+ lines = [f"({children[0]} -> {parent})"]
70
+ elif relation.is_or():
71
+ lines = [f"({parent} <-> {_or(children)})"]
72
+ elif relation.is_alternative():
73
+ lines = [f"({parent} <-> {_or(children)})", *at_most_one]
74
+ elif relation.is_mutex():
75
+ lines = [*(f"({child} -> {parent})" for child in children), *at_most_one]
76
+ elif relation.is_cardinal():
77
+ lines = _cardinality_lines(parent, children, relation.card_min, relation.card_max)
78
+ return lines
79
+
80
+
81
+ def _cardinality_lines(parent: str, children: list[str], card_min: int,
82
+ card_max: int) -> list[str]:
83
+ """The same constraints as PLModel, not the same clauses: every selection outside
84
+ [min, max] under the parent is forbidden, and "no child without its parent" is one
85
+ ``child -> parent`` line per child where PLModel enumerates the subsets."""
86
+ card_max = len(children) if card_max == -1 else card_max
87
+ lines = [f"({child} -> {parent})" for child in children]
88
+ for size in range(len(children) + 1):
89
+ if card_min <= size <= card_max:
90
+ continue
91
+ for selected in itertools.combinations(children, size):
92
+ literals = [f"(not {parent})"]
93
+ literals += [f"(not {child})" if child in selected else child for child in children]
94
+ lines.append(_or(literals))
95
+ return lines
96
+
97
+
98
+ _BINARY = {
99
+ ASTOperation.AND: "and",
100
+ ASTOperation.OR: "or",
101
+ ASTOperation.IMPLIES: "->",
102
+ ASTOperation.REQUIRES: "->",
103
+ ASTOperation.EQUIVALENCE: "<->",
104
+ }
105
+
106
+
107
+ def _constraint_line(constraint: Constraint, to_l2b: dict[str, str]) -> str:
108
+ return _node_text(constraint.ast.root, to_l2b)
109
+
110
+
111
+ def _node_text(node: Node, to_l2b: dict[str, str]) -> str:
112
+ if not node.is_op():
113
+ return to_l2b[str(node.data)]
114
+ operation = node.data
115
+ if operation == ASTOperation.NOT:
116
+ return f"(not {_node_text(node.left, to_l2b)})"
117
+ left = _node_text(node.left, to_l2b)
118
+ right = _node_text(node.right, to_l2b)
119
+ if operation == ASTOperation.EXCLUDES:
120
+ return f"({left} -> (not {right}))"
121
+ if operation == ASTOperation.XOR:
122
+ return f"(({left} or {right}) and (not ({left} and {right})))"
123
+ if operation in _BINARY:
124
+ return f"({left} {_BINARY[operation]} {right})"
125
+ raise ValueError(f"Operation {operation} is not supported by Logic2BDD.")
@@ -0,0 +1,130 @@
1
+ """Static variable orders and clause schedules for building a BDD clause by clause.
2
+
3
+ The size of a BDD depends heavily on the variable order, and the cost of building it
4
+ clause by clause depends on the order in which clauses are conjoined. These heuristics follow
5
+ the ideas of Logic2BDD (D. Fernández-Amorós, R. Heradio et al.) adapted to feature models:
6
+
7
+ Variable orders
8
+ ``model`` the order of `FeatureModel.get_features()`.
9
+ ``tree`` depth-first pre-order of the feature tree (a feature followed by its subtree).
10
+ ``force`` the FORCE hypergraph heuristic (Aloul, Markov and Sakallah, 2003), seeded with
11
+ the tree order: variables that share clauses are pulled together, which shortens
12
+ the span of cross-tree constraints.
13
+
14
+ Clause schedules
15
+ ``model`` the order in which the clauses were generated.
16
+ ``minspan`` clauses with a smaller span (distance between their first and last variable
17
+ in the order) first; ties broken by the position of their first variable.
18
+ ``minmax`` clauses whose deepest variable is higher in the order first.
19
+
20
+ All functions are pure and deterministic: the same inputs always give the same result.
21
+ """
22
+ from typing import Sequence
23
+
24
+ from flamapy.core.exceptions import FlamaException
25
+ from flamapy.metamodels.bdd_metamodel.models.utils.pl_model import PLClause
26
+
27
+
28
+ class BDDOptionError(FlamaException, ValueError):
29
+ """An invalid BDD build option (argument or FLAMAPY_BDD_* variable). A FlamaException like
30
+ every other invalid transformation option; also a ValueError, as it was before 2.9."""
31
+
32
+
33
+ VARIABLE_ORDERS = ("model", "tree", "force")
34
+ CLAUSE_ORDERS = ("model", "minspan", "minmax")
35
+
36
+ FORCE_MAX_ITERATIONS = 50
37
+
38
+
39
+ def variable_order(variables: Sequence[str],
40
+ clauses: Sequence[PLClause],
41
+ strategy: str = "tree",
42
+ model_order: Sequence[str] = ()) -> list[str]:
43
+ """Return the variables in the order given by `strategy`.
44
+
45
+ `variables` must be in feature-tree pre-order (as `PLModel.variables`); `model_order` is
46
+ only used by the ``model`` strategy.
47
+ """
48
+ if strategy == "tree":
49
+ return list(variables)
50
+ if strategy == "model":
51
+ ordered = [var for var in model_order if var in set(variables)]
52
+ seen = set(ordered)
53
+ return ordered + [var for var in variables if var not in seen]
54
+ if strategy == "force":
55
+ return force_order(variables, [clause.support for clause in clauses])
56
+ raise BDDOptionError(f"Unknown variable order '{strategy}'. Use one of {VARIABLE_ORDERS}.")
57
+
58
+
59
+ def total_span(order: Sequence[str], supports: Sequence[Sequence[str]]) -> int:
60
+ """Sum over the clauses of the distance between their first and last variable."""
61
+ position = {var: index for index, var in enumerate(order)}
62
+ span = 0
63
+ for support in supports:
64
+ levels = [position[var] for var in support if var in position]
65
+ if len(levels) > 1:
66
+ span += max(levels) - min(levels)
67
+ return span
68
+
69
+
70
+ def force_order(initial: Sequence[str], supports: Sequence[Sequence[str]]) -> list[str]:
71
+ """FORCE: move every variable to the mean centre of gravity of its clauses, repeat.
72
+
73
+ Each iteration computes the centre of gravity of every clause (the mean position of its
74
+ variables), places every variable at the mean of the centres of the clauses it belongs
75
+ to, and re-sorts. The best order found (smallest total span) is returned; ties keep the
76
+ previous position, so the result is deterministic.
77
+ """
78
+ order = list(initial)
79
+ edges = [tuple(dict.fromkeys(var for var in support if var in set(order)))
80
+ for support in supports]
81
+ edges = [edge for edge in edges if len(edge) > 1]
82
+ if not edges:
83
+ return order
84
+ var_edges: dict[str, list[int]] = {var: [] for var in order}
85
+ for index, edge in enumerate(edges):
86
+ for var in edge:
87
+ var_edges[var].append(index)
88
+
89
+ best_order = order
90
+ best_span = total_span(order, edges)
91
+ for _ in range(FORCE_MAX_ITERATIONS):
92
+ position = {var: index for index, var in enumerate(order)}
93
+ centres = [sum(position[var] for var in edge) / len(edge) for edge in edges]
94
+ target = {
95
+ var: (sum(centres[i] for i in var_edges[var]) / len(var_edges[var])
96
+ if var_edges[var] else float(position[var]))
97
+ for var in order
98
+ }
99
+ order = sorted(order, key=lambda var: (target[var], position[var]))
100
+ span = total_span(order, edges)
101
+ if span >= best_span:
102
+ break
103
+ best_order, best_span = order, span
104
+ return best_order
105
+
106
+
107
+ def clause_order(clauses: Sequence[PLClause],
108
+ order: Sequence[str],
109
+ strategy: str = "minspan") -> list[PLClause]:
110
+ """Return the clauses in the order given by `strategy` for the variable order `order`."""
111
+ if strategy == "model":
112
+ return list(clauses)
113
+ position = {var: index for index, var in enumerate(order)}
114
+
115
+ def bounds(clause: PLClause) -> tuple[int, int]:
116
+ levels = [position[var] for var in clause.support if var in position] or [0]
117
+ return min(levels), max(levels)
118
+
119
+ indexed = list(enumerate(clauses))
120
+ if strategy == "minspan":
121
+ def span_key(item: tuple[int, PLClause]) -> tuple[int, int, int]:
122
+ low, high = bounds(item[1])
123
+ return (high - low, low, item[0])
124
+ return [clause for _, clause in sorted(indexed, key=span_key)]
125
+ if strategy == "minmax":
126
+ def max_key(item: tuple[int, PLClause]) -> tuple[int, int, int]:
127
+ low, high = bounds(item[1])
128
+ return (high, low, item[0])
129
+ return [clause for _, clause in sorted(indexed, key=max_key)]
130
+ raise BDDOptionError(f"Unknown clause order '{strategy}'. Use one of {CLAUSE_ORDERS}.")
@@ -1,12 +1,31 @@
1
- import re
2
1
  import itertools
2
+ from dataclasses import dataclass
3
3
 
4
4
  from flamapy.core.models.ast import ASTOperation
5
5
  from flamapy.metamodels.fm_metamodel.models import FeatureModel, Relation, Constraint
6
6
 
7
7
 
8
+ @dataclass(frozen=True)
9
+ class PLClause:
10
+ """One conjunct of the propositional formula of a feature model.
11
+
12
+ `kind` says where the clause comes from ('root', 'mandatory', 'optional', 'or',
13
+ 'alternative', 'mutex', 'cardinality' or 'constraint'), `formula` is the clause in the
14
+ model's logic connectives, and `support` lists the variables the clause mentions, in
15
+ feature-tree order.
16
+ """
17
+ kind: str
18
+ formula: str
19
+ support: tuple[str, ...]
20
+
21
+
8
22
  class PLModel():
9
- """A model representing a Prepositional Logical Formula (PL)."""
23
+ """A model representing a Prepositional Logical Formula (PL).
24
+
25
+ Besides the whole formula (the conjunction of all clauses), the model keeps the clauses
26
+ one by one (`clauses`) and the variables in feature-tree pre-order (`variables`), so a
27
+ BDD can be built clause by clause with a deterministic variable order.
28
+ """
10
29
 
11
30
  LOGIC_CONNECTIVES = {
12
31
  "NOT": "!",
@@ -18,29 +37,71 @@ class PLModel():
18
37
 
19
38
  def __init__(self, logic_connectives: dict[str, str] = LOGIC_CONNECTIVES) -> None:
20
39
  self.formula: str = ""
21
- self.variables: set[str] = set()
40
+ self.variables: list[str] = []
41
+ self.clauses: list[PLClause] = []
22
42
  self.logic_connectives: dict[str, str] = logic_connectives
23
43
 
24
44
  def build_from_feature_model(self, fm_model: FeatureModel) -> str:
25
45
  """Builds a PL formula from a feature model."""
26
- self.variables = {feature.name for feature in fm_model.get_features()}
27
- self.formula = self._traverse_feature_tree(fm_model)
46
+ self.variables = self._tree_preorder(fm_model)
47
+ self.clauses = self._get_clauses(fm_model)
48
+ self.formula = f" {self.logic_connectives['AND']} ".join(
49
+ f"({clause.formula})" for clause in self.clauses)
28
50
  return self.formula
29
51
 
30
- def _traverse_feature_tree(self, feature_model: FeatureModel) -> str:
31
- """Traverse the feature tree from the root and return the propositional formula."""
52
+ @staticmethod
53
+ def _tree_preorder(feature_model: FeatureModel) -> list[str]:
54
+ """Feature names in depth-first pre-order of the feature tree.
55
+
56
+ A feature is followed by its whole subtree, so the variables of each group sit next
57
+ to their parent, which is usually a good static order for a BDD.
58
+ """
32
59
  if feature_model is None or feature_model.root is None:
33
- return ""
60
+ return []
61
+ order: list[str] = []
62
+ seen: set[str] = set()
63
+ stack = [feature_model.root]
64
+ while stack:
65
+ feature = stack.pop()
66
+ if feature.name in seen:
67
+ continue
68
+ seen.add(feature.name)
69
+ order.append(feature.name)
70
+ children = [child for relation in feature.get_relations()
71
+ for child in relation.children]
72
+ stack.extend(reversed(children))
73
+ return order
74
+
75
+ def _get_clauses(self, feature_model: FeatureModel) -> list[PLClause]:
76
+ """Traverse the feature tree from the root and return the clauses of the formula."""
77
+ if feature_model is None or feature_model.root is None:
78
+ return []
79
+ position = {name: index for index, name in enumerate(self.variables)}
80
+
81
+ def support(names: list[str]) -> tuple[str, ...]:
82
+ return tuple(sorted(set(names), key=lambda name: position.get(name, len(position))))
83
+
34
84
  # The root is always present
35
- root_feature = feature_model.root
36
- formula = [root_feature.name]
85
+ root_name = feature_model.root.name
86
+ clauses = [PLClause("root", root_name, (root_name,))]
37
87
  for feature in feature_model.get_features():
38
88
  for relation in feature.get_relations():
39
- formula.append(self._get_relation_formula(relation))
89
+ formula = self._get_relation_formula(relation)
90
+ if formula:
91
+ names = [relation.parent.name] + [child.name for child in relation.children]
92
+ clauses.append(PLClause(self._get_relation_kind(relation), formula,
93
+ support(names)))
40
94
  for constraint in feature_model.get_logical_constraints():
41
- formula.append(self._get_constraint_formula(constraint))
42
- propositional_formula = f" {self.logic_connectives['AND']} ".join(f"({f})" for f in formula)
43
- return propositional_formula
95
+ clauses.append(PLClause("constraint", self._get_constraint_formula(constraint),
96
+ support(constraint.get_features())))
97
+ return clauses
98
+
99
+ @staticmethod
100
+ def _get_relation_kind(relation: Relation) -> str:
101
+ kinds = (("mandatory", relation.is_mandatory), ("optional", relation.is_optional),
102
+ ("or", relation.is_or), ("alternative", relation.is_alternative),
103
+ ("mutex", relation.is_mutex), ("cardinality", relation.is_cardinal))
104
+ return next((kind for kind, check in kinds if check()), "relation")
44
105
 
45
106
  def _get_relation_formula(self, relation: Relation) -> str:
46
107
  result = ""
@@ -78,12 +139,12 @@ class PLModel():
78
139
  def _get_alternative_formula(self, relation: Relation) -> str:
79
140
  formula = []
80
141
  parent = relation.parent.name
81
- children = {child.name for child in relation.children}
142
+ children = list(dict.fromkeys(child.name for child in relation.children))
82
143
  equiv_str = self.logic_connectives['EQUIVALENCE']
83
144
  and_str = self.logic_connectives['AND']
84
145
  not_str = self.logic_connectives['NOT']
85
146
  for child in children:
86
- children_negatives = children - {child}
147
+ children_negatives = [ch for ch in children if ch != child]
87
148
  neg_children = f" {and_str} ".join(not_str + ch for ch in children_negatives)
88
149
  formula.append(f"{child} {equiv_str} ({neg_children} {and_str} {parent})")
89
150
  return f" {and_str} ".join(f"({f})" for f in formula)
@@ -94,11 +155,11 @@ class PLModel():
94
155
  not_str = self.logic_connectives['NOT']
95
156
  or_str = self.logic_connectives['OR']
96
157
  parent = relation.parent.name
97
- children = {child.name for child in relation.children}
158
+ children = list(dict.fromkeys(child.name for child in relation.children))
98
159
 
99
160
  one_child_formula = []
100
161
  for child in children:
101
- other_children_or = f" {or_str} ".join(c for c in children - {child})
162
+ other_children_or = f" {or_str} ".join(c for c in children if c != child)
102
163
  one_child_formula.append(f"({not_str} {child} {equiv_str} ({other_children_or}))")
103
164
  one_child_str = f" {and_str} ".join(f for f in one_child_formula)
104
165
  no_child_str = f" {and_str} ".join(f"{not_str} {c}" for c in children)
@@ -143,37 +204,20 @@ class PLModel():
143
204
  return f" {and_str} ".join(all_clauses)
144
205
 
145
206
  def _get_constraint_formula(self, ctc: Constraint) -> str:
146
- constraint_str = ctc.ast.pretty_str()
147
- constraint_str = re.sub(
148
- rf"\b{ASTOperation.XOR.value}\b", self.logic_connectives['XOR'], constraint_str
149
- )
150
- constraint_str = re.sub(
151
- rf"\b{ASTOperation.NOT.value}\b", self.logic_connectives['NOT'], constraint_str
152
- )
153
- constraint_str = re.sub(
154
- rf"\b{ASTOperation.AND.value}\b", self.logic_connectives['AND'], constraint_str
155
- )
156
- constraint_str = re.sub(
157
- rf"\b{ASTOperation.OR.value}\b", self.logic_connectives['OR'], constraint_str
158
- )
159
- constraint_str = re.sub(
160
- rf"\b{ASTOperation.IMPLIES.value}\b",
161
- self.logic_connectives['IMPLIES'],
162
- constraint_str,
163
- )
164
- constraint_str = re.sub(
165
- rf"\b{ASTOperation.EQUIVALENCE.value}\b",
166
- self.logic_connectives['EQUIVALENCE'],
167
- constraint_str,
168
- )
169
- constraint_str = re.sub(
170
- rf"\b{ASTOperation.REQUIRES.value}\b",
171
- self.logic_connectives['IMPLIES'],
172
- constraint_str,
173
- )
174
- constraint_str = re.sub(
175
- rf"\b{ASTOperation.EXCLUDES.value}\b",
176
- f"{self.logic_connectives['IMPLIES']} {self.logic_connectives['NOT']}",
177
- constraint_str,
178
- )
179
- return constraint_str
207
+ """Write a logical constraint with this model's connectives.
208
+
209
+ `AST.pretty_str` replaces the operators node by node, so a feature called, e.g., `OR`
210
+ is never taken for an operator.
211
+ """
212
+ conn = self.logic_connectives
213
+ operators = {
214
+ ASTOperation.NOT: conn['NOT'],
215
+ ASTOperation.AND: conn['AND'],
216
+ ASTOperation.OR: conn['OR'],
217
+ ASTOperation.XOR: conn['XOR'],
218
+ ASTOperation.IMPLIES: conn['IMPLIES'],
219
+ ASTOperation.REQUIRES: conn['IMPLIES'],
220
+ ASTOperation.EQUIVALENCE: conn['EQUIVALENCE'],
221
+ ASTOperation.EXCLUDES: f"{conn['IMPLIES']} {conn['NOT']}",
222
+ }
223
+ return ctc.ast.pretty_str(operators)