Search code examples
droolsoptaplanner

accumulate with total counted two times


I have three classes in Java: Product, Sale, and ProductType. The use case is the count of sales of all products by type.

Example:

  • total sales of Product type A : 500 €
  • total sales of Product type B : 500 €

In my data source (Optaplanner solution), I have many products of type A and type B. I wrote a constraint in drools with accumulate function which returns a right total by type but counted two times (each product instance).

Here is the drools constraint:

    rule "products sales"
        when
           $product : Product( )
           $total : Integer() from
                 accumulate ( Product($sales: sales, productType == $product.productType) ,
                 init( int total =0;),
                 action(total+=($sales.getPrice());),
                 result( new Integer (total)))

        then
             System.out.println(
                  " Product : "+ $product.getName() +
                  " \nProduct Type : " + $productType +
                  " \nTotal sales : " + $total +
                  " \nPenalty : " + $product.getSale().totalSellsOfProductPenalty($total)
                             );
           scoreHolder.penalize(kcontext, $product.getSale().totalSellsOfProductPenalty($total));
    end

And the result:

 Product : MyPRODUCT_A 
Product Type : A 
Total sales : 120 
Penalty : -60
 Product : MyPRODUCT_A 
Product Type : A 
Total sales : 120 
Penalty : -60
 Subject : MyPRODUCT_B 
Product Type : B
Total sales : 1200
Penalty : -600

=> penalty : -720

Wanted result:

 Product : MyPRODUCT_A 
Product Type : A 
Total sales : 120 
Penalty : -60
 Subject : MyPRODUCT_B 
Product Type : B
Total sales : 1200
Penalty : -600

=> penalty : -660

How can I solve this?


Solution

  • The problem here is not with your rule -- it's very good, you've used the 'accumulate' function properly (which many people have problems with, myself included), and it does what you ask it to.

    The problem is with the data you're passing into the rules. Basically, the way your rule is structured it's going to fire once per Product. So basically what you're seeing is a situation where you have two MyPRODUCT_A and one MyPRODUCT_B present in working memory. The rule fires once for each item, so you get two outputs for MyPRODUCT_A and one output for MyPRODUCT_B.

    There's a couple ways to handle this, the easiest of which is to put a "flag" into working memory to signal that you've already fired the rule for a given Product. Another solution might be to retract all of that product from working memory. There are others ways to solve this, but these are the simplest ones that I could come up with off the top of my head.

    (Note that you do not have a looping situation here. Your rule isn't looping; it's just firing more than you want it to. Solutions like no-loop will do you no good.)


    Solution 1: a flag in working memory

    This is generally the easiest solution. Basically, every time you trigger a rule for a given product, you add a flag to working memory. Then the next time the rule triggers, it will check that there is not a flag for the current product before firing the rule.

    In this example, I'm assuming that you want to keep the product name unique, so I'm going to insert the product name into working memory. You should adjust based on your actual requirements, use case, and model.

    rule "products sales"
    when
      // Get the name of the current product ($name)
      $product: Product( $name: name )
    
      // Check that there is no flag for $name
      not( String(this == $name) )
    
      // Accumulate as in original
      $total: Integer() from
              accumulate ( Product($sales: sales, productType == $product.productType) ,
                init( int total =0;),
                action(total+=($sales.getPrice());),
                result( new Integer (total)))
    then
      System.out.println(
        " Product : "+ $product.getName() +
        " \nProduct Type : " + $productType +
        " \nTotal sales : " + $total +
        " \nPenalty : " + $product.getSale().totalSellsOfProductPenalty($total)
      );
      scoreHolder.penalize(kcontext, $product.getSale().totalSellsOfProductPenalty($total));
    
      // Insert the flag
      insert($name);
    end
    

    Basically after each product triggers the accumulate rule, its name is entered into working memory. If the product's name is in working memory at the time it triggers the rule, it won't fire. This means that each product that does trigger the rule must have a unique name; duplicates are ignored.

    The insert operation adds a new piece of information into working memory, but does not re-trigger previously run rules. So if a rule previously fired, it's already over and done with. Subsequent matches will now be aware of the new fact, however.

    There is a very similar operation called update. This other operation will refire all rules, and re-evaluate all conditions. Once you start calling update, you start running into potential issues with looping rules across multiple executions. It is 99% likely that you don't want to be doing that here.

    The downside to this approach is generally the same as with most situations where you have "flags" or other sorts of semaphores -- the number of items you're juggling can get out of control. If you have a handful of products being evaluated at once, the overhead is pretty low. But imagine you have millions of millions of products running through your system at once -- potentially you could have a crazy number of Strings in working memory. This could cause resource issues with your application and hardware as well. (There is a critical point at which you should start interning your strings, which is out of scope of this answer.)

    But for a simple solution, this one works "quick and dirty".


    Solution 2: retract the items

    This solution will only work if you don't need the Products in working memory for anything else. That is, once MyPRODUCT_A triggers your rule, you don't need any MyPRODUCT_A anymore. In this case, we can just remove all MyPRODUCT_A from working memory as soon as one triggers the rule.

    In the example rule, I'm once again going to collect all of the Products with the same name value, but you can update as needed based on your use case and model.

    rule "products sales"
    when
      // Get the name of the current product ($name)
      $product: Product( $name: name )
    
      // Accumulate as in original
      $total: Integer() from
              accumulate ( Product($sales: sales, productType == $product.productType) ,
                init( int total =0;),
                action(total+=($sales.getPrice());),
                result( new Integer (total)))
    
      // Collect all of the products with the same name
      $duplicates: List() from collect( Product( name == $name ) )
    then
      System.out.println(
        " Product : "+ $product.getName() +
        " \nProduct Type : " + $productType +
        " \nTotal sales : " + $total +
        " \nPenalty : " + $product.getSale().totalSellsOfProductPenalty($total)
      );
      scoreHolder.penalize(kcontext, $product.getSale().totalSellsOfProductPenalty($total));
    
      // Remove all of the products with the same name
      for (Product product : $duplicates) {
        retract(product);
      }
    end
    

    (Note that retract and delete do the same thing. They're currently pushing for adoption of 'delete' as an opposite of 'insert' but us old Drools users from back-in-the-day favor to 'retract'.)

    So basically what you'll see is the first instance of MyPRODUCT_A hit the rule, log, and then it will remove all other instances of MyPRODUCT_A (and itself) from working memory. Then MyPRODUCT_B will hit the rule, and do the same. Eventually there will be no Product left in working memory.

    Obviously, if you still need to do other stuff with Product, this isn't a viable solution since those other rules can't fire if the Products are removed from memory. But if you have a very specialized data set that is only doing this one thing, you can go this route.


    As I said, there are many possible solutions to this problem. Your rule, as-is, is good. It's correctly firing for every product. How to have it fire only for a subset of products is the problem you're facing, but how exactly you address it is up to you and your requirements.