flamapy-sharpsat 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 (24) hide show
  1. {flamapy_sharpsat-2.6.0.dev11/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.dev11 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/operations/sharpsat_configurations_number.py +10 -0
  5. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/operations/sharpsat_sampling.py +18 -4
  6. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/transformations/fm_to_sharpsat.py +2 -2
  7. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0/flamapy_sharpsat.egg-info}/PKG-INFO +4 -3
  8. {flamapy_sharpsat-2.6.0.dev11 → 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.dev11 → flamapy_sharpsat-2.9.0.dev0}/flamapy_sharpsat.egg-info/requires.txt +3 -2
  11. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/pyproject.toml +7 -3
  12. flamapy_sharpsat-2.9.0.dev0/tests/test_sharpsat.py +242 -0
  13. flamapy_sharpsat-2.6.0.dev11/flamapy/metamodels/sharpsat_metamodel/__init__.py +0 -0
  14. flamapy_sharpsat-2.6.0.dev11/tests/test_sharpsat.py +0 -140
  15. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/LICENSE +0 -0
  16. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/README.md +0 -0
  17. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/models/__init__.py +0 -0
  18. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/models/sharpsat_model.py +0 -0
  19. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/operations/__init__.py +0 -0
  20. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/flamapy/metamodels/sharpsat_metamodel/transformations/__init__.py +0 -0
  21. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/flamapy_sharpsat.egg-info/dependency_links.txt +0 -0
  22. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/flamapy_sharpsat.egg-info/top_level.txt +0 -0
  23. {flamapy_sharpsat-2.6.0.dev11 → flamapy_sharpsat-2.9.0.dev0}/setup.cfg +0 -0
  24. {flamapy_sharpsat-2.6.0.dev11 → 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.dev11
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.dev11
12
- Requires-Dist: flamapy-fm~=2.6.0.dev11
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.dev11
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.dev11
12
- Requires-Dist: flamapy-fm~=2.6.0.dev11
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.dev11
2
- flamapy-fm~=2.6.0.dev11
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.dev11"
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.dev11",
15
- "flamapy-fm~=2.6.0.dev11",
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
 
@@ -0,0 +1,242 @@
1
+ """Tests for the sharpsat metamodel: approximate counting and almost-uniform sampling."""
2
+ import os
3
+ import tempfile
4
+ from itertools import product
5
+
6
+ import pytest
7
+
8
+ from flamapy.metamodels.fm_metamodel.transformations import UVLReader
9
+ from flamapy.metamodels.fm_metamodel.models import FeatureModel, ClauseSet
10
+ from flamapy.metamodels.sharpsat_metamodel.transformations import FmToSharpSAT
11
+ from flamapy.metamodels.sharpsat_metamodel.operations import (
12
+ SharpSATConfigurationsNumber,
13
+ SharpSATSampling,
14
+ )
15
+ from flamapy.metamodels.configuration_metamodel.models.configuration import Configuration
16
+
17
+
18
+ # Exact oracle: enumerate the valid configurations by brute-forcing the CNF produced by
19
+ # fm_metamodel's solver-agnostic ClauseSet — no SAT backend, so the tests only depend on
20
+ # flamapy-fm (already required) and match sharpsat's own encoding path.
21
+ def _valid_configs(fm: FeatureModel) -> set:
22
+ """Valid configurations as frozensets of selected feature names."""
23
+ clause_set = ClauseSet.from_feature_model(fm)
24
+ name_of = {vid: name for name, vid in clause_set.variables.items()}
25
+ var_ids = sorted({abs(lit) for clause in clause_set.clauses for lit in clause}
26
+ | set(clause_set.variables.values()))
27
+ configs = set()
28
+ for bits in product((False, True), repeat=len(var_ids)):
29
+ assignment = dict(zip(var_ids, bits))
30
+ if all(any((lit > 0) == assignment[abs(lit)] for lit in clause)
31
+ for clause in clause_set.clauses):
32
+ configs.add(frozenset(name_of[v] for v in var_ids if assignment[v] and v in name_of))
33
+ return configs
34
+
35
+
36
+ def _exact_count(fm: FeatureModel) -> int:
37
+ return len(_valid_configs(fm))
38
+
39
+
40
+ _UVL = """features
41
+ Root {abstract}
42
+ optional
43
+ A
44
+ B
45
+ C
46
+ D
47
+ constraints
48
+ (A | B) => (C <=> D)
49
+ A => !B
50
+ """
51
+
52
+
53
+ def _fm() -> FeatureModel:
54
+ handle, path = tempfile.mkstemp(suffix='.uvl')
55
+ try:
56
+ with os.fdopen(handle, 'w') as file:
57
+ file.write(_UVL)
58
+ return UVLReader(path).transform()
59
+ finally:
60
+ os.remove(path)
61
+
62
+
63
+ def _all_feature_names(fm: FeatureModel) -> list[str]:
64
+ return [feature.name for feature in fm.get_features()]
65
+
66
+
67
+ def _is_valid(fm: FeatureModel, selected: set) -> bool:
68
+ return frozenset(selected) in _valid_configs(fm)
69
+
70
+
71
+ _IMPLIED_VARS_UVL = """features
72
+ Root {abstract}
73
+ mandatory
74
+ Base
75
+ mandatory
76
+ Core
77
+ optional
78
+ A
79
+ B
80
+ C
81
+ constraints
82
+ A => B
83
+ """
84
+
85
+
86
+ def _fm_from(uvl):
87
+ handle, path = tempfile.mkstemp(suffix='.uvl')
88
+ try:
89
+ with os.fdopen(handle, 'w') as file:
90
+ file.write(uvl)
91
+ return UVLReader(path).transform()
92
+ finally:
93
+ os.remove(path)
94
+
95
+
96
+ def test_approximate_count_matches_exact_on_small_model():
97
+ fm = _fm()
98
+ exact = _exact_count(fm)
99
+ approx = SharpSATConfigurationsNumber().execute(FmToSharpSAT(fm).transform()).get_result()
100
+ # ApproxMC's default (epsilon, delta) is exact on a model this small.
101
+ assert approx == exact
102
+
103
+
104
+ def test_count_is_correct_with_implied_variables():
105
+ # Regression guard: models with implied variables (mandatory chains, root unit clause)
106
+ # must not be undercounted. The standalone pyapproxmc binding halved these; the UniGen
107
+ # ApproxMC invocation used by the counter does not.
108
+ fm = _fm_from(_IMPLIED_VARS_UVL)
109
+ exact = _exact_count(fm)
110
+ approx = SharpSATConfigurationsNumber().execute(FmToSharpSAT(fm).transform()).get_result()
111
+ assert approx == exact
112
+
113
+
114
+ def test_sampling_returns_valid_configurations():
115
+ fm = _fm()
116
+ model = FmToSharpSAT(fm).transform()
117
+ op = SharpSATSampling()
118
+ op.set_sample_size(6)
119
+ op.set_with_replacement(True)
120
+ sample = op.execute(model).get_sample()
121
+ assert len(sample) == 6
122
+ for configuration in sample:
123
+ selected = {name for name, value in configuration.elements.items() if value}
124
+ assert _is_valid(fm, selected), f"invalid configuration sampled: {selected}"
125
+
126
+
127
+ def test_sampling_respects_partial_configuration():
128
+ fm = _fm()
129
+ model = FmToSharpSAT(fm).transform()
130
+ op = SharpSATSampling()
131
+ op.set_sample_size(5)
132
+ op.set_with_replacement(True)
133
+ op.set_partial_configuration(Configuration({'A': True}))
134
+ for configuration in op.execute(model).get_sample():
135
+ selected = {name for name, value in configuration.elements.items() if value}
136
+ assert 'A' in selected # the fixed feature is always present
137
+ assert 'B' not in selected # A => !B forces B out
138
+
139
+
140
+ def test_without_replacement_deduplicates():
141
+ fm = _fm()
142
+ model = FmToSharpSAT(fm).transform()
143
+ op = SharpSATSampling()
144
+ op.set_sample_size(20)
145
+ op.set_with_replacement(False)
146
+ sample = op.execute(model).get_sample()
147
+ keys = [frozenset(n for n, v in c.elements.items() if v) for c in sample]
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
@@ -1,140 +0,0 @@
1
- """Tests for the sharpsat metamodel: approximate counting and almost-uniform sampling."""
2
- import os
3
- import tempfile
4
-
5
- import pytest
6
-
7
- from flamapy.metamodels.fm_metamodel.transformations import UVLReader
8
- from flamapy.metamodels.fm_metamodel.models import FeatureModel
9
- from flamapy.metamodels.sharpsat_metamodel.transformations import FmToSharpSAT
10
- from flamapy.metamodels.sharpsat_metamodel.operations import (
11
- SharpSATConfigurationsNumber,
12
- SharpSATSampling,
13
- )
14
-
15
- # Exact oracle and validity oracle via SAT (a declared dependency, unlike BDD).
16
- from flamapy.metamodels.pysat_metamodel.transformations import FmToPysat
17
- from flamapy.metamodels.pysat_metamodel.operations.pysat_configurations_number import (
18
- PySATConfigurationsNumber,
19
- )
20
- from flamapy.metamodels.pysat_metamodel.operations.pysat_satisfiable_configuration import (
21
- PySATSatisfiableConfiguration,
22
- )
23
- from flamapy.metamodels.configuration_metamodel.models.configuration import Configuration
24
-
25
-
26
- _UVL = """features
27
- Root {abstract}
28
- optional
29
- A
30
- B
31
- C
32
- D
33
- constraints
34
- (A | B) => (C <=> D)
35
- A => !B
36
- """
37
-
38
-
39
- def _fm() -> FeatureModel:
40
- handle, path = tempfile.mkstemp(suffix='.uvl')
41
- try:
42
- with os.fdopen(handle, 'w') as file:
43
- file.write(_UVL)
44
- return UVLReader(path).transform()
45
- finally:
46
- os.remove(path)
47
-
48
-
49
- def _all_feature_names(fm: FeatureModel) -> list[str]:
50
- return [feature.name for feature in fm.get_features()]
51
-
52
-
53
- def _is_valid(fm: FeatureModel, selected: set) -> bool:
54
- sat_model = FmToPysat(fm).transform()
55
- elements = {name: (name in selected) for name in _all_feature_names(fm)}
56
- configuration = Configuration(elements)
57
- configuration.set_full(True)
58
- op = PySATSatisfiableConfiguration()
59
- op.set_configuration(configuration)
60
- return op.execute(sat_model).get_result()
61
-
62
-
63
- _IMPLIED_VARS_UVL = """features
64
- Root {abstract}
65
- mandatory
66
- Base
67
- mandatory
68
- Core
69
- optional
70
- A
71
- B
72
- C
73
- constraints
74
- A => B
75
- """
76
-
77
-
78
- def _fm_from(uvl):
79
- handle, path = tempfile.mkstemp(suffix='.uvl')
80
- try:
81
- with os.fdopen(handle, 'w') as file:
82
- file.write(uvl)
83
- return UVLReader(path).transform()
84
- finally:
85
- os.remove(path)
86
-
87
-
88
- def test_approximate_count_matches_exact_on_small_model():
89
- fm = _fm()
90
- exact = PySATConfigurationsNumber().execute(FmToPysat(fm).transform()).get_result()
91
- approx = SharpSATConfigurationsNumber().execute(FmToSharpSAT(fm).transform()).get_result()
92
- # ApproxMC's default (epsilon, delta) is exact on a model this small.
93
- assert approx == exact
94
-
95
-
96
- def test_count_is_correct_with_implied_variables():
97
- # Regression guard: models with implied variables (mandatory chains, root unit clause)
98
- # must not be undercounted. The standalone pyapproxmc binding halved these; the UniGen
99
- # ApproxMC invocation used by the counter does not.
100
- fm = _fm_from(_IMPLIED_VARS_UVL)
101
- exact = PySATConfigurationsNumber().execute(FmToPysat(fm).transform()).get_result()
102
- approx = SharpSATConfigurationsNumber().execute(FmToSharpSAT(fm).transform()).get_result()
103
- assert approx == exact
104
-
105
-
106
- def test_sampling_returns_valid_configurations():
107
- fm = _fm()
108
- model = FmToSharpSAT(fm).transform()
109
- op = SharpSATSampling()
110
- op.set_sample_size(6)
111
- op.set_with_replacement(True)
112
- sample = op.execute(model).get_sample()
113
- assert len(sample) == 6
114
- for configuration in sample:
115
- selected = {name for name, value in configuration.elements.items() if value}
116
- assert _is_valid(fm, selected), f"invalid configuration sampled: {selected}"
117
-
118
-
119
- def test_sampling_respects_partial_configuration():
120
- fm = _fm()
121
- model = FmToSharpSAT(fm).transform()
122
- op = SharpSATSampling()
123
- op.set_sample_size(5)
124
- op.set_with_replacement(True)
125
- op.set_partial_configuration(Configuration({'A': True}))
126
- for configuration in op.execute(model).get_sample():
127
- selected = {name for name, value in configuration.elements.items() if value}
128
- assert 'A' in selected # the fixed feature is always present
129
- assert 'B' not in selected # A => !B forces B out
130
-
131
-
132
- def test_without_replacement_deduplicates():
133
- fm = _fm()
134
- model = FmToSharpSAT(fm).transform()
135
- op = SharpSATSampling()
136
- op.set_sample_size(20)
137
- op.set_with_replacement(False)
138
- sample = op.execute(model).get_sample()
139
- keys = [frozenset(n for n, v in c.elements.items() if v) for c in sample]
140
- assert len(keys) == len(set(keys)) # no duplicates