diff --git a/pycona/benchmarks/__init__.py b/pycona/benchmarks/__init__.py index 2cfc4cd..7506ce0 100644 --- a/pycona/benchmarks/__init__.py +++ b/pycona/benchmarks/__init__.py @@ -4,4 +4,6 @@ from .exam_timetabling import construct_examtt_simple from .job_shop_scheduling import construct_job_shop_scheduling_problem from .nurse_rostering import construct_nurse_rostering - +from .zebra import construct_zebra_problem +from .nqueens import construct_nqueens_problem +from .golomb import construct_golomb \ No newline at end of file diff --git a/pycona/benchmarks/golomb.py b/pycona/benchmarks/golomb.py new file mode 100644 index 0000000..b3c9923 --- /dev/null +++ b/pycona/benchmarks/golomb.py @@ -0,0 +1,138 @@ +import cpmpy as cp +from cpmpy.transformations.normalize import toplevel_list +from ..answering_queries.constraint_oracle import ConstraintOracle +from ..problem_instance import ProblemInstance, absvar +from itertools import combinations +from ..utils import get_scope, replace_variables, combine_sets_distinct + +class GolombInstance(ProblemInstance): + + def construct_bias(self, X=None): + """ + Construct the bias (candidate constraints) for the golomb instance. + We need a different bias construction for the golomb instance because + it needs to include all permutations of scopes for the quaternary relations. + """ + if X is None: + X = self.X + + all_cons = [] + + for relation in self.language: + + abs_vars = get_scope(relation) + + combs = list(combinations(X, 2)) + + if len(abs_vars) == 2: + for comb in combs: + replace_dict = dict() + for i, v in enumerate(comb): + replace_dict[abs_vars[i]] = v + constraint = replace_variables(relation, replace_dict) + all_cons.append(constraint) + elif len(abs_vars) == 4: + result_combinations = combine_sets_distinct(combs, combs) + for ((v1, v2), (v3, v4)) in result_combinations: + replace_dict = dict() + replace_dict[abs_vars[0]] = v1 + replace_dict[abs_vars[1]] = v2 + replace_dict[abs_vars[2]] = v3 + replace_dict[abs_vars[3]] = v4 + constraint = replace_variables(relation, replace_dict) + all_cons.append(constraint) + + self.bias = list(set(all_cons) - set(self.cl) - set(self.excluded_cons)) + + def construct_bias_for_vars(self, v1, X=None): + """ + Construct the bias (candidate constraints) for specific variables in the golomb instance. + Overrides the parent class method to handle the special case of quaternary relations in Golomb. + + Args: + v1: The variable(s) for which to construct the bias. Can be a single variable or list of variables. + X: The set of variables to consider, default is None (uses self.X). + """ + if not isinstance(v1, list): + v1 = [v1] + + if X is None: + X = self.X + + # Sort X based on variable names for consistency + X = sorted(X, key=lambda var: var.name) + + all_cons = [] + + for relation in self.language: + abs_vars = get_scope(relation) + + combs = list(combinations(X, 2)) + + if len(abs_vars) == 2: + for comb in combs: + replace_dict = dict() + for i, v in enumerate(comb): + replace_dict[abs_vars[i]] = v + constraint = replace_variables(relation, replace_dict) + all_cons.append(constraint) + elif len(abs_vars) == 4: + result_combinations = combine_sets_distinct(combs, combs) + for ((v1_, v2), (v3, v4)) in result_combinations: + replace_dict = dict() + replace_dict[abs_vars[0]] = v1_ + replace_dict[abs_vars[1]] = v2 + replace_dict[abs_vars[2]] = v3 + replace_dict[abs_vars[3]] = v4 + constraint = replace_variables(relation, replace_dict) + all_cons.append(constraint) + + # Filter constraints to only include those containing at least one of the specified variables + filtered_cons = [c for c in all_cons if any(v in set(get_scope(c)) for v in v1)] + self.bias = list(set(filtered_cons) - set(self.cl) - set(self.excluded_cons)) + + +def construct_golomb(n_marks=8): + """ + :Description: The Golomb ruler problem is to place n marks on a ruler such that the distances between any two marks are all different. + A Golomb ruler with 8 marks is sought in this instance. + :return: a ProblemInstance object, along with a constraint-based oracle + """ + # Parameters + parameters = {"n_marks": n_marks} + + # Variables + grid = cp.intvar(1, n_marks*8, shape=(1, n_marks), name="grid") + # adaptive domain: the larger the domain the slower query generation is + # current domain makes it satisfiable up to 12 marks, more marks make it too slow either way + + C_T = [] + + all_mark_pairs = [] + for a in range(n_marks): + for b in range(a + 1, n_marks): + all_mark_pairs.append((a, b)) + + for outer_idx in range(len(all_mark_pairs)): + i, j = all_mark_pairs[outer_idx] # Get the first pair of marks (i, j) + + for inner_idx in range(outer_idx + 1, len(all_mark_pairs)): + x, y = all_mark_pairs[inner_idx] # Get the second pair of marks (x, y) + + C_T += [cp.abs(grid[0, j] - grid[0, i]) != cp.abs(grid[0, y] - grid[0, x])] + + for i in range(n_marks): + for j in range(i + 1, n_marks): + C_T += [grid[0, i] < grid[0, j]] + + # Create the language: + AV = absvar(4) # create abstract vars - as many as maximum arity + + # create abstract relations using the abstract vars + lang = [AV[0] == AV[1], AV[0] != AV[1], AV[0] < AV[1], AV[0] > AV[1], AV[0] >= AV[1], AV[0] <= AV[1], cp.abs(AV[0] - AV[1]) != cp.abs(AV[2] - AV[3])] + + instance = GolombInstance(variables=grid, params=parameters, language=lang, name="golomb") + + oracle = ConstraintOracle(list(set(toplevel_list(C_T)))) + + return instance, oracle \ No newline at end of file diff --git a/pycona/benchmarks/nqueens.py b/pycona/benchmarks/nqueens.py new file mode 100644 index 0000000..cc3bfd9 --- /dev/null +++ b/pycona/benchmarks/nqueens.py @@ -0,0 +1,48 @@ +import cpmpy as cp +from cpmpy.transformations.normalize import toplevel_list +from ..answering_queries.constraint_oracle import ConstraintOracle +from ..problem_instance import ProblemInstance, absvar + +def construct_nqueens_problem(n): + + parameters = {"n": n} + + queens = cp.intvar(1, n, shape=n, name="queens") + + # Model + model = cp.Model() + + # Constraints list + CT = [] + diag = [] + + CT += list(cp.AllDifferent(queens).decompose()) + + for i in range(n): + for j in range(i + 1, n): # Compare each queen with every other queen once + diag += [(queens[i] - i != queens[j] - j)] # Different major diagonals + diag += [(queens[i] + i != queens[j] + j)] # Different minor diagonals + + # Add all collected constraints to the model + model += CT + diag + + C_T = toplevel_list(CT + diag) + + AV = absvar(2) + lang = [AV[0] == AV[1], AV[0] != AV[1], AV[0] < AV[1], AV[0] > AV[1], AV[0] >= AV[1], AV[0] <= AV[1]] + #lang = [AV[0] - AV[1] == constant for constant in range(-n, 2*n)] + [AV[0] - AV[1] != constant for constant in range(-n, 2*n)] + + instance = ProblemInstance(variables=queens, params=parameters, language=lang, name="nqueens") + + instance.construct_bias() + instance.bias = instance.bias + diag + + oracle = ConstraintOracle(list(set(toplevel_list(C_T)))) + + print("oracle constraints: ", len(oracle.constraints)) + for c in oracle.constraints: + print(c) + + input("Press Enter to continue...") + + return instance, oracle diff --git a/pycona/benchmarks/zebra.py b/pycona/benchmarks/zebra.py new file mode 100644 index 0000000..952ec86 --- /dev/null +++ b/pycona/benchmarks/zebra.py @@ -0,0 +1,63 @@ +import cpmpy as cp +from cpmpy.transformations.normalize import toplevel_list +from ..answering_queries.constraint_oracle import ConstraintOracle +from ..problem_instance import ProblemInstance, absvar + + +def construct_zebra_problem(): + """ + :Description: The zebra puzzle is a well-known logic puzzle. Five houses, each of a different color, are occupied by men of + different nationalities, with different pets, drinks and cigarettes. The puzzle is to find out who owns the zebra. + The puzzle has 15 clues that help determine the solution. + :return: a ProblemInstance object, along with a constraint-based oracle + """ + # Create a dictionary with the parameters + parameters = {"grid_size": 5, "num_categories": 5} + + # Variables + # Flattened array with 25 elements, representing 5 elements for each of the 5 categories + grid = cp.intvar(1, 5, shape=(5, 5), name="grid") + + C_T = list() + + # Extract variables for readability + ukr, norge, eng, spain, jap = grid[0, :] # Nationalities + red, blue, yellow, green, ivory = grid[1,:] # Colors + oldGold, parly, kools, lucky, chest = grid[2,:] # Cigarettes + zebra, dog, horse, fox, snails = grid[3,:] # Pets + coffee, tea, h2o, milk, oj = grid[4,:] # Drinks + + # Add all constraints + C_T += [(eng == red)] # Englishman lives in the red house + C_T += [(spain == dog)] # Spaniard owns the dog + C_T += [(coffee == green)] # Coffee is drunk in the green house + C_T += [(ukr == tea)] # Ukrainian drinks tea + C_T += [(green == ivory + 1)] # Green house is immediately right of the ivory house + C_T += [(oldGold == snails)] # OldGold smoker owns snails + C_T += [(kools == yellow)] # Kools are smoked in the yellow house + C_T += [(milk == 3)] # Milk is drunk in the middle house + C_T += [(norge == 1)] # Norwegian lives in the first house + C_T += [(abs(chest - fox) == 1)] # Chesterfield smoker lives next to the man with the fox + C_T += [(abs(kools - horse) == 1)] # Kools are smoked in the house next to the house with the horse + C_T += [(lucky == oj)] # Lucky smoker drinks orange juice + C_T += [(jap == parly)] # Japanese smokes Parliaments + C_T += [(abs(norge - blue) == 1)] # Norwegian lives next to the blue house + + # Each row must have different values + for row in grid: + C_T += list(cp.AllDifferent(row).decompose()) + + # Create the language: + AV = absvar(2) # create abstract vars - as many as maximum arity + + # create abstract relations using the abstract vars + lang = [AV[0] == AV[1], AV[0] != AV[1], AV[0] < AV[1], AV[0] > AV[1], AV[0] >= AV[1], AV[0] <= AV[1], + abs(AV[0] - AV[1]) == 1, abs(AV[0] - AV[1]) != 1, AV[0] - AV[1] == 1, AV[1] - AV[0] == 1] + [AV[0] == constant for constant in range(1, 6)] + [AV[0] != constant for constant in range(1, 6)] + + instance = ProblemInstance(variables=grid, params=parameters, language=lang, name="zebra") + + oracle = ConstraintOracle(list(set(toplevel_list(C_T)))) + + + + return instance, oracle diff --git a/pycona/ca_environment/ca_env_core.py b/pycona/ca_environment/ca_env_core.py index a2e1fb6..e3f9e7a 100644 --- a/pycona/ca_environment/ca_env_core.py +++ b/pycona/ca_environment/ca_env_core.py @@ -92,6 +92,7 @@ def add_to_cl(self, C): self.instance.cl.extend(C) self.instance.bias = list(set(self.instance.bias) - set(C)) - self.metrics.cl += 1 + self.metrics.cl += len(C) if self.verbose == 1: - print("L", end="") + for c in C: + print("L", end="") diff --git a/pycona/find_constraint/findc2.py b/pycona/find_constraint/findc2.py index 0afd37b..4ac6675 100644 --- a/pycona/find_constraint/findc2.py +++ b/pycona/find_constraint/findc2.py @@ -1,20 +1,18 @@ import cpmpy as cp +import copy from ..ca_environment.active_ca import ActiveCAEnv -from .utils import get_max_conjunction_size, get_delta_p +from .utils import get_max_conjunction_size, get_delta_p, join_con_net, unravel_conjunctions from .findc_core import FindCBase -from .utils import join_con_net -from ..utils import restore_scope_values, get_con_subset, check_value +from ..utils import restore_scope_values, get_con_subset, check_value, get_scope class FindC2(FindCBase): """ - This is the version of the FindC function that was presented in - Bessiere, Christian, et al., "Learning constraints through partial queries", AIJ 2023 - + Implementation of the FindC algorithm from Bessiere et al., "Learning constraints through partial queries" (AIJ 2023). + This function works also for non-normalised target networks! """ - # TODO optimize to work better (probably only needs to make better the generate_find_query2) def __init__(self, ca_env: ActiveCAEnv = None, time_limit=0.2, findscope=None): """ @@ -32,7 +30,8 @@ def findscope(self): """ Get the findscope function to be used. - :return: The findscope function. + Returns: + callable: The function used to determine constraint scopes """ return self._findscope @@ -41,33 +40,41 @@ def findscope(self, findscope): """ Set the findscope function to be used. - :param findscope: The findscope function. + Args: + findscope (callable): The function to be used for determining constraint scopes """ self._findscope = findscope def run(self, scope): """ - Run the FindC2 algorithm. + Execute the FindC2 algorithm to learn constraints within a given scope. + + Args: + scope (list): Variables defining the scope in which to search for constraints + + Returns: + list: The constraint(s) found in the given scope. - :param scope: The scope in which we search for a constraint. - :return: The constraint found. + Raises: + Exception: If the target constraint is not in the bias (search space). """ assert self.ca is not None - - # Initialize delta + scope_values = [x.value() for x in scope] + + # Initialize delta with constraints from bias that match the scope delta = get_con_subset(self.ca.instance.bias, scope) - delta = join_con_net(delta, [c for c in delta if check_value(c) is False]) + delta = [c for c in delta if len(get_scope(c)) == len(scope)] - # We need to take into account only the constraints in the scope we search on - sub_cl = get_con_subset(self.ca.instance.cl, scope) + # Join the constraints in delta with the violated constraints in kappaD + kappaD = [c for c in delta if check_value(c) is False] + delta = join_con_net(delta, kappaD) - scope_values = [x.value() for x in scope] + # Get subset of learned constraints in the current scope + sub_cl = get_con_subset(self.ca.instance.cl, scope) while True: - - # Try to generate a counter example to reduce the candidates + # Generate a query to distinguish between candidate constraints if self.generate_findc_query(sub_cl, delta) is False: - # If no example could be generated # Check if delta is the empty set, and if yes then collapse if len(delta) == 0: @@ -75,8 +82,12 @@ def run(self, scope): restore_scope_values(scope, scope_values) - # Return random c in delta otherwise (if more than one, they are equivalent w.r.t. C_l) - return delta[0] + # Unravel nested AND constraints + delta_unraveled = unravel_conjunctions(delta) + + # Return the smallest equivalent conjunction (if more than one, they are equivalent w.r.t. C_l) + delta_unraveled = sorted(delta_unraveled, key=lambda x: len(x)) + return delta_unraveled[0] self.ca.metrics.increase_findc_queries() @@ -89,53 +100,71 @@ def run(self, scope): # delta <- joint(delta,K_{delta}(e)) kappaD = [c for c in delta if check_value(c) is False] - - scope2 = self.ca.run_find_scope(list(scope), kappaD) # TODO: replace with real findscope arguments when done! - + scope2 = self.ca.run_find_scope(list(scope)) if len(scope2) < len(scope): - self.run(scope2) + # Recursively learn constraint in sub-scope + c = self.run(scope2) + self.ca.add_to_cl(c) + sub_cl.append(c) else: delta = join_con_net(delta, kappaD) def generate_findc_query(self, L, delta): - # TODO: optimize to work better """ - Changes directly the values of the variables + Generate a query that helps distinguish between candidate constraints. - :param L: learned network in the given scope - :param delta: candidate constraints in the given scope - :return: Boolean value representing a success or failure on the generation - """ - - tmp = cp.Model(L) - - max_conj_size = get_max_conjunction_size(delta) - delta_p = get_delta_p(delta) + Args: + L (list): Currently learned constraints in the scope + delta (list): Candidate constraints to distinguish between - p = cp.intvar(0, max_conj_size) - kappa_delta_p = cp.intvar(0, len(delta), shape=(max_conj_size,)) - p_soft_con = cp.boolvar(shape=(max_conj_size,)) + Returns: + bool: True if a query was generated successfully, False otherwise - for i in range(max_conj_size): - tmp += kappa_delta_p[i] == sum([c for c in delta_p[i]]) - p_soft_con[i] = (kappa_delta_p[i] > 0) + Note: + The method directly modifies variable values in the constraint network + """ + tmp = cp.Model(L) - tmp += p == min([i for i in range(max_conj_size) if (kappa_delta_p[i] < len(delta_p[i]))]) + satisfied_delta = sum([c for c in delta]) # get the amount of satisfied constraints from B - objective = sum([c for c in delta]) # get the amount of satisfied constraints from B + scope = get_scope(delta[0]) # at least 1 violated and at least 1 satisfied # we want this to assure that each answer of the user will reduce # the set of candidates - tmp += objective < len(delta) - tmp += objective > 0 + tmp += satisfied_delta < len(delta) + tmp += satisfied_delta > 0 + + max_conj_size = get_max_conjunction_size(delta) + delta_p = get_delta_p(delta) + + for p in range(max_conj_size): + s = cp.SolverLookup.get("ortools", tmp) + + kappa_delta_p = sum([c for c in delta_p[p]]) + s += kappa_delta_p < len(delta_p[p]) + + # Solve without objective for start + if not s.solve(): # if a solution is not found + continue + + # Next solve will change the values of the variables in lY + # so we need to return them to the original ones to continue if we don't find a solution next + values = [x.value() for x in scope] - # Try first without objective - s = cp.SolverLookup.get("ortools", tmp) + p_soft_con = (kappa_delta_p > 0) + + # So a solution was found, try to find a better one now + # set the objective + s.maximize(p_soft_con) - # run with the objective - s.minimize(100 * p - p_soft_con[p]) + # Give hint with previous solution to the solver + s.solution_hint(scope, values) - flag = s.solve(time_limit=self.time_limit) + # Solve with objective + flag = s.solve(time_limit=self.time_limit, num_workers=8) + if not flag: + restore_scope_values(scope, values) + return True - return flag + return False \ No newline at end of file diff --git a/pycona/find_constraint/utils.py b/pycona/find_constraint/utils.py index 899825b..e78e25f 100644 --- a/pycona/find_constraint/utils.py +++ b/pycona/find_constraint/utils.py @@ -1,4 +1,5 @@ from itertools import chain +import cpmpy as cp def get_max_conjunction_size(C1): @@ -64,59 +65,97 @@ def join_con_net(C1, C2): :param C2: The second list of constraints. :return: A list of constraints resulting from the conjunction of C1 and C2. """ - C3 = [[c1 & c2 if c1 is not c2 else c1 for c2 in C2] for c1 in C1] + C3 = [[set(c1 + c2) for c2 in unravel_conjunctions(C2)] for c1 in unravel_conjunctions(C1)] C3 = list(chain.from_iterable(C3)) + C3 = [cp.all(c) for c in C3] C3 = remove_redundant_conj(C3) return C3 -def remove_redundant_conj(C1): +def get_conjunction_args(constraint): """ - Remove redundant conjunctions from the given list of constraints. - - :param C1: A list of constraints. - :return: A list of constraints with redundant conjunctions removed. + Break down a constraint into its constituent conjunctive arguments. + + Args: + constraint: A CPMpy constraint that may contain conjunctions + + Returns: + list: A list of atomic constraints that make up the conjunction """ - C2 = list() - - for c in C1: - C = [c] - conj_args = [] - - while len(C) > 0: - c1 = C.pop() + stack = [constraint] + conj_args = [] - if c1.name == 'and': - [C.append(c2) for c2 in c1.args] - else: - conj_args.append(c1) + while stack: + current = stack.pop() + if current.name == 'and': + stack.extend(current.args) + else: + conj_args.append(current) + + return conj_args - flag_eq = False - flag_neq = False - flag_geq = False - flag_leq = False - flag_ge = False - flag_le = False - - for c1 in conj_args: - print(c1.name) - # Tias is on 3.9, no 'match' please! - if c1.name == "==": - flag_eq = True - elif c1.name == "!=": - flag_neq = True - elif c1.name == "<=": - flag_leq = True - elif c1.name == ">=": - flag_geq = True - elif c1.name == "<": - flag_le = True - elif c1.name == ">": - flag_ge = True - else: - raise Exception("constraint name is not recognised") - if not ((flag_eq and (flag_neq or flag_le or flag_ge)) or ( - (flag_leq or flag_le) and (flag_geq or flag_ge))): - C2.append(c) - return C2 +def remove_redundant_conj(constraints: list) -> list: + """ + Remove redundant conjunctions from the given list of constraints. + A conjunction is considered redundant if: + 1. It contains the same set of atomic constraints as another conjunction, or + 2. It is unsatisfiable + + Args: + constraints: A list of CPMpy constraints, potentially containing conjunctions + + Returns: + list: A filtered list of constraints with redundant conjunctions removed + + Example: + >>> x = cp.intvar(0, 10, "x") + >>> constraints = [x >= 0, x >= 0 & x <= 5, x >= 2 & x <= 5] + >>> result = remove_redundant_conj(constraints) + >>> len(result) < len(constraints) # Some redundant constraints removed + True + """ + unique_constraints = [] + unique_atomic_sets = [] + + for constraint in constraints: + # Break down the constraint into atomic parts + atomic_constraints = get_conjunction_args(constraint) + + # Check if this set of atomic constraints is unique + is_redundant = any( + len(atomic_constraints) == len(existing_set) and + set(atomic_constraints) == set(existing_set) + for existing_set in unique_atomic_sets + ) + + if not is_redundant: + # Verify the constraint is satisfiable + try: + if cp.Model(constraint).solve(): + unique_constraints.append(constraint) + unique_atomic_sets.append(atomic_constraints) + except cp.exceptions.UnsatisfiableError: + # Skip unsatisfiable constraints + continue + + return unique_constraints + +def unravel_conjunctions(constraints: list) -> list: + """ + Unravel conjunctions in the given list of constraints. + """ + if not isinstance(constraints, list): + constraints = [constraints] + + unraveled = [] + for c in constraints: + if c.name == 'and': + sub_list = [] + for sub_c in c.args: + sub_list.append(sub_c) + unraveled.append(sub_list) + else: + unraveled.append([c]) + + return unraveled \ No newline at end of file diff --git a/pycona/predictor/feature_representation.py b/pycona/predictor/feature_representation.py index 1e23e44..fb60ac9 100644 --- a/pycona/predictor/feature_representation.py +++ b/pycona/predictor/feature_representation.py @@ -112,11 +112,11 @@ def _init_features(self): self._features['Var_name_same'] = 'Bool' for i in range(self._max_ndims): + self._features[f"Dim{i}_same"] = 'Bool' self._features[f"Dim{i}_max"] = 'Int' self._features[f"Dim{i}_min"] = 'Int' self._features[f"Dim{i}_avg"] = 'Real' self._features[f"Dim{i}_diff"] = 'Real' - self._features[f"Dim{i}_avg_diff"] = 'Real' self._features[f"Relation"] = self._lang self._features[f"Arity"] = 'Real' diff --git a/pycona/problem_instance/language.py b/pycona/problem_instance/language.py index 4d4cc79..1032a2c 100644 --- a/pycona/problem_instance/language.py +++ b/pycona/problem_instance/language.py @@ -38,7 +38,7 @@ def __init__(self, name): def is_bool(self): """ is it a Boolean (return type) Operator? """ - return NotImplementedError("Abstract variable is not supposed to be used") + return False def value(self): """ the value obtained in the last solve call diff --git a/tests/test_finc.py b/tests/test_finc.py new file mode 100644 index 0000000..3ac5455 --- /dev/null +++ b/tests/test_finc.py @@ -0,0 +1,81 @@ +import pytest +import cpmpy as cp +from pycona.find_constraint import FindC, FindC2 +from pycona.ca_environment.active_ca import ActiveCAEnv +from pycona.find_constraint.findc_obj import findc_obj_splithalf, findc_obj_proba +from pycona.benchmarks.golomb import construct_golomb +import pycona as ca + +algorithms = [FindC(), FindC2()] +fast_algorithms = [FindC()] # Use only FindC for fast tests + +class TestFinC: + @pytest.mark.fast + def test_findc_query_generation(self): + """Test query generation in FindC""" + ca_env = ActiveCAEnv() + findc = FindC(ca_env=ca_env) + + # Create test variables + x = cp.intvar(1, 10, name="x") + y = cp.intvar(1, 10, name="y") + + # Create constraints + L = [x <= y] # learned constraints + delta = [x < y, x >= y, x == y] # candidate constraints + + # Test query generation + assert findc.generate_findc_query(L, delta) + + @pytest.mark.fast + def test_findc2_query_generation(self): + """Test query generation in FindC2""" + ca_env = ActiveCAEnv() + findc2 = FindC2(ca_env=ca_env) + + # Create test variables + x = cp.intvar(1, 10, name="x") + y = cp.intvar(1, 10, name="y") + + # Create constraints + L = [x <= y] # learned constraints + delta = [x < y, x >= y, x == y] # candidate constraints + + # Test query generation + assert findc2.generate_findc_query(L, delta) + + @pytest.mark.fast + def test_findc_objective_functions(self): + """Test objective function changes in FindC""" + findc = FindC() + + # Test probability-based objective + findc.obj = findc_obj_proba + assert findc.obj == findc_obj_proba + + # Test split-half objective + findc.obj = findc_obj_splithalf + assert findc.obj == findc_obj_splithalf + + def test_findc2_with_golomb4(self): + """Test FindC with a Golomb ruler of order 4""" + ca_env = ActiveCAEnv(findc=FindC2()) + alg = ca.QuAcq(ca_env) + + # Create Golomb ruler instance of order 4 + instance, oracle = construct_golomb(n_marks=4) + + li = alg.learn(instance, oracle) + + # oracle model imply learned? + oracle_not_learned = cp.Model(oracle.constraints) + oracle_not_learned += cp.any([~c for c in li._cl]) + assert not oracle_not_learned.solve() + + # learned model imply oracle? + learned_not_oracle = cp.Model(li._cl) + learned_not_oracle += cp.any([~c for c in oracle.constraints]) + assert not learned_not_oracle.solve() + + + diff --git a/tests/test_findscope.py b/tests/test_findscope.py index 2810322..9245b7b 100644 --- a/tests/test_findscope.py +++ b/tests/test_findscope.py @@ -6,11 +6,19 @@ import cpmpy as cp algorithms = [ca.FindScope(), ca.FindScope2()] +fast_algorithms = [ca.FindScope2()] # Use only FindScope for fast tests class TestFindScope: - def test_findscope(self): + @pytest.mark.parametrize( + "algorithm", + [ + *[pytest.param(alg, marks=pytest.mark.fast) for alg in fast_algorithms], + *[pytest.param(alg) for alg in algorithms if alg not in fast_algorithms] + ] + ) + def test_findscope(self, algorithm): a, b, c, d = cp.intvar(0, 9, shape=4) # variables vars_array = cp.cpm_array([a, b, c, d]) @@ -21,7 +29,8 @@ def test_findscope(self): constraints = toplevel_list(oracle_model.constraints) instance = ca.ProblemInstance(variables=cp.cpm_array(vars_array)) - ca_env = ca.ActiveCAEnv(find_scope=ca.FindScope()) + instance.bias = [10 * c + d == 3 * (10 * a + b), 10 * d + a == 2 * (10 * b + c)] + ca_env = ca.ActiveCAEnv(find_scope=algorithm) for con in range(len(constraints)): model = cp.Model(constraints[:con] + constraints[con + 1:]) # all constraints except this @@ -33,5 +42,4 @@ def test_findscope(self): ca_env.init_state(oracle=ca.ConstraintOracle(oracle_model.constraints), instance=instance, verbose=1) Y = ca_env.run_find_scope(vars_array) - assert ca.utils.compare_scopes(Y, - get_variables(constraints[con])), f"{Y}, {get_variables(constraints[con])}" + assert ca.utils.compare_scopes(Y, get_variables(constraints[con])), f"{Y}, {get_variables(constraints[con])}"