flamapy-sharpsat 2.6.0.dev12__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 (23) hide show
  1. {flamapy_sharpsat-2.6.0.dev12/flamapy_sharpsat.egg-info → flamapy_sharpsat-2.9.0.dev0}/PKG-INFO +4 -3
  2. flamapy_sharpsat-2.9.0.dev0/flamapy/metamodels/sharpsat_metamodel/__init__.py +26 -0
  3. flamapy_sharpsat-2.9.0.dev0/flamapy/metamodels/sharpsat_metamodel/operations/_satcheck.py +16 -0
  4. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/operations/sharpsat_configurations_number.py +10 -0
  5. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/operations/sharpsat_sampling.py +18 -4
  6. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/transformations/fm_to_sharpsat.py +2 -2
  7. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0/flamapy_sharpsat.egg-info}/PKG-INFO +4 -3
  8. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/flamapy_sharpsat.egg-info/SOURCES.txt +2 -0
  9. flamapy_sharpsat-2.9.0.dev0/flamapy_sharpsat.egg-info/entry_points.txt +2 -0
  10. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/flamapy_sharpsat.egg-info/requires.txt +3 -2
  11. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/pyproject.toml +7 -3
  12. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/tests/test_sharpsat.py +94 -0
  13. flamapy_sharpsat-2.6.0.dev12/flamapy/metamodels/sharpsat_metamodel/__init__.py +0 -0
  14. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/LICENSE +0 -0
  15. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/README.md +0 -0
  16. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/models/__init__.py +0 -0
  17. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/models/sharpsat_model.py +0 -0
  18. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/operations/__init__.py +0 -0
  19. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/transformations/__init__.py +0 -0
  20. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/flamapy_sharpsat.egg-info/dependency_links.txt +0 -0
  21. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/flamapy_sharpsat.egg-info/top_level.txt +0 -0
  22. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/setup.cfg +0 -0
  23. {flamapy_sharpsat-2.6.0.dev12 → flamapy_sharpsat-2.9.0.dev0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flamapy-sharpsat
3
- Version: 2.6.0.dev12
3
+ Version: 2.9.0.dev0
4
4
  Summary: flamapy-sharpsat is a plugin to flamapy providing approximate model counting and almost-uniform sampling
5
5
  Author-email: Flamapy <flamapy@us.es>
6
6
  License-Expression: GPL-3.0-or-later
@@ -8,9 +8,10 @@ Project-URL: Homepage, https://github.com/flamapy/sharpsat_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.dev12
12
- Requires-Dist: flamapy-fm~=2.6.0.dev12
11
+ Requires-Dist: flamapy-fw~=2.9.0.dev0
12
+ Requires-Dist: flamapy-fm~=2.9.0.dev0
13
13
  Requires-Dist: pyunigen~=2.5
14
+ Requires-Dist: python-sat
14
15
  Provides-Extra: dev
15
16
  Requires-Dist: pytest; extra == "dev"
16
17
  Requires-Dist: pytest-mock; extra == "dev"
@@ -0,0 +1,26 @@
1
+ from flamapy.core.manifest import OperationCapability, PluginManifest
2
+ from flamapy.core.models.language_level import LanguageLevel, MajorLevel, MinorLevel
3
+
4
+ # Approximate counting (ApproxMC) and almost-uniform sampling (UniGen): never chosen by the
5
+ # planner unless the caller allows approximate results. Since 2.9 both operations pre-check
6
+ # satisfiability (pyunigen segfaults on UNSAT input) and short-circuit void models.
7
+ # Costs are estimated milliseconds from the 2026-11-06 calibration (betty synthetic models at
8
+ # 1% and 30% constraint density plus JHipster and busybox; worst case, never decreasing):
9
+ # counting takes 0.1 s at 100 features and 1.6 s at 200; from 500 features on it depends on the
10
+ # model (1.5-8 s on the dense 1,000/2,000-feature models, but the sparse 500-feature one and
11
+ # busybox did not finish within 3 minutes), so the curve carries the cap and the ceiling is the
12
+ # largest size measured.
13
+ MANIFEST = PluginManifest(
14
+ name='sharpsat',
15
+ calibration='2026-11-06',
16
+ extension='sharpsat',
17
+ supported_level=LanguageLevel(MajorLevel.BOOLEAN, {MinorLevel.GROUP_CARDINALITY}),
18
+ max_features=2000,
19
+ transformation_cost_curve=((200, 1), (500, 2), (1000, 4), (2000, 10)),
20
+ operations={
21
+ 'configurations_number': OperationCapability(
22
+ exact=False, cost_curve=((50, 30), (100, 100), (200, 1600), (500, 180000))),
23
+ 'sampling': OperationCapability(
24
+ exact=False, cost_curve=((50, 100), (100, 420), (200, 3500), (500, 70000))),
25
+ },
26
+ )
@@ -0,0 +1,16 @@
1
+ """Satisfiability pre-check guarding the pyunigen calls.
2
+
3
+ pyunigen (the UniGen/ApproxMC binding) segfaults the whole interpreter when handed an
4
+ unsatisfiable formula (found by the calibration runs during the 2.9 development, where it
5
+ silently killed worker pools), so every operation checks the CNF with a plain SAT call first and
6
+ short-circuits the trivial answer (count 0 / empty sample) instead of crashing.
7
+ """
8
+ from pysat.solvers import Solver
9
+
10
+
11
+ def satisfiable(clauses: list[list[int]]) -> bool:
12
+ solver = Solver(name='glucose3', bootstrap_with=clauses)
13
+ try:
14
+ return bool(solver.solve())
15
+ finally:
16
+ solver.delete()
@@ -6,6 +6,8 @@ from flamapy.core.models import VariabilityModel
6
6
  from flamapy.core.operations import ConfigurationsNumber
7
7
  from flamapy.metamodels.sharpsat_metamodel.models import SharpSATModel
8
8
 
9
+ from ._satcheck import satisfiable
10
+
9
11
 
10
12
  class SharpSATConfigurationsNumber(ConfigurationsNumber):
11
13
  """Approximate the number of valid configurations with ApproxMC.
@@ -19,8 +21,13 @@ class SharpSATConfigurationsNumber(ConfigurationsNumber):
19
21
  mis-configures ApproxMC's projected counting on feature-model CNFs with implied variables
20
22
  (the root and mandatory-relation unit clauses), undercounting by a factor of two; UniGen's
21
23
  ApproxMC invocation does not have that problem.
24
+
25
+ Unsatisfiable models short-circuit to 0 through a plain SAT pre-check: pyunigen
26
+ segfaults the interpreter on UNSAT input.
22
27
  """
23
28
 
29
+ exact = False # reported in the OperationResult provenance envelope
30
+
24
31
  def __init__(self, seed: int = 1) -> None:
25
32
  self._result = 0
26
33
  self._seed = seed
@@ -33,6 +40,9 @@ class SharpSATConfigurationsNumber(ConfigurationsNumber):
33
40
 
34
41
  def execute(self, model: VariabilityModel) -> 'SharpSATConfigurationsNumber':
35
42
  sharpsat_model = cast(SharpSATModel, model)
43
+ if not satisfiable(sharpsat_model.clauses):
44
+ self._result = 0
45
+ return self
36
46
  counter = pyunigen.Sampler(seed=self._seed)
37
47
  for clause in sharpsat_model.clauses:
38
48
  counter.add_clause(clause)
@@ -7,6 +7,8 @@ from flamapy.core.operations import Sampling
7
7
  from flamapy.metamodels.configuration_metamodel.models.configuration import Configuration
8
8
  from flamapy.metamodels.sharpsat_metamodel.models import SharpSATModel
9
9
 
10
+ from ._satcheck import satisfiable
11
+
10
12
 
11
13
  class SharpSATSampling(Sampling):
12
14
  """Almost-uniform sampling of valid configurations with UniGen.
@@ -14,8 +16,14 @@ class SharpSATSampling(Sampling):
14
16
  Unlike an enumerate-first-N sampler, UniGen draws configurations almost uniformly at
15
17
  random. Sampling is projected onto the feature variables, and an optional partial
16
18
  configuration fixes features as assumptions.
19
+
20
+ Unsatisfiable input (a void model, or a partial configuration that contradicts it)
21
+ short-circuits to an empty sample through a plain SAT pre-check: pyunigen segfaults
22
+ the interpreter on UNSAT input.
17
23
  """
18
24
 
25
+ exact = False # reported in the OperationResult provenance envelope
26
+
19
27
  def __init__(self, seed: int = 1) -> None:
20
28
  self._sample_size = 1
21
29
  self._with_replacement = False
@@ -40,14 +48,20 @@ class SharpSATSampling(Sampling):
40
48
 
41
49
  def execute(self, model: VariabilityModel) -> 'SharpSATSampling':
42
50
  sharpsat_model = cast(SharpSATModel, model)
43
- sampler = pyunigen.Sampler(seed=self._seed)
44
- for clause in sharpsat_model.clauses:
45
- sampler.add_clause(clause)
51
+ clauses = [list(clause) for clause in sharpsat_model.clauses]
46
52
  if self._partial_configuration is not None:
47
53
  for name, value in self._partial_configuration.elements.items():
48
54
  variable = sharpsat_model.variables.get(name)
49
55
  if variable is not None:
50
- sampler.add_clause([variable if value else -variable])
56
+ clauses.append([variable if value else -variable])
57
+
58
+ if not satisfiable(clauses):
59
+ self._result = []
60
+ return self
61
+
62
+ sampler = pyunigen.Sampler(seed=self._seed)
63
+ for clause in clauses:
64
+ sampler.add_clause(clause)
51
65
 
52
66
  _cells, _hashes, samples = sampler.sample(
53
67
  num=self._sample_size, sampling_set=sharpsat_model.feature_variables()
@@ -1,5 +1,5 @@
1
1
  from flamapy.core.transformations import ModelToModel
2
- from flamapy.metamodels.fm_metamodel.models import FeatureModel, ClauseSet
2
+ from flamapy.metamodels.fm_metamodel.models import ClauseSet, DEFAULT_CNF_METHOD, FeatureModel
3
3
  from flamapy.metamodels.sharpsat_metamodel.models import SharpSATModel
4
4
 
5
5
 
@@ -19,7 +19,7 @@ class FmToSharpSAT(ModelToModel):
19
19
  def get_destination_extension() -> str:
20
20
  return 'sharpsat'
21
21
 
22
- def __init__(self, source_model: FeatureModel, cnf_method: str = 'distributive') -> None:
22
+ def __init__(self, source_model: FeatureModel, cnf_method: str = DEFAULT_CNF_METHOD) -> None:
23
23
  self.source_model = source_model
24
24
  self.cnf_method = cnf_method
25
25
  self.destination_model = SharpSATModel()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flamapy-sharpsat
3
- Version: 2.6.0.dev12
3
+ Version: 2.9.0.dev0
4
4
  Summary: flamapy-sharpsat is a plugin to flamapy providing approximate model counting and almost-uniform sampling
5
5
  Author-email: Flamapy <flamapy@us.es>
6
6
  License-Expression: GPL-3.0-or-later
@@ -8,9 +8,10 @@ Project-URL: Homepage, https://github.com/flamapy/sharpsat_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.dev12
12
- Requires-Dist: flamapy-fm~=2.6.0.dev12
11
+ Requires-Dist: flamapy-fw~=2.9.0.dev0
12
+ Requires-Dist: flamapy-fm~=2.9.0.dev0
13
13
  Requires-Dist: pyunigen~=2.5
14
+ Requires-Dist: python-sat
14
15
  Provides-Extra: dev
15
16
  Requires-Dist: pytest; extra == "dev"
16
17
  Requires-Dist: pytest-mock; extra == "dev"
@@ -6,6 +6,7 @@ flamapy/metamodels/sharpsat_metamodel/__init__.py
6
6
  flamapy/metamodels/sharpsat_metamodel/models/__init__.py
7
7
  flamapy/metamodels/sharpsat_metamodel/models/sharpsat_model.py
8
8
  flamapy/metamodels/sharpsat_metamodel/operations/__init__.py
9
+ flamapy/metamodels/sharpsat_metamodel/operations/_satcheck.py
9
10
  flamapy/metamodels/sharpsat_metamodel/operations/sharpsat_configurations_number.py
10
11
  flamapy/metamodels/sharpsat_metamodel/operations/sharpsat_sampling.py
11
12
  flamapy/metamodels/sharpsat_metamodel/transformations/__init__.py
@@ -13,6 +14,7 @@ flamapy/metamodels/sharpsat_metamodel/transformations/fm_to_sharpsat.py
13
14
  flamapy_sharpsat.egg-info/PKG-INFO
14
15
  flamapy_sharpsat.egg-info/SOURCES.txt
15
16
  flamapy_sharpsat.egg-info/dependency_links.txt
17
+ flamapy_sharpsat.egg-info/entry_points.txt
16
18
  flamapy_sharpsat.egg-info/requires.txt
17
19
  flamapy_sharpsat.egg-info/top_level.txt
18
20
  tests/test_sharpsat.py
@@ -0,0 +1,2 @@
1
+ [flamapy.plugins]
2
+ sharpsat = flamapy.metamodels.sharpsat_metamodel
@@ -1,6 +1,7 @@
1
- flamapy-fw~=2.6.0.dev12
2
- flamapy-fm~=2.6.0.dev12
1
+ flamapy-fw~=2.9.0.dev0
2
+ flamapy-fm~=2.9.0.dev0
3
3
  pyunigen~=2.5
4
+ python-sat
4
5
 
5
6
  [dev]
6
7
  pytest
@@ -4,16 +4,17 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flamapy-sharpsat"
7
- version = "2.6.0.dev12"
7
+ version = "2.9.0.dev0"
8
8
  description = "flamapy-sharpsat is a plugin to flamapy providing approximate model counting and almost-uniform sampling"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
11
11
  authors = [{ name = "Flamapy", email = "flamapy@us.es" }]
12
12
  requires-python = ">=3.11"
13
13
  dependencies = [
14
- "flamapy-fw~=2.6.0.dev12",
15
- "flamapy-fm~=2.6.0.dev12",
14
+ "flamapy-fw~=2.9.0.dev0",
15
+ "flamapy-fm~=2.9.0.dev0",
16
16
  "pyunigen~=2.5",
17
+ "python-sat",
17
18
  ]
18
19
 
19
20
  [project.optional-dependencies]
@@ -25,6 +26,9 @@ dev = [
25
26
  "coverage",
26
27
  ]
27
28
 
29
+ [project.entry-points."flamapy.plugins"]
30
+ sharpsat = "flamapy.metamodels.sharpsat_metamodel"
31
+
28
32
  [project.urls]
29
33
  Homepage = "https://github.com/flamapy/sharpsat_metamodel"
30
34
 
@@ -146,3 +146,97 @@ def test_without_replacement_deduplicates():
146
146
  sample = op.execute(model).get_sample()
147
147
  keys = [frozenset(n for n, v in c.elements.items() if v) for c in sample]
148
148
  assert len(keys) == len(set(keys)) # no duplicates
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # UNSAT guard: pyunigen segfaults the interpreter on unsatisfiable input, so both
153
+ # operations must short-circuit without ever reaching it.
154
+ # ---------------------------------------------------------------------------
155
+
156
+ _VOID_UVL = """features
157
+ Root {abstract}
158
+ mandatory
159
+ A
160
+ B
161
+ constraints
162
+ A => !B
163
+ """
164
+
165
+
166
+ def test_void_model_counts_zero_without_crashing():
167
+ model = FmToSharpSAT(_fm_from(_VOID_UVL)).transform()
168
+ operation = SharpSATConfigurationsNumber()
169
+ assert operation.execute(model).get_result() == 0
170
+
171
+
172
+ def test_void_model_samples_empty_without_crashing():
173
+ model = FmToSharpSAT(_fm_from(_VOID_UVL)).transform()
174
+ operation = SharpSATSampling()
175
+ operation.set_sample_size(5)
176
+ assert operation.execute(model).get_result() == []
177
+
178
+
179
+ def test_unsatisfiable_partial_configuration_samples_empty():
180
+ model = FmToSharpSAT(_fm()).transform()
181
+ operation = SharpSATSampling()
182
+ operation.set_sample_size(3)
183
+ # A and B are mutually exclusive in _UVL (A => !B).
184
+ operation.set_partial_configuration(Configuration({'A': True, 'B': True}))
185
+ assert operation.execute(model).get_result() == []
186
+
187
+
188
+ def test_satisfiable_behavior_unchanged_by_the_guard():
189
+ fm = _fm()
190
+ model = FmToSharpSAT(fm).transform()
191
+ operation = SharpSATConfigurationsNumber()
192
+ assert operation.execute(model).get_result() == _exact_count(fm)
193
+
194
+
195
+ _TSEYTIN_UVL = """features
196
+ Root {abstract}
197
+ optional
198
+ A
199
+ B
200
+ C
201
+ D
202
+ E
203
+ F
204
+ constraints
205
+ (A & B & C) | (D & E & F) | (B & E & F)
206
+ """
207
+
208
+ # [1..2] over A, B, C (3 + 3 selections) times D optional: 12 configurations.
209
+ _GROUP_UVL = """features
210
+ Root {abstract}
211
+ [1..2]
212
+ A
213
+ B
214
+ C
215
+ optional
216
+ D
217
+ """
218
+
219
+
220
+ def test_cnf_methods_agree():
221
+ """Tseytin auxiliary variables must not change the count or let samples leak them."""
222
+ fm = _fm_from(_TSEYTIN_UVL)
223
+ assert ClauseSet.from_feature_model(fm, cnf_method='auto').auxiliary_variables
224
+ exact = _exact_count(fm)
225
+ for method in ('distributive', 'tseytin', 'auto'):
226
+ model = FmToSharpSAT(fm, cnf_method=method).transform()
227
+ assert SharpSATConfigurationsNumber().execute(model).get_result() == exact, method
228
+ sampler = SharpSATSampling()
229
+ sampler.set_sample_size(5)
230
+ for config in sampler.execute(model).get_sample():
231
+ assert _is_valid(fm, set(config.get_selected_elements())), method
232
+
233
+
234
+ def test_group_cardinality():
235
+ fm = _fm_from(_GROUP_UVL)
236
+ model = FmToSharpSAT(fm).transform()
237
+ assert SharpSATConfigurationsNumber().execute(model).get_result() == 12
238
+ sampler = SharpSATSampling()
239
+ sampler.set_sample_size(5)
240
+ for config in sampler.execute(model).get_sample():
241
+ selected = set(config.get_selected_elements())
242
+ assert 1 <= len(selected & {'A', 'B', 'C'}) <= 2