Persistent CHR store

As the title says, I’d like to have a CHR store that handles all the UI information for a js frontend. As I use a pengine to communicate with prolog, I would like the CHR store to survive between calls.

As a hack, I could use a non backtrackable variable to store the content of the CHR store, and each time I have a question I could reload the constraints, ask the question, and save again the constraints in the non-backtrackable variable. I think this is more computational work than necessary, so is there an alternative?

Its been a while, but I think you can create a Pengine and ask it multiple queries. Isn’t that enough?

Hmm, I don’t think so, consider this scenario: in my server I have:

:- use_module(library(chr)).

:- chr_constraint sugar/0, water/0, sweet_water/0.
sugar, water <=> sweet_water.

add_sugar(Js) :-
    sugar,
    findall(X, find_chr_constraint(X), Xs),
    format(string(Js), 'console.log("~w")', [Xs]).

add_water(Js) :-
    water,
    findall(X, find_chr_constraint(X), Xs),
    format(string(Js), 'console.log("~w")', [Xs]).

:- use_module(pengine_sandbox:server).
sandbox:safe_primitive(server:add_sugar(_)).
sandbox:safe_primitive(server:add_water(_)).

Then, over the js side, I do two calls, add_sugar(Js) and add_water(Js) and eval the result (which causes the content of the chr constraint store to be logged).

But the first result logs [sugar] and the second [water] (and not [sweet_water]). That is because the CHR store is specific to each query, not each pengine. I’m trying to have a CHR store that persist between pengine queries, possibly without manually extracting the entire store each time like the hack that I described in the first post.

Does this make more sense now?

Edit: the trick of using :- set_prolog_flag(toplevel_mode, recursive). doesn’t seem to work for pengines (at least, I wrote it in the server file, should I have done something different?).

I want to detail better the ‘hack’ I mentioned in the first post, in the hope it clarifies my question.
I can do:

chr_ask(ChrStoreName, Input, NewStore) :-
    nb_getval(ChrStoreName, ChrStore),
    maplist(call, ChrStore),
    call(Input),
    findall(X, find_chr_constraint(X), NewStore),
    nb_setval(ChrStoreName, NewStore).

This will save every time the constraints in the chr store in a global non-backtracking variable, but at the expense of the linear computational complexity in serializing and deserializing the store each time. On the other hand, I can call this from a pengine now (if I just needed this behavior on the toplevel, I would have used the flag (toplevel_mode, recursive)).

Is there any better way of doing this @jan ? For example somehow getting a low level reference of a chr store?

Looking at pengines.pl, the problem is probably in solve/4. This collects solutions using findall/3 (actually findnsols/5), so we backtrack over solutions and (thus) the CHR store.

One solution is to run a loop that uses pengine I/O to get CHR queries and their replies. Another option might be to not use findnsols/5 if the chunk size is one. That might not be completely trivial as SWISH uses a varying chunk size (the 1,10,100 buttons) on the same query that starts with one.

You could try to wire that into solve/4 in pengines.pl. If the patch is fairly clean I’m happy to accept it. I’d start by just getting rid of the findnsols/5 and see whether it behaves as intended :slight_smile: I think it should as the Pengine main loop is recursive rather than backtracking.

1 Like

Thank you for the insight @jan!

You are right in saying that solve/4, and in particular the findnsols/4 solution in pengines.pl is the problem. Here’s how I confirmed it.

1) Trying to use once/1
First I though I could get around the problem with a clever use of once/1: I noted at a recursive repl that this invocation loses the CHR constraints (here sugar/0 is a CHR constraint):

findnsols(1, X, (sugar, member(X, [1,2])), Xs).

But this one keeps them:

once(findnsols(1, X, (sugar, member(X, [1,2])), Xs)).

Interestingly this one loses them again:

once(findnsols(1, X, (sugar, member(X, [1])), Xs)).

I think this behavior has to do with leaving choicepoints but I’d like to understand it better.

2) Changing findsols_no_empty/4 in pengines.pl
The most ergonomic way to change pengines.el seemed to rewrite the findsols_no_empty/4 predicate. I could get it working in 2 ways:

  • The naive one:
findnsols_no_empty(N, Template, Goal, List) :-
    call(Goal),
    List = [Template].
  • The one trying to use once/1 but adding a fake predicate to retain a choice point (?).
findnsols_no_empty(N, Template, Goal, List) :-
    once(findnsols(N, Template, (Goal, member(_, [1,2])), List)),
    List \== [].

I like the second solution you proposed more, but I’m not familiar with all the implication that changing this code could have for SWISH, so I would appreciate more guidance on the type of solution that you are envisioning here.

I discovered an even more bizarre solution. This version of findsols_no_empty works too:

findnsols_no_empty(N, Template, Goal, List) :-
    findnsols(N, Template, (Goal, member(_, [1,2])), List),
    List \== [].

The only thing that changed in pengines.pl is the addition of this fake member/2 goal, but I’m not even using once/2 anymore. Why does this happen?

I think the correct way to call this is probably this, although a normal disjunction may do as well.

    (  call(Goal)
   *->  List = [Goal]
    ;   List = []
    ).

The problem is that, in this scenario, you run the first goal like this, but then if the user wishes to get a large chunk in SWISH, you should switch to the findnsols trick. That seems tricky. I think impossible. It might be possible to redesign findnsols/5 such that it doesn’t backtrack over the last solution of the N solutions it returned.

Looking at the code, it actually seems to do so if it stops on reaching the next chunk without being deterministic. That suggests you may get the desired result using (Goal;fail), i.e., ensuring it does not complete deterministically.

I don’t think we want to change findnsols/5. As it, it cleans up on deterministic success and that is typically what you want. This seems a strange corner case where you are interested in the side effects on a global variable.

An alternative could be not to use findnsols/5 if there is no request to chunk and distinguish between chunk(1) and no chunking specified. Then we need to ensure (is probably true) that SWISH runs the first request using chunk(1).

Exactly as my ;fail: you force leaving a choicepoint. You should not force the goal to be det, as that tells findnsols/5 to complete the findall/3. You should ask it for one solution and then succeed with a choicepoint to make it believe it is not finished.

I’m not sure whether you can then close the query without loosing the CHR store after all. Not closing is not an option as you build up lots of queries. So, possibly we need to alternative after all. Possibly you are now sufficiently on track to figure it out?

So it seems to me that the situation is as follows:

This means that we can’t use tricks like (Goal; fail) because they will cause a memory leak.

We should leave findnsols/5 untouched because it has good cleanup properties in the general case.

And this is in fact the only solution remaining; but in the documentation for http_pengine_create the default chunk is already 1. Maybe we should have an explicit option chunk=once, or something to suggest that we care only about the first result?
Or even solutions=first to signal that we want a pengine that doesn’t backtrack?

Almost, if I’m correct on the above and we decide on the outward interface, but I’ll have more questions for sure :smile:

Looks like there are two routes. One would be to change chunk(_, [integer, default(1)]) into chunk(_, [integer, optional(true)]). I think that should be enough to cause the chunk(1) to be removed from the request. Then you can check in solve/4 whether or not there is a chunk(_) option. The other would indeed be to add a solutions(first) as alternative to all or chunked. I don’t know the full implications of either route. Needs some more reading of the source and probably some debugging.

Good luck :slight_smile:

@jan as I was testing the code I wrote for this I think I have discovered what could be a bug in the old code, and I’m asking here to see if what I think is correct.

Basically, in the old code (ie. the current version of pengines.pl), calling pengine.ask('whatever(X)', {template:'X', chunk:3}); in javascript never correctly sets the chunk to 3. It seems to me that the default is always set to 1 in fix_bindings/4 because Options1 is empty (it should contain chunk(3). Can you reproduce this? Am I going mad? :laughing:

Well, if I go into packages/pengines/examples and run

swipl server.pl
?- server(8000).

Then open the page and select As above using paginated results, it does the chunking just fine. Also the fact that it works in SWISH is encouraging.

If this doesn’t answer the question, please provide something we can just load and run. Sometimes weird details matter :frowning:

1 Like

Thank you for linking that, it made me find my error in the js I wrote!

Hey @jan, following our discussion last time I tried a couple of solution but then settled on the simplest one. I opened a PR so that I can ask further question and address any feedback that you might have. Thank you!

1 Like

Included your request for an explicit value :slight_smile: Merged. Thanks!