2

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 Answers2

3

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]);
Dekker1
  • 5,565
  • 25
  • 33
  • I admit that I'm not 100% sure about all this, and I've ran this only on your example, and smaller test cases. Let me know if anything doesn't seem right – Dekker1 Jun 14 '22 at 04:48
  • Thanks a lot for your answer! The real problem is a bit trickier, so I'm not sure I can directly use what you proposed but it sure gives me ideas. Namely, as I'm considering mapping between nodes, it is not sufficient to verify the value count, I also need to verify that the edges are mapped, and that mapped objects have the same values. As i am considering subisomorphisms, I'm afraid it's not enough to negate the predicate while locally constraining the mapping array(I'll detail in the next comment). – Julien Carayol Jun 14 '22 at 15:47
  • There is two stage of validity for the mapping, structural validity(two nodes are adjacent -> the two corresponding nodes are adjacents) and value-based validity(the attributes of mapped nodes are equals). I would like to verify that all structurally valid mappings, are not value-valid. And so, even if I can easily locally constrain the mapping to be structurally valid with your solution, I can't do the same thing(put the constraint in the 'let') for value-validity because if I do so not_p will be true if a mapping value-invalid is found, where i would like it to be true if [...] – Julien Carayol Jun 14 '22 at 15:57
  • [...] no mapping value-valid is found. If I understand correctly you mention that it would be slow, but possible to create a generator for mappings(where i could constrain it to be structurally valid to gain a bit of efficiency), and then verify for each of these that they are not value-valid. What do you think? How would you define that generator? – Julien Carayol Jun 14 '22 at 16:00
0

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.

hakank
  • 6,629
  • 1
  • 17
  • 27
  • Thanks for the answer, I reached the same conclusion but I'm not sure I can formulate the inverse of my predicate. Still in this exemple, won't the solver assume the constraint not_p to be satisfied as long as it finds values for the mapping such that `arr1[i] != 0 /\arr1[i] != arr2[mapping[i]]` ? I need to verify that this is true for every possible mapping – Julien Carayol Jun 13 '22 at 17:53
  • The let expression, if I understand correctly, is an implicit `exists`. inverting p to get not_p would require to transform that `exists` into a `forall`. But I wasn't able to define the array generator. Is it possible to write something along the lines of `for all mapping, exists(i)(arr1[i] != 0 /\ arr1[i] != arr2[mapping[i]])` ? – Julien Carayol Jun 13 '22 at 17:56
  • One important point is that you should think what (your original) `not p` constraint should do: What solutions should it accept/not accept? Also, I'm not sure what you mean by the `for all mapping`; what do you expect it does instead of the existing `exists(i in 1..5)` loop? – hakank Jun 13 '22 at 18:06
  • Also, should the `all_different` constraint be kept (or negated) in the `not_p`solution? – hakank Jun 13 '22 at 18:08
  • The all_different should be kept, I only consider bijective mappings – Julien Carayol Jun 13 '22 at 18:22
  • What i mean by 'for all mapping' is: for all possible bijection between the two arrays(enforced by the all_different), there exists an index i where `arr1[i] != 0 /\ arr1[i] != arr2[mapping[i]]` . The solution you propose enforces "there exists a mapping such that there exists an index such that `arr1[i] != 0 /\ arr1[i] != arr2[mapping[i]]`" – Julien Carayol Jun 13 '22 at 18:28
  • Can you give some examples for solutions that is not acceptable? – hakank Jun 13 '22 at 18:49
  • [0,0,0,0,0] would not be acceptable, it satisfies the 2 first calls of p(..) but also the third which should be evaluated to false. [1,1,2,2,3] is also not acceptable. There is no way to map these values to [1,2,3,4,5] because all elements are not there. – Julien Carayol Jun 13 '22 at 19:05
  • [1,1,2,2,3] is not a current solution (without the `not p` constraint). Would you mind give some examples that is now shown by the model but that should be ruled out by the `not p(arr,[1,2,3,5,6])` constraint? – hakank Jun 13 '22 at 19:32
  • By, the way, there is a hint `promise_total` that makes the flattening error goes away (and you can keep your original `not p(...)` constraint . See example here: https://www.minizinc.org/doc-2.6.3/en/predicates.html?highlight=promise_total However, just one solution is removed by this, namely `[0, 0, 0, 0, 0]`. – hakank Jun 13 '22 at 19:39
  • Apologies, the first three examples are wrong, they match [1,2,3,5,6]. for [1,0,0,0,0], the mapping [1,2,3,4,5] is valid(if a= [1,2,3,5,6], b=[1,0,0,0,0],and `for all i in 1..5 a=0\/a[mapping[i]]=b[i]`) – Julien Carayol Jun 13 '22 at 20:25
  • And i'm currently testing ::premise_total. It runs, thanks a lot! I'm verifying the solution and the rest of my code, I'm not sure about the solution returned and if it's related to the fix, or to the rest of my code – Julien Carayol Jun 13 '22 at 20:41
  • The domain of `arr` is `0..10` but no one of the solutions get a value of >= 5. Does that seems to be correct? – hakank Jun 13 '22 at 20:55
  • yes that is not surprising, values above 5 can't be mapped so they are not part of valid solutions. – Julien Carayol Jun 13 '22 at 21:18
  • And adding ::promise_total to the real problem returns invalid solutions, I am searching why. It returns only valid solutions when applied to the toy problem above – Julien Carayol Jun 13 '22 at 21:41