How can I generate a graph by constraining it to be subisomorphic to a given graph, while not subisomorphic to another?

Viewed 105

TL;DR: How can I generate a graph while constraining it to be subisomorph to every graph in a positive list while being non-subisomorph to every graph in a negative list?

I have a list of directed heterogeneous attributed graphs labeled as positive or negative. I would like to find the smallest list of patterns(graphs with special values) such that:

  • Every input graph has a pattern that matches(= 'P is subisomorphic to G, and the mapped nodes have the same attribute values')
  • A positive pattern can only match a positive graph
  • A positive pattern does not match any negative graph
  • A negative pattern can only match a negative graph
  • A negative pattern does not match any negative graph

Exemple: Input g1(+),g2(-),g3(+),g4(+),g5(-),g6(+)

Acceptable solution: p1(+),p2(+),p3(-) where p1(+) matches g1(+) and g4(+); p2(+) matches g3(+) and g6(+); and p3(-) matches g2(-) and g5(-)

Non acceptable solution: p1(+),p2(-) where p1(+) matches g1(+),g2(-),g3(+); p2(-) matches g4(+),g5(-),g6(+)

Currently, I'm able to generate graphs matching every graph in a list, but I can't manage to enforce the constraint 'A positive pattern does not match any negative graph'. I made a predicate 'matches', which takes as input a pattern and a graph, and uses a local array of variables 'mapping' to try and map nodes together. But when I try to use that predicate in a negative context, the following error is returned: MiniZinc: flattening error: free variable in non-positive context.

How can I bypass that limitation? I tried to code the opposite predicate 'not_matches' but I've not yet found how to specify 'for all node mapping, the isomorphism is invalid'. I also can't define the mapping outside the predicate, because a pattern can match a graph more than once and i need to be able to get all mappings.

Here is a reproductible exemple:

include "globals.mzn";

predicate p(array [1..5] of var 0..10:arr1, array [1..5] of 1..10:arr2)=
          let{array [1..5] of var 1..5: mapping; constraint all_different(mapping)} in (forall(i in 1..5)(arr1[i]=0\/arr1[i]=arr2[mapping[i]]));


                    
array [1..5] of var 0..10:arr;
constraint p(arr,[1,2,3,4,5]);
constraint  p(arr,[1,2,3,4,6]);
constraint  not p(arr,[1,2,3,5,6]);

solve satisfy;

For that exemple, the decision variable is an array and the predicate p is true if a mapping exists such that the values of the array are mapped together. One or more elements of the array can also be 0, used here as a wildcard.

  • [1,2,3,4,0] is an acceptable solution
  • [0,0,0,0,0] is not acceptable, it matches anything. And the solution should not match [1,2,3,5,6]
  • [1,2,3,4,7] is not acceptable, it doesn't match anything(as there is no 7 in the parameter arrays)

Thanks by advance! =)

Edit: Added non-acceptable solutions

2 Answers

It is probably good to note that MiniZinc's limitation is not coincidental. When the creation of a free variable is negated, rather then finding a valid assignment for the variable, instead the model would have to prove that no such valid assignment exists. This is a much harder problem that would bring MiniZinc into the field of quantified constraint programming. The only general solution (to still receive the same flattened constraint model) would be to iterate over all possible values for each variable and enforce the negated constraints. Since the number of possibilities quickly explodes and the chance of getting a good model is small, MiniZinc does not do this automatically and throws this error instead.

This technique would work in your case as well. In the not_matches version of your predicate, you can iterate over all possible permutations (the possible mappings) and enforce that they not correct (partial) mappings. This would be a correct way to enforce the constraint, but would quickly explode. I believe, however, that there is a different way to enforce this constraint that will work better.

My idea stems from the fact that, although the most natural way to describe a permutation from one array to the another is to actually create the assignment from the first to the second, when dealing with discrete variables, you can instead enforce that each has the exact same number of each possible value. As such a predicate that enforces X is a permutation of Y might be written as:

predicate is_perm(array[int] of var $$E: X, array[int] of var $$E: Y) =
    let {
        array[int] of int: vals = [i | i in (dom_array(X) union dom_array(Y))]
    } in global_cardinality(X, vals) = global_cardinality(Y, vals);

Notably this predicate can be negated because it doesn't contain any free variables. All new variables (the resulting values of global_cardinality) are functionally defined. When negated, only the relation = has to be changed to !=.

In your model, we are not just considering full permutations, but rather partial permutations, and we use a dummy value otherwise. As such, the p predicate might also be written:

predicate p(array [int] of var 0..10: X, array [int] of var 1..10: Y) =
    let {
        set of int: vals = lb_array(Y)..ub_array(Y); % must not include dummy value
        array[vals] of var int: countY = global_cardinality(Y, [i | i in vals]);
        array[vals] of var int: countX = global_cardinality(X, [i | i in vals]);
    } in forall(i in vals) (countX[i] <= countY[i]);

Again this predicate does not contain any free variables, and can be negated. In this case, the forall can be changed into a exist with a negated body.


There are a few things that we can still do to optimise p for this use case. First, it seems that global_cardinality is only defined for variables, but since Y is guaranteed par, we can rewrite it and have the correct counts during MiniZinc's compilation. Second, it can be seen that lb_array(Y)..ub_array(Y) gives the tighest possible set. In your example, this means that only slightly different versions of the global cardinality function are evaluated, that could have been

predicate p(array [1..5] of var 0..10: X, array [1..5] of 1..10: Y) =
let {
    % CHANGE: Use declared values of Y to ensure CSE will reuse `global_cardinality` result values.
    set of int: vals = 1..10; % do not include dummy value
    % CHANGE: parameter evaluation of global_cardinality
    array[vals] of int: countY = [count(j in index_set(Y)) (i = Y[j]) | i in vals];
    array[vals] of var int: countX = global_cardinality(X, [i | i in 1..10]);
} in forall(i in vals) (countX[i] <= countY[i]);

Regarding the example. One approach might be to rewrite the not p(...) constraint to a specific not_p(...) constraint. But I'm how sure how that be formulated.

Here's an example but it's probably not correct:

predicate not_p(array [1..5] of var 0..10:arr1, array [1..5] of 1..10:arr2)=
  let{
  array [1..5] of var 1..5: mapping;
        constraint all_different(mapping)
  } in
  exists(i in 1..5)(
    arr1[i] != 0
    /\
    arr1[i] != arr2[mapping[i]]
   
);

This give 500 solutions such as

arr = [1, 0, 0, 0, 0];
----------
arr = [2, 0, 0, 0, 0];
----------
arr = [3, 0, 0, 0, 0];
...
----------
arr = [2, 0, 0, 3, 4];
----------
arr = [2, 0, 1, 3, 4];
----------
arr = [2, 1, 0, 3, 4];

Update I added not before the exists loop.

Related