flamapy-fm 2.6.0.dev11__tar.gz → 2.9.0.dev0__tar.gz

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 (73) hide show
  1. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/PKG-INFO +6 -2
  2. flamapy_fm-2.9.0.dev0/flamapy/metamodels/fm_metamodel/__init__.py +26 -0
  3. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/models/__init__.py +2 -1
  4. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/models/clause_set.py +90 -14
  5. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/models/feature_model.py +18 -1
  6. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_atomic_sets.py +8 -15
  7. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_core_features.py +4 -0
  8. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_estimated_configurations_number.py +2 -0
  9. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_language_level.py +7 -25
  10. flamapy_fm-2.9.0.dev0/flamapy/metamodels/fm_metamodel/transformations/_uvl_lexer.py +67 -0
  11. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/afm_reader.py +35 -5
  12. flamapy_fm-2.9.0.dev0/flamapy/metamodels/fm_metamodel/transformations/afm_writer.py +226 -0
  13. flamapy_fm-2.9.0.dev0/flamapy/metamodels/fm_metamodel/transformations/clafer_writer.py +262 -0
  14. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/featureide_reader.py +30 -16
  15. flamapy_fm-2.9.0.dev0/flamapy/metamodels/fm_metamodel/transformations/featureide_writer.py +151 -0
  16. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/fm_secure_features_names.py +37 -2
  17. flamapy_fm-2.9.0.dev0/flamapy/metamodels/fm_metamodel/transformations/glencoe_writer.py +131 -0
  18. flamapy_fm-2.9.0.dev0/flamapy/metamodels/fm_metamodel/transformations/group_support.py +83 -0
  19. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/json_writer.py +10 -13
  20. flamapy_fm-2.9.0.dev0/flamapy/metamodels/fm_metamodel/transformations/pl_writer.py +210 -0
  21. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/pysat_to_fm.py +13 -0
  22. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/cardinality_group_refactoring.py +71 -2
  23. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/multiple_group_decomposition_refactoring.py +16 -5
  24. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/uvl_reader.py +3 -3
  25. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/uvl_writer.py +103 -27
  26. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/xml_writer.py +158 -140
  27. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy_fm.egg-info/PKG-INFO +6 -2
  28. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy_fm.egg-info/SOURCES.txt +3 -0
  29. flamapy_fm-2.9.0.dev0/flamapy_fm.egg-info/entry_points.txt +2 -0
  30. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy_fm.egg-info/requires.txt +6 -1
  31. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/pyproject.toml +13 -2
  32. flamapy_fm-2.6.0.dev11/flamapy/metamodels/fm_metamodel/__init__.py +0 -0
  33. flamapy_fm-2.6.0.dev11/flamapy/metamodels/fm_metamodel/transformations/afm_writer.py +0 -140
  34. flamapy_fm-2.6.0.dev11/flamapy/metamodels/fm_metamodel/transformations/clafer_writer.py +0 -160
  35. flamapy_fm-2.6.0.dev11/flamapy/metamodels/fm_metamodel/transformations/featureide_writer.py +0 -150
  36. flamapy_fm-2.6.0.dev11/flamapy/metamodels/fm_metamodel/transformations/glencoe_writer.py +0 -133
  37. flamapy_fm-2.6.0.dev11/flamapy/metamodels/fm_metamodel/transformations/pl_writer.py +0 -170
  38. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/LICENSE +0 -0
  39. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/README.md +0 -0
  40. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/__init__.py +0 -0
  41. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_average_branching_factor.py +0 -0
  42. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_count_leafs.py +0 -0
  43. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_feature_ancestors.py +0 -0
  44. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_generate_random_attribute.py +0 -0
  45. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_generate_random_feature_model.py +0 -0
  46. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_leaf_features.py +0 -0
  47. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_max_depth_tree.py +0 -0
  48. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_metrics.py +0 -0
  49. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/fm_variation_points.py +0 -0
  50. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/interfaces/__init__.py +0 -0
  51. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/operations/interfaces/variation_points.py +0 -0
  52. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/__init__.py +0 -0
  53. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/flat_fm.py +0 -0
  54. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/glencoe_reader.py +0 -0
  55. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/json_reader.py +0 -0
  56. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/__init__.py +0 -0
  57. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/commitment_feature.py +0 -0
  58. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/deletion_feature.py +0 -0
  59. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/feature_cardinality_refactoring.py +0 -0
  60. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/feature_cardinality_refactoring_paper.py +0 -0
  61. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/mutex_group_refactoring.py +0 -0
  62. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/or_mandatory_refactoring.py +0 -0
  63. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/pseudocomplex_constraint_refactoring.py +0 -0
  64. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/refactoring_exception.py +0 -0
  65. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/refactoring_interface.py +0 -0
  66. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/strictcomplex_constraint_refactoring.py +0 -0
  67. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/refactorings/xor_mandatory_refactoring.py +0 -0
  68. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/splot_writer.py +0 -0
  69. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy/metamodels/fm_metamodel/transformations/xml_reader.py +0 -0
  70. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy_fm.egg-info/dependency_links.txt +0 -0
  71. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/flamapy_fm.egg-info/top_level.txt +0 -0
  72. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/setup.cfg +0 -0
  73. {flamapy_fm-2.6.0.dev11 → flamapy_fm-2.9.0.dev0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flamapy-fm
3
- Version: 2.6.0.dev11
3
+ Version: 2.9.0.dev0
4
4
  Summary: flamapy-fm is a plugin to Flamapy module
5
5
  Author-email: Flamapy <flamapy@us.es>
6
6
  License-Expression: GPL-3.0-or-later
@@ -8,7 +8,7 @@ Project-URL: Homepage, https://github.com/flamapy/fm_metamodel
8
8
  Requires-Python: >=3.11
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
- Requires-Dist: flamapy-fw~=2.6.0.dev11
11
+ Requires-Dist: flamapy-fw~=2.9.0.dev0
12
12
  Requires-Dist: uvlparser~=2.5.0
13
13
  Requires-Dist: afmparser~=1.0.3
14
14
  Provides-Extra: dev
@@ -18,6 +18,10 @@ Requires-Dist: prospector; extra == "dev"
18
18
  Requires-Dist: mypy; extra == "dev"
19
19
  Requires-Dist: coverage; extra == "dev"
20
20
  Requires-Dist: antlr4-tools; extra == "dev"
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest; extra == "test"
23
+ Requires-Dist: flamapy-sat~=2.9.0.dev0; extra == "test"
24
+ Requires-Dist: flamapy-bdd~=2.9.0.dev0; extra == "test"
21
25
  Dynamic: license-file
22
26
 
23
27
  # flamapy-fm
@@ -0,0 +1,26 @@
1
+ from flamapy.core.manifest import OperationCapability, PluginManifest
2
+ from flamapy.core.models.language_level import ALL_MINOR_LEVELS, LanguageLevel, MajorLevel
3
+
4
+ # The metamodel itself understands the full UVL language; its own (tree-level)
5
+ # operations run on any model. Costs are estimated milliseconds measured on 2026-11-08 on the
6
+ # betty calibration models (1% and 30% density, 1,000-10,000 features): both operations walk
7
+ # the tree once, core features in under 1 ms and atomic sets in 7 ms at 10,000 features.
8
+ MANIFEST = PluginManifest(
9
+ name='fm',
10
+ calibration='2026-11-08',
11
+ extension='fm',
12
+ supported_level=LanguageLevel(MajorLevel.TYPE, set(ALL_MINOR_LEVELS)),
13
+ operations={
14
+ # FMCoreFeatures reads the tree only (mandatory chains from the root) and ignores
15
+ # the cross-tree constraints: an estimate, never chosen by the planner unless the
16
+ # caller allows approximate results.
17
+ 'core_features': OperationCapability(exact=False, cost=1),
18
+ # FMAtomicSets groups mandatory features with their parents and ignores the
19
+ # cross-tree constraints, which can merge sets: an estimate as well.
20
+ 'atomic_sets': OperationCapability(
21
+ exact=False, cost_curve=((1000, 1), (2000, 2), (5000, 4), (10000, 7))),
22
+ # The estimated number of configurations is, by definition, an estimate (2 ms at
23
+ # 10,000 features).
24
+ 'estimated_number_of_configurations': OperationCapability(exact=False, cost=2),
25
+ },
26
+ )
@@ -10,9 +10,10 @@ from .feature_model import (
10
10
  FeatureType,
11
11
  AttributeType
12
12
  )
13
- from .clause_set import ClauseSet
13
+ from .clause_set import DEFAULT_CNF_METHOD, ClauseSet
14
14
 
15
15
  __all__ = [
16
+ 'DEFAULT_CNF_METHOD',
16
17
  'Attribute',
17
18
  'AttributeType',
18
19
  'Cardinality',
@@ -10,9 +10,10 @@ their representation from this single encoding.
10
10
  """
11
11
  import itertools
12
12
  from dataclasses import dataclass, field
13
- from typing import Any, Optional
13
+ from typing import Any, Callable, Optional
14
14
 
15
15
  from flamapy.core.exceptions import FlamaException
16
+ from flamapy.core.models.ast import AST, ASTOperation, Node
16
17
 
17
18
  from flamapy.metamodels.fm_metamodel.models.feature_model import (
18
19
  FeatureModel,
@@ -22,6 +23,11 @@ from flamapy.metamodels.fm_metamodel.models.feature_model import (
22
23
  )
23
24
 
24
25
 
26
+ # The encoding every CNF consumer uses unless told otherwise (FmToPysat, FmToDiagPysat, FmToSDD,
27
+ # FmToSharpSAT, FmToDDNNF): import it rather than repeating the literal.
28
+ DEFAULT_CNF_METHOD = 'auto'
29
+
30
+
25
31
  @dataclass
26
32
  class ClauseSet:
27
33
  """CNF encoding of a feature model as signed-integer clauses plus name/id maps."""
@@ -42,17 +48,90 @@ class ClauseSet:
42
48
 
43
49
  @classmethod
44
50
  def from_feature_model(
45
- cls, feature_model: FeatureModel, cnf_method: str = 'distributive'
51
+ cls, feature_model: FeatureModel, cnf_method: str = DEFAULT_CNF_METHOD
46
52
  ) -> 'ClauseSet':
47
53
  """Encode ``feature_model`` into CNF.
48
54
 
49
55
  ``cnf_method`` controls only how cross-tree constraints are turned into clauses:
50
- ``'distributive'`` (default) expands them directly, ``'tseytin'`` introduces auxiliary
51
- variables for a compact, model-count-preserving encoding.
56
+ ``'distributive'`` expands them directly, ``'tseytin'`` introduces auxiliary variables
57
+ for a compact, model-count-preserving encoding, and ``'auto'`` (the default since 2.9)
58
+ decides per constraint: it expands a constraint directly unless the expansion would be
59
+ larger than its Tseytin encoding (see `distributive_clause_count`), so only constraints
60
+ that blow up (e.g. disjunctions of conjunctions, wide equivalences) get auxiliary
61
+ variables. On the SoSyM benchmark corpus ``'auto'`` encodes 1,409 of 1,538 models exactly
62
+ as ``'distributive'`` does; on the other 129 it produces fewer clauses (never more than
63
+ 7,000 where the expansion reaches 51,000), encodes 2.5x faster and gives the same
64
+ results for every operation.
52
65
  """
66
+ if cnf_method not in CNF_METHODS:
67
+ raise FlamaException(f'Unknown cnf_method {cnf_method!r}; expected one of '
68
+ f'{", ".join(CNF_METHODS)}.')
53
69
  return _Encoder(cnf_method).encode(feature_model)
54
70
 
55
71
 
72
+ CNF_METHODS = ('distributive', 'tseytin', 'auto')
73
+ BINARY = 2
74
+
75
+
76
+ # Clause counts of (P op Q) and of its negation, from the counts of P and Q in both polarities,
77
+ # following flamapy's rewriting (simplify_formula): P => Q as !P | Q, P excludes Q as !P | !Q,
78
+ # P <=> Q as (P => Q) & (Q => P), P xor Q as (P & !Q) | (!P & Q). A conjunction adds the clauses
79
+ # of its operands, a disjunction multiplies them.
80
+ _COUNT_RULES: dict[ASTOperation, Callable[[int, int, int, int], tuple[int, int]]] = {
81
+ ASTOperation.AND: lambda ap, an, bp, bn: (ap + bp, an * bn),
82
+ ASTOperation.OR: lambda ap, an, bp, bn: (ap * bp, an + bn),
83
+ ASTOperation.IMPLIES: lambda ap, an, bp, bn: (an * bp, ap + bn),
84
+ ASTOperation.REQUIRES: lambda ap, an, bp, bn: (an * bp, ap + bn),
85
+ ASTOperation.EXCLUDES: lambda ap, an, bp, bn: (an * bn, ap + bp),
86
+ ASTOperation.EQUIVALENCE: lambda ap, an, bp, bn: (an * bp + ap * bn, ap * bp + an * bn),
87
+ ASTOperation.XOR: lambda ap, an, bp, bn: ((ap + bn) * (an + bp), an * bp + ap * bn),
88
+ }
89
+
90
+
91
+ def distributive_clause_count(ast: AST) -> Optional[int]:
92
+ """Number of clauses the distributive CNF expansion of `ast` produces, computed without
93
+ building it (duplicates and subsumed clauses are not removed, as in the expansion itself).
94
+
95
+ Each node is counted for both polarities (see _COUNT_RULES), in one iterative pass, so deep
96
+ constraints do not hit the recursion limit. Returns None for non-propositional operators.
97
+ """
98
+ pos: dict[int, int] = {}
99
+ neg: dict[int, int] = {}
100
+ stack: list[tuple[Node, bool]] = [(ast.root, False)]
101
+ while stack:
102
+ node, expanded = stack.pop()
103
+ if node.is_term():
104
+ pos[id(node)] = neg[id(node)] = 1
105
+ continue
106
+ children = [child for child in (node.left, node.right) if child is not None]
107
+ if not expanded:
108
+ stack.append((node, True))
109
+ stack.extend((child, False) for child in children)
110
+ continue
111
+ if any(id(child) not in pos for child in children):
112
+ return None
113
+ if node.data == ASTOperation.NOT:
114
+ pos[id(node)], neg[id(node)] = neg[id(children[0])], pos[id(children[0])]
115
+ elif node.data in _COUNT_RULES and len(children) == BINARY:
116
+ left, right = children
117
+ pos[id(node)], neg[id(node)] = _COUNT_RULES[node.data](
118
+ pos[id(left)], neg[id(left)], pos[id(right)], neg[id(right)])
119
+ else:
120
+ return None
121
+ return pos[id(ast.root)]
122
+
123
+
124
+ def tseytin_clause_estimate(ast: AST) -> int:
125
+ """Approximate size of the Tseytin encoding: about three clauses per operator, plus one."""
126
+ return 3 * sum(1 for _ in ast.get_operators()) + 1
127
+
128
+
129
+ def use_tseytin(ast: AST) -> bool:
130
+ """The `auto` rule: Tseytin when the distributive expansion would be larger."""
131
+ count = distributive_clause_count(ast)
132
+ return count is not None and count > tseytin_clause_estimate(ast)
133
+
134
+
56
135
  class _Encoder:
57
136
  """Ported ~1:1 from the SAT plugin's FmToPysat so the produced clauses are identical."""
58
137
 
@@ -145,15 +224,9 @@ class _Encoder:
145
224
  else:
146
225
  cnf.append(self._get_variable(feat.name))
147
226
  clauses.append(cnf)
148
- for val in range(1, len(relation.children) + 1):
149
- for combination in itertools.combinations(relation.children, val):
150
- cnf = [parent]
151
- for feat in relation.children:
152
- if feat in combination:
153
- cnf.append(-self._get_variable(feat.name))
154
- else:
155
- cnf.append(self._get_variable(feat.name))
156
- clauses.append(cnf)
227
+ # Every selected child implies the parent: one binary clause per child.
228
+ for feat in relation.children:
229
+ clauses.append([-self._get_variable(feat.name), parent])
157
230
  return clauses
158
231
 
159
232
  def _add_relation(self, relation: Relation) -> None:
@@ -180,7 +253,10 @@ class _Encoder:
180
253
  return aux_map
181
254
 
182
255
  def _add_constraint(self, ctc: Constraint) -> None:
183
- if self.cnf_method == 'tseytin':
256
+ method = self.cnf_method
257
+ if method == 'auto':
258
+ method = 'tseytin' if use_tseytin(ctc.ast) else 'distributive'
259
+ if method == 'tseytin':
184
260
  clauses, aux_names = ctc.ast.get_clauses_with_aux(method='tseytin')
185
261
  aux_map = self._allocate_auxiliary(aux_names)
186
262
  else:
@@ -118,7 +118,7 @@ class Cardinality:
118
118
  @total_ordering
119
119
  class Feature(VariabilityElement):
120
120
 
121
- def __init__( # noqa: PLR0913
121
+ def __init__( # noqa: PLR0913, PLR0917
122
122
  self,
123
123
  name: str,
124
124
  relations: Optional[list["Relation"]] = None,
@@ -398,6 +398,23 @@ class FeatureModel(VariabilityModel):
398
398
  self.ctcs = [] if constraints is None else constraints
399
399
  self.imports: dict[str, FeatureModel] = {} # namespace -> FeatureModel
400
400
  self.alias_namespace: dict[str, str] = {} # alias -> namespace
401
+ self._canonical_hash: Optional[str] = None
402
+
403
+ def canonical_hash(self) -> Optional[str]:
404
+ """sha256 of the model's canonical UVL serialization, memoized.
405
+
406
+ Mutating the model does not invalidate the memo — hash before editing, or build a
407
+ fresh FeatureModel. (UVLWriter is imported lazily: transformations import models.)
408
+ """
409
+ if self._canonical_hash is None:
410
+ import hashlib # noqa: PLC0415
411
+ # Lazy: transformations import models, so this import cannot be top-level.
412
+ from flamapy.metamodels.fm_metamodel.transformations.uvl_writer import ( # noqa: PLC0415 pylint: disable=import-outside-toplevel,cyclic-import
413
+ UVLWriter,
414
+ )
415
+ uvl = UVLWriter(path=None, source_model=self).transform() # type: ignore[arg-type]
416
+ self._canonical_hash = hashlib.sha256(uvl.encode('utf-8')).hexdigest()
417
+ return self._canonical_hash
401
418
 
402
419
  def get_relations(self, feature: Optional["Feature"] = None) -> list["Relation"]:
403
420
  if self.root is None or self.root.is_empty():
@@ -1,25 +1,18 @@
1
- from typing import Any, cast
1
+ from typing import cast
2
2
 
3
3
  from flamapy.core.models import VariabilityModel
4
4
  from flamapy.core.operations.atomic_sets import AtomicSets
5
- from flamapy.core.operations.descriptor import OperationDescriptor
6
5
  from flamapy.metamodels.fm_metamodel.models import FeatureModel, Feature
7
6
 
8
7
 
9
- def _atomic_sets_result(result: Any) -> Any:
10
- return [[feature.name for feature in atomic_set] for atomic_set in result]
11
-
12
-
13
8
  class FMAtomicSets(AtomicSets):
14
- facade = OperationDescriptor(
15
- doc=(
16
- 'This operation is used to find the atomic sets in a model: It returns the\n'
17
- 'atomic sets if they are found in the model. If the model does not follow the\n'
18
- 'UVL specification, an exception is raised and the operation returns False.'
19
- ),
20
- returns='Union[None, List[List[Any]]]',
21
- name='atomic_sets', operation='FMAtomicSets', result_adapter=_atomic_sets_result
22
- )
9
+ """The atomic sets read from the feature tree: the root with its chain of mandatory
10
+ descendants, and every other feature with its own chain. Cross-tree constraints, which
11
+ can merge sets, are ignored (the facade descriptor is the interface's; the exact sets are
12
+ computed by the solver-based implementations)."""
13
+
14
+ # Ignores the cross-tree constraints: reported in the OperationResult provenance envelope.
15
+ exact = False
23
16
 
24
17
  def __init__(self) -> None:
25
18
  self.result: list[set[Feature]] = []
@@ -18,6 +18,10 @@ class FMCoreFeatures(CoreFeatures):
18
18
  false-optional features (e.g., for 'requires' constraints).
19
19
  """
20
20
 
21
+ # Reads the tree only, so the result can miss core features that cross-tree constraints
22
+ # force: reported in the OperationResult provenance envelope.
23
+ exact = False
24
+
21
25
  def __init__(self) -> None:
22
26
  self.result: list[Feature] = []
23
27
 
@@ -16,6 +16,8 @@ class FMEstimatedConfigurationsNumber(EstimatedConfigurationsNumber):
16
16
  and thus, the number is an upper limit of the real number of products.
17
17
  """
18
18
 
19
+ exact = False # reported in the OperationResult provenance envelope
20
+
19
21
  facade = OperationDescriptor(
20
22
  doc=(
21
23
  'This is an estimate of the total number of different products that can be\n'
@@ -1,37 +1,19 @@
1
- from enum import Enum, auto
2
- from dataclasses import dataclass, field
3
1
  from typing import cast
4
2
 
5
3
  from flamapy.core.models import VariabilityModel, ASTOperation
4
+ # LanguageLevel/MajorLevel/MinorLevel moved to flamapy.core.models.language_level (they now
5
+ # also describe plugin capability manifests); re-exported here for compatibility until 3.0.
6
+ from flamapy.core.models.language_level import (
7
+ LanguageLevel,
8
+ MajorLevel,
9
+ MinorLevel,
10
+ )
6
11
  from flamapy.core.operations import Operation
7
12
  from flamapy.core.operations.descriptor import OperationDescriptor
8
13
 
9
14
  from flamapy.metamodels.fm_metamodel.models import FeatureModel, Constraint
10
15
 
11
16
 
12
- class MajorLevel(Enum):
13
- BOOLEAN = 1
14
- ARITHMETIC = 2
15
- TYPE = 3
16
-
17
-
18
- class MinorLevel(Enum):
19
- STRING_CONSTRAINTS = auto()
20
- FEATURE_CARDINALITY = auto()
21
- AGGREGATE_FUNCTION = auto()
22
- GROUP_CARDINALITY = auto()
23
-
24
-
25
- @dataclass(order=True, frozen=True)
26
- class LanguageLevel:
27
- major: MajorLevel
28
- minors: set[MinorLevel] = field(default_factory=set)
29
-
30
- def __str__(self) -> str:
31
- minors_str = ', '.join(ext.name for ext in sorted(self.minors, key=lambda x: x.name))
32
- return f'Major: {self.major.name}, Minor: {{{minors_str}}}'
33
-
34
-
35
17
  class FMLanguageLevel(Operation):
36
18
  """Operation to calculate the maximum language level of a feature model."""
37
19
 
@@ -0,0 +1,67 @@
1
+ """Work around a lexer slowdown in uvlparser <= 2.5.0.
2
+
3
+ The generated UVL lexer's NEWLINE rule starts with the semantic predicate
4
+ ``{self.atStartOfInput()}?``. ANTLR never caches the lexer's DFA start state when a predicate
5
+ is reachable from it, so every token recomputes the full ATN closure (~6x slower parsing).
6
+
7
+ The predicate is ``line == 1 and column == 0``, so it can only hold on the very first token.
8
+ The simulator below bypasses the start-state cache on that token and caches it everywhere
9
+ else, where the predicate is false and the closure does not depend on the position. Lexing
10
+ results are identical, including the rejection of an indented first line.
11
+
12
+ Once uvlparser ships a lexer without the predicate this becomes a no-op and can be removed.
13
+ """
14
+ from typing import Any
15
+
16
+ from antlr4 import InputStream
17
+ from antlr4.PredictionContext import PredictionContextCache
18
+ from antlr4.atn.LexerATNSimulator import LexerATNSimulator
19
+ from antlr4.dfa.DFA import DFA
20
+ from uvl.UVLCustomLexer import UVLCustomLexer
21
+ from uvl.UVLPythonLexer import UVLPythonLexer
22
+
23
+ NEEDS_WORKAROUND = hasattr(UVLPythonLexer, 'NEWLINE_sempred')
24
+
25
+ # A private DFA cache for the patched lexers. ANTLR shares UVLPythonLexer.decisionsToDFA across
26
+ # all lexer instances; filling its start state there would leak into unpatched UVL lexers,
27
+ # which would then use it on the first token and skip the predicate.
28
+ _DECISIONS_TO_DFA = [DFA(state, index)
29
+ for index, state in enumerate(UVLPythonLexer.atn.decisionToState)]
30
+
31
+
32
+ class _StartStateCachingSimulator(LexerATNSimulator):
33
+
34
+ # Parameter names follow LexerATNSimulator (pylint: arguments-renamed otherwise).
35
+ # pylint: disable=redefined-builtin
36
+ def match(self, input: InputStream, mode: int) -> int:
37
+ if self.line == 1 and self.column == 0:
38
+ # The only position where atStartOfInput() holds: neither read nor fill the cache.
39
+ self.mode = mode
40
+ mark = input.mark()
41
+ try:
42
+ self.startIndex = input.index
43
+ self.prevAccept.reset()
44
+ return int(self.execATN(input, self._start_state(input)))
45
+ finally:
46
+ input.release(mark)
47
+ return int(super().match(input, mode))
48
+
49
+ def matchATN(self, input: InputStream) -> int:
50
+ # Past the first token the predicate is false, so the start state is safe to cache.
51
+ start = self._start_state(input)
52
+ self.decisionToDFA[self.mode].s0 = start
53
+ return int(self.execATN(input, start))
54
+
55
+ def _start_state(self, input: InputStream) -> Any:
56
+ closure = self.computeStartState(input, self.atn.modeToStartState[self.mode])
57
+ closure.hasSemanticContext = False
58
+ return self.addDFAState(closure)
59
+
60
+
61
+ def uvl_lexer(input_stream: InputStream) -> UVLCustomLexer:
62
+ """A UVL lexer, with the start-state cache workaround when the installed parser needs it."""
63
+ lexer = UVLCustomLexer(input_stream)
64
+ if NEEDS_WORKAROUND:
65
+ lexer._interp = _StartStateCachingSimulator( # pylint: disable=protected-access
66
+ lexer, lexer.atn, _DECISIONS_TO_DFA, PredictionContextCache())
67
+ return lexer
@@ -1,9 +1,10 @@
1
1
  import os
2
2
 
3
- from typing import Optional
3
+ from typing import Any, Optional
4
4
 
5
- from afmparser import AFMParser
6
- from afmparser import get_tree
5
+ from antlr4 import CommonTokenStream, FileStream
6
+ from antlr4.error.ErrorListener import ErrorListener
7
+ from afmparser import AFMLexer, AFMParser
7
8
 
8
9
  from flamapy.core.exceptions import FlamaException
9
10
  from flamapy.core.transformations import TextToModel
@@ -19,7 +20,32 @@ from flamapy.metamodels.fm_metamodel.models import (
19
20
  )
20
21
 
21
22
 
23
+ class _RaisingErrorListener(ErrorListener):
24
+ """Turn syntax errors into exceptions: the generated parser recovers from them and the
25
+ reader would otherwise fail later on a half-built parse tree."""
26
+
27
+ def syntaxError( # noqa: PLR0913, PLR0917 (ANTLR's signature)
28
+ self, recognizer: Any, offendingSymbol: Any, line: int, column: int, msg: str, e: Any
29
+ ) -> None:
30
+ raise FlamaException(f"AFM syntax error at line {line}:{column}: {msg}")
31
+
32
+
33
+ def get_tree(path: str) -> AFMParser.Feature_modelContext:
34
+ """Parse an AFM file (UTF-8), raising FlamaException on the first syntax error."""
35
+ lexer = AFMLexer(FileStream(path, encoding="utf-8"))
36
+ lexer.removeErrorListeners()
37
+ lexer.addErrorListener(_RaisingErrorListener())
38
+ parser = AFMParser(CommonTokenStream(lexer))
39
+ parser.removeErrorListeners()
40
+ parser.addErrorListener(_RaisingErrorListener())
41
+ return parser.feature_model()
42
+
43
+
22
44
  class AFMReader(TextToModel):
45
+ """Read the AFM text format of FaMa. Relationships, attributes (Integer ranges and
46
+ discrete domains) and the logical constraints are read; constraints with relational or
47
+ arithmetic operators are not supported."""
48
+
23
49
  @staticmethod
24
50
  def get_source_extension() -> str:
25
51
  return "afm"
@@ -141,7 +167,8 @@ class AFMReader(TextToModel):
141
167
  if range_domain_node is not None:
142
168
  range_list = []
143
169
  for domain_range in range_domain_node.domain_range():
144
- range_list.append(Range(domain_range.INT()[0], domain_range.INT()[1]))
170
+ range_list.append(Range(int(domain_range.INT()[0].getText()),
171
+ int(domain_range.INT()[1].getText())))
145
172
  domain = Domain(range_list, None)
146
173
 
147
174
  default_value = attribute_spec.attribute_default_value().value_spec().getText()
@@ -203,6 +230,7 @@ class AFMReader(TextToModel):
203
230
  "OR": ASTOperation.OR,
204
231
  "AND": ASTOperation.AND,
205
232
  "IFF": ASTOperation.EQUIVALENCE,
233
+ "IMPLIES": ASTOperation.IMPLIES,
206
234
  }
207
235
 
208
236
  if expression.__class__ in binary_operation_types:
@@ -219,8 +247,10 @@ class AFMReader(TextToModel):
219
247
  result.right = self.build_ast_node(expression.expression()[1], prefix)
220
248
 
221
249
  if isinstance(expression, AFMParser.NotExpContext):
250
+ # the operand of a unary operator is the left child (as the other readers and
251
+ # the AST simplification expect)
222
252
  result = Node(ASTOperation.NOT)
223
- result.right = self.build_ast_node(expression.expression(), prefix)
253
+ result.left = self.build_ast_node(expression.expression(), prefix)
224
254
 
225
255
  if isinstance(expression, AFMParser.ParenthesisExpContext):
226
256
  result = self.build_ast_node(expression.expression(), prefix)