Skip to content

feat(pumpkin-solver): Implement consistency checker infrastructure#449

Draft
maartenflippo wants to merge 23 commits into
mainfrom
feat/consistency-checkers
Draft

feat(pumpkin-solver): Implement consistency checker infrastructure#449
maartenflippo wants to merge 23 commits into
mainfrom
feat/consistency-checkers

Conversation

@maartenflippo
Copy link
Copy Markdown
Contributor

@maartenflippo maartenflippo commented May 20, 2026

A proper implementation of consistency checkers (see #383), with lessons learned from #374.

TODO

Comment thread pumpkin-crates/core/src/checkers/store.rs Outdated
Comment thread pumpkin-crates/core/src/checkers/store.rs
Comment thread pumpkin-crates/core/src/checkers/strong_retention_checker.rs
Comment thread pumpkin-crates/core/src/checkers/support.rs
Comment thread pumpkin-crates/core/src/checkers/support.rs
Comment thread pumpkin-crates/core/src/propagation/constructor.rs
Comment thread pumpkin-crates/core/src/propagation/constructor.rs
Comment thread pumpkin-crates/core/src/propagation/constructor.rs Outdated
Comment thread pumpkin-crates/propagators/src/propagators/arithmetic/multiplication/checker.rs Outdated
Comment thread pumpkin-crates/core/src/checkers/mod.rs
@EmirDe
Copy link
Copy Markdown
Contributor

EmirDe commented May 25, 2026

Following up on our discussion from Friday:

(this is a long post, and some of the things are interconnected. It might be best to discuss it instead of text.)

Support generator

Conceptually, it would make sense that propagators generate propagation/retention certificates, since the propagator is to prove that it is working correctly.

Currently, the propagator generates propagation certificates as explanations, good. But retention certificates are attached to inference checkers through the support generator trait.

I think we could have inference checkers only do inference checking, and leave the retention certificates to the propagator. This also makes sense since the propagator may use some internal data structures / algorithms to construct the certificates. But in any case, generating retention certificates should be an immutable operation so the trace does not change.

Interface for inference and retention checkers

I like the interface for inference checkers, because it is reasonably decoupled from the solver, e.g., it has its own variables as input to which the solver translates to.

I think the retention checker could use a similar interface. Now the retention checkers rely on the internal solver details through the scope and local ids, but probably they could use the same as the inference checkers. This way we could have the retention checkers more decoupled from the solver.

Reified propagator wrappings and different way of registering variables for propagators

I understand that we have special consideration for the reified propagator, e.g., the constructor context has pending_inference_checkers because of the reified propagator.

Is the main problem that we do not know how many local ids will the underlying propagator make, which makes it difficult for the generic reified propagator to understand which local id it will take?

I think we could consider a slightly different way of registering variables for propagators, which could solve this issue.

Currently we ask propagators to, within the constructor, call "context.register". Instead, maybe we could do the following: as part of the return value of the propagator constructor, we return the propagator AND a mapping 'variable -> (local id, domain_event)'. Essentially, the propagator constructor tells the solver how it renamed the variables locally. The solver then registers the variables for watching after creating the propagator.

This might be a bit cleaner, and it will be harder to forget to register variables, since registering variables is part of the output of the create function. It would also help with the reified propagator situation, since then we would not need to wrap checkers (but see point below, very related), simply the create function will be called of the underlying propagator, and then the reified propagator can process that and see which local id it needs to select for the reified variable.

Creating propagators together with checkers

In the current design, the user is responsible for calling "add checkers" functions, and there is a lot of flexibility. I am thinking we might be able to streamline this a bit.

Ideally, the propagator constructor returns an initialised propagator, but also returns inference checker(s) and a retention checker, e.g., (propagator, list of inference checkers, list of retention checkers, possibly also a mapping from variables to local ids and their domain events). This way it is clearer that checkers are core parts, and not optional. Now this seems to be difficult to do fully, but maybe we can still come up with something in this direction, see below.

First observation is that perhaps the propagator and both checkers should take as input the PropagatorArgs struct. Conceptually this makes sense, since those input define what the propagator is, which is clearly what the checkers care about.

Being sure the correct number of checkers is returned: We discussed it, but after looking through the code again, I get the impression that each InferenceCode must have exactly one inference checker. We could ask the propagator to request inference codes from the context (instead of having propagators be able to make inference codes themselves), and this way we would know exactly how many checkers the propagator needs to return.

Specifying the retention checker: the propagator specifies the checker using an enum, which has the types (that may have additional fields) to specify which type of consistency is guaranteed (domain/bounds weak/regular consistency, or custom). This is a bit tricky because I am not entirely sure how to define consistency for a propagator if it can have multiple inferences or constraints.

Should propagators define consistency for each individual variable? Say these variables are going to be bound-consistent, and these domain-consistent?

Is it a fair assumption that a propagator has one consistency type per constraint tag it uses? This seems reasonable to me for bound/domain consistent propagators. For weakly consistent ones, it seems to me that retention consistency is actually defined on the level of an inference code and not on a constraint.

Assuming one retention per constraint tag, the propagator specifies the consistency as an enum with several types (with each type having its own fields). These enum types dictate how feasible solutions are going to checked (using one of the inference checkers) or how the infeasibility function will be used to check consistency (see below for infeasibility function). And then there is a fifth option ‘custom’ which then allows for any type of consistency checking that we have not yet covered with the ‘standard’ options.

Infeasibility function interface

For weakly consistent propagators, we use the infeasibility function to check whether it is consistent. Since we already learned that this can be a major bottleneck, then we can design the interface to allow the option for more efficient checking.

The infeasibility function could have this type of workflow (functions):
“Initialisation” function: it takes as input variables and their domains (like our inference checkers do now). The function can do some precomputation to make checking easier, or it can keep this as simple as copying the domains.
For cumulative, this could mean computing the timetable. For circuit, it could mean colouring nodes in a way that nodes within the same chain have the same colour.
“Is this value weakly supported for this variable?” function. Returns true or false.
For cumulative, since the timetable is computed, it is easy to check consistency. For circuit, simply checks if the edge is between the same colour of nodes.

(The alternative to the above is to have a checker with no initialisation apart from copying the domain, so then checking needs to recompute things. This is more trustworthy but too slow potentially.

The solver would simply initialise the function, and ask for each relevant value.

Contexts for checkers?

Minor point: do we want checkers to have "contexts" instead of receiving a list of arguments? We use this design everywhere so I am wondering if we should also use it for checkers.

@maartenflippo
Copy link
Copy Markdown
Contributor Author

maartenflippo commented May 26, 2026

Support generator

I think we could have inference checkers only do inference checking, and leave the retention certificates to the propagator. This also makes sense since the propagator may use some internal data structures / algorithms to construct the certificates. But in any case, generating retention certificates should be an immutable operation so the trace does not change.

This could also work. As long as something implements the SupportGenerator trait, it can work. So we can have the propagator itself implement it, and pass a cloned version of itself as a support generator. Right now you are right the PropagatorChecker implements both the inference checker and support generator (or the more general retention checker trait).

Interface for inference and retention checkers

I like the interface for inference checkers, because it is reasonably decoupled from the solver, e.g., it has its own variables as input to which the solver translates to.

I think the retention checker could use a similar interface. Now the retention checkers rely on the internal solver details through the scope and local ids, but probably they could use the same as the inference checkers. This way we could have the retention checkers more decoupled from the solver.

We could extract a trimmed-down version of our ReadDomains trait here. It exposes methods such as lower_bound(var), upper_bound(var), etc. The retention checker can then receive an implementation of that trait, thereby decoupling our solver state from the library.

The only thing that is not clear to me is how we would deal with views. I suppose the CheckerVariable trait already handles some of that. So we may be able to expand that interface.

In general, I think it would be amazing to extract the retention checkers into a separate library. Just as with everything I do in pumpkin, I want the code to be good and re-usable, which is no different here. However, at the moment I do have to make choices with my time, and as I raised in the meeting as well, especially in the next two months I feel that extracting the retention checkers would just be one of the lowest priority things for me. That means we could end up blocking this PR, since it would require an interface change. So we would have to discuss what the pragmatic way forward is on this.

Reified propagator wrappings and different way of registering variables for propagators

Currently we ask propagators to, within the constructor, call "context.register". Instead, maybe we could do the following: as part of the return value of the propagator constructor, we return the propagator AND a mapping 'variable -> (local id, domain_event)'. Essentially, the propagator constructor tells the solver how it renamed the variables locally. The solver then registers the variables for watching after creating the propagator.

I suppose this can work as well. For e.g. the nogood propagator, which does not register anything in the constructor, this mapping can be empty. I agree it is also much more composable than the interface we have now. For this I would suggest doing a separate PR first, though. It would be better to review this change in isolation.

Being sure the correct number of checkers is returned: We discussed it, but after looking through the code again, I get the impression that each InferenceCode must have exactly one inference checker.

This is not true. I remind you of the example where a clausal encoding of a constraint that introduces $n$ clauses will create $n$ inference checkers with the same inference code. Currently, we accept an inference if there exists a checker among those $n$ that accepts it. Another example of this is the way we model $Ax = b$ constraints as $Ax \le b \land Ax \ge b$, which introduces two checkers using the same inference code.

Upon reflection, I suppose it is true for the inference checkers in one propagator during the constructor. In the example of the clausal encoding, the inference checkers are created when the constraint is added to the propagator, but the constructor for the clausal propagator does not register any. Similarly, in the example of $Ax = b$, each linear inequality propagator returns one inference checker for one inference code. In the solver there may be multiple checkers per inference code.

We could ask the propagator to request inference codes from the context (instead of having propagators be able to make inference codes themselves)

We actually used to have this design. Yet we moved away from it because we gained little from it, whilst making it difficult to debug (due to the indirection), and we had to always carry the mapping inference_code -> (constraint_id, label) everywhere.

Perhaps we will land on a preferable API that would benefit from from requesting inference codes, in which case we can revert the change. One benefit immediately is that the codes would implement Copy again, making them easier to work with (now they only implement Clone).

Creating propagators together with checkers

Is it a fair assumption that a propagator has one consistency type per constraint tag it uses? This seems reasonable to me for bound/domain consistent propagators. For weakly consistent ones, it seems to me that retention consistency is actually defined on the level of an inference code and not on a constraint.

I think this is not entirely true. E.g. if we post a time-table retention checker and an edge-finding retention checker for cumulative, even though the reasoning is not comparable, at fixed point both would need to accept the domains as they are for the consistency check to pass.

But you are right that it means there is a many to one relationship between consistency checkers and constraint tags, which is already supported to handle decompositions.

These enum types dictate how feasible solutions are going to checked (using one of the inference checkers) or how the infeasibility function will be used to check consistency (see below for infeasibility function)

I do not see what the enum version gives us that is not already afforded by the current design. The enum variants will just be implementations of the RetentionChecker trait. And now there is no need for a Custom variant, since all retention checkers are equal.

Infeasibility function interface

The infeasibility function could have this type of workflow (functions):
“Initialisation” function: it takes as input variables and their domains (like our inference checkers do now). The function can do some precomputation to make checking easier, or it can keep this as simple as copying the domains.

This is a great idea. I assume you intend that we call the initialization function once per fixed point, but that state can then be re-used for subsequent calls to determine whether a value is supported?

The alternative to the above is to have a checker with no initialisation apart from copying the domain, so then checking needs to recompute things. This is more trustworthy but too slow potentially.

I would assume this separation is worth it, since we know from experience that it is still important to get somewhat deep into the search tree to be confident in the propagators.

Contexts for checkers?

Minor point: do we want checkers to have "contexts" instead of receiving a list of arguments? We use this design everywhere so I am wondering if we should also use it for checkers.

I personally don't think so. The checkers take only few arguments, which means it is much clearer to spell those arguments out. The contexts make sense where we need many arguments (e.g. the resolution context combines so many fields that they would be impractical as individual arguments).

Copy link
Copy Markdown
Contributor

@ImkoMarijnissen ImkoMarijnissen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About halfway through, but these are some intermediate comments.

Overall, I like the interface, though I think it is difficult to understand all of the different types (which is unavoidable with how general we want it to be). Perhaps we should add a dedicated page to all the different types in the checkers and how they interact with one another.

use crate::checkers::Scope;
use crate::propagation::Domains;

/// A runtime verifier that determines whether domains are sufficiently pruned.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would argue that this is a bit too vague

Comment on lines +20 to +24
impl Clone for BoxedRetentionChecker {
fn clone(&self) -> Self {
BoxedRetentionChecker(dyn_clone::clone_box(&*self.0))
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't this be implemented using the dyn_clone::clone_trait_object! macro?

Comment on lines +59 to +60
impl_scope_from_tuple!(la,va: VA, lb,vb: VB);
impl_scope_from_tuple!(la,va: VA, lb,vb: VB, lc,vc: VC);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a small comment what this is doing; spacing is off as well

impl_scope_from_tuple!(la,va: VA, lb,vb: VB);
impl_scope_from_tuple!(la,va: VA, lb,vb: VB, lc,vc: VC);

pub trait ScopeItem {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs

Comment on lines +19 to +20
/// The checkers to run the next time.
queue: Vec<CheckerId>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like we do not need this field when we have enqueued? Is it just to prevent the iteration complexity of a bitset?

// The support was incomplete or not a solution. Either way, the
// consistency check fails.
return false;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is one example where it would be nice to have either a Result with an error enum or just an enum to provide this info directly

let lb = domains.lower_bound(&domain) as f32;
let ub = domains.upper_bound(&domain) as f32;

lb <= *self && *self <= ub
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could suffer from floating point inaccuracies; should we use f32::EPSILON for this comparison (similar to np.isclose)?

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct UnsupportedValue(pub(crate) i32);

/// Implementors know how to unpack an [`UnsupportedValue`].
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rewrite this documentation, it is not clear what the trait itself represents

}
}

/// A domain value that needs to be unpacked through [`UnpackUnsupportedValue::unpack`].
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could add information about what it means to be unsupported

Comment on lines +40 to +56
impl<Key> Clone for KeyedBitSet<Key> {
fn clone(&self) -> Self {
Self {
bitset: self.bitset.clone(),
key: PhantomData,
}
}
}

impl<Key> Default for KeyedBitSet<Key> {
fn default() -> Self {
Self {
bitset: BitSet::default(),
key: PhantomData,
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just out of curiosity; these could not be derived?

@maartenflippo
Copy link
Copy Markdown
Contributor Author

maartenflippo commented May 27, 2026

Summary of the meeting with @EmirDe and @ImkoMarijnissen:

Most of the interface changes proposed by @EmirDe are good, and we will make the necessary changes. A few things of note:

  • I will try to figure out a design where propagators can implement the SupportGenerator interface. It could be beneficial to access the propagator state when creating supports. In addition, the retention checker (and support generator as well) need to receive the constraint ID to allow compound propagators to identify which constraint they are checking.
  • Extracting the retention checkers into the checking crate has the lowest priority. If it is easy, great. Otherwise, I will create an issue to document that it is a goal, but it will have to be picked up in the future.

Sketch of part of the design:

/// Pumpkin

trait Propagator:
    fn propagate(...):

trait PropagatorConstructor:
    fn create(...) -> (impl Propagator, Mapping):
        let mapping = Mapping::new()
            .add(localid1, &self.a)
            .add(localid1, &self.b)
            .build()
        

/// Checking lib

struct LocalId(...);

trait Domains:

trait SupportGenerator<D>:
    fn support(&self, local_id, value, constraint_tag, ctx: D) -> Support<>:
        // hello

DomainConssitency<D>:
    fn check(generator: &impl SupportGen, ctx: D):
        for all vars
            for all values
                support_gen give me a support for var = val

commit b95ad41
Author: Maarten Flippo <maartenflippo@outlook.com>
Date:   Wed May 27 23:29:26 2026 +1000

    Also update the proof processor

commit b456892
Author: Maarten Flippo <maartenflippo@outlook.com>
Date:   Wed May 27 23:18:14 2026 +1000

    Implement the new interface in the rest of pumpkin

commit c5290b1
Author: Maarten Flippo <maartenflippo@outlook.com>
Date:   Wed May 27 22:46:16 2026 +1000

    Remove Watchers::watch_all in favor of event target

commit 27ed751
Author: Maarten Flippo <maartenflippo@outlook.com>
Date:   Wed May 27 22:28:36 2026 +1000

    Do the registration in the state

commit 4dd4222
Author: Maarten Flippo <maartenflippo@outlook.com>
Date:   Wed May 27 22:26:41 2026 +1000

    Remove registration from PropagatorConstructorContext

commit 39f97fd
Author: Maarten Flippo <maartenflippo@outlook.com>
Date:   Wed May 27 22:09:21 2026 +1000

    PropagatatorConstructor::create now returns an EventRegistration

commit 7777f42
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Sun May 24 12:56:42 2026 +1000

    chore(deps): bump enumset from 1.1.12 to 1.1.13 (#452)

    Bumps [enumset](https://github.com/Lymia/enumset) from 1.1.12 to 1.1.13.
    <details>
    <summary>Changelog</summary>
    <p><em>Sourced from <a
    href="https://github.com/Lymia/enumset/blob/main/RELEASES.md">enumset's
    changelog</a>.</em></p>
    <blockquote>
    <h1>Version 1.1.13 (2026-05-18)</h1>
    <ul>
    <li>Revert a semver breaking change introduced in 1.1.12 relating to
    using
    functions like <code>EnumSet&lt;T&gt;.symmetric_difference</code> with
    <code>Enum::A.into()</code>.</li>
    <li>Deprecate <code>EnumSet&lt;T&gt;.symmetrical_difference</code> in
    favor of
    <code>symmetric_difference</code>, to better match the standard library
    sets.</li>
    </ul>
    </blockquote>
    </details>
    <details>
    <summary>Commits</summary>
    <ul>
    <li><a
    href="https://github.com/Lymia/enumset/commit/3ccc5704ec01c3d269852045577511ac21b06ff4"><code>3ccc570</code></a>
    Bump version to 1.1.13</li>
    <li><a
    href="https://github.com/Lymia/enumset/commit/fec58cc76ded5a1a050544abdd7f5e73cab1b11a"><code>fec58cc</code></a>
    Bless new trybuild output.</li>
    <li><a
    href="https://github.com/Lymia/enumset/commit/2bc3757fa104e13ce8388de93d48795a6f2be44b"><code>2bc3757</code></a>
    Deprecate symmetrical_difference in favor of symmetric_difference</li>
    <li><a
    href="https://github.com/Lymia/enumset/commit/9cfdb1d6736e4e4c9bd4482988d27a6f046ac8fa"><code>9cfdb1d</code></a>
    Fix accidental breaking change in the signatures of e.g.
    EnumSet.is_disjoint</li>
    <li><a
    href="https://github.com/Lymia/enumset/commit/117824aa804d883145c14101e4342885b6445ed7"><code>117824a</code></a>
    Clarify the format of the bit indexes returned.</li>
    <li>See full diff in <a
    href="https://github.com/Lymia/enumset/compare/v1.1.12...v1.1.13">compare
    view</a></li>
    </ul>
    </details>
    <br />

    [![Dependabot compatibility
    score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=enumset&package-manager=cargo&previous-version=1.1.12&new-version=1.1.13)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

    Dependabot will resolve any conflicts with this PR as long as you don't
    alter it yourself. You can also trigger a rebase manually by commenting
    `@dependabot rebase`.

    [//]: # (dependabot-automerge-start)
    [//]: # (dependabot-automerge-end)

    ---

    <details>
    <summary>Dependabot commands and options</summary>
    <br />

    You can trigger Dependabot actions by commenting on this PR:
    - `@dependabot rebase` will rebase this PR
    - `@dependabot recreate` will recreate this PR, overwriting any edits
    that have been made to it
    - `@dependabot show <dependency name> ignore conditions` will show all
    of the ignore conditions of the specified dependency
    - `@dependabot ignore this major version` will close this PR and stop
    Dependabot creating any more for this major version (unless you reopen
    the PR or upgrade to it yourself)
    - `@dependabot ignore this minor version` will close this PR and stop
    Dependabot creating any more for this minor version (unless you reopen
    the PR or upgrade to it yourself)
    - `@dependabot ignore this dependency` will close this PR and stop
    Dependabot creating any more for this dependency (unless you reopen the
    PR or upgrade to it yourself)

    </details>

    ---------

    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    Co-authored-by: Maarten Flippo <maartenflippo@outlook.com>

commit dfbdff1
Author: Imko Marijnissen <50290518+ImkoMarijnissen@users.noreply.github.com>
Date:   Sun May 24 04:54:59 2026 +0200

    fix: README links + updating component overview (#453)

commit bbfd857
Author: Imko Marijnissen <50290518+ImkoMarijnissen@users.noreply.github.com>
Date:   Thu May 21 09:38:47 2026 +0200

    chore: avoid rebuilding unnecessarily (#450)

    #438 introduced a `rerun-if-changed` based on git directories. However,
    this causes rebuilding even if nothing has changed. I propose to remove
    these lines (i.e., to go back to the rebuilding strategy before #438).

commit 7425839
Author: Imko Marijnissen <50290518+ImkoMarijnissen@users.noreply.github.com>
Date:   Thu May 21 09:33:32 2026 +0200

    fix: disambiguate .msc files (#451)

    Currently, we have two separate
    [`.msc`](https://docs.minizinc.dev/en/stable/fzn-spec.html#solver-configuration-files)
    files: one for "default" Pumpkin and another for Pumpkin with proof
    logging.

    However, there is a name clash when using these two approaches, making
    it unclear which library is in use. This PR fixes this by changing the
    name in the `pumpkin-for-proofs.msc` file. It will then show up as
    follows in the listing:
     ```
      Pumpkin 0.1 (nl.tudelft.algorithmics.pumpkin, cp, lcg, int)
      PumpkinProof 0.1 (nl.tudelft.algorithmics.pumpkin, cp, lcg, int)
     ```
     We could also consider moving it to a separate folder.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants