feat: implement extended nogood propagation and CPIP nogood learning#454
feat: implement extended nogood propagation and CPIP nogood learning#454ImkoMarijnissen wants to merge 59 commits into
Conversation
…ed UIP + adding some small docs
There was a problem hiding this comment.
I started doing an in-depth review but got lost, so I am pausing here for a moment.
My overall impression of this PR so far is that it is incredibly hard to understand what is going on. Both in the propagator and the resolver. This is because two very different analysis and propagation routines are merged into one. I understand that there would be a lot of duplication, but this is almost impossible to understand in a code review.
The main point I would like feedback on is whether it makes sense to keep the 1UIP + unit propagation and CPIP + extended nogood propagation approaches merged or whether it would be preferable to be separated into their own structs. I have kept it this way to be able to clearly see the differences between the two, but I can imagine that this does not make the code clearer.
Depending on the results of the experiment, I think we may simply want to remove the one we do not use... However, if we want to keep everything, we definitely want to find a different way to structure it. Perhaps we can do a zoom meeting at some point to talk about how we can do this?
Additionally, do we want to include testing this feature in the CI or is that unnecessary?
Yes, we do. Now we are not running it so I cannot see whether this works or not. I expect it to fail at least on test cases where we run the solver + processor + checker, as the new propagation rule is not covered by our unit propagation inference rule in the proof.
@maartenflippo I ran into the issue that retrieving from unit_nogood_inference_codes when using CPIP + extended nogood propagation led to some issues. I have resolved this in a way but it would be good to hear your opinion on this!
I think I found the place you talk about here. I left a comment, there as well, but I think it is not right. Yet, running with proof logging should clarify a lot.
| #[derive(Debug, Clone, Copy, Default)] | ||
| /// Determines which type of learning is performed by the [`ResolutionResolver`]. | ||
| pub enum AnalysisMode { | ||
| #[default] | ||
| /// Standard conflict analysis which returns as soon as the first unit implication point is | ||
| /// found (i.e. when a nogood is created which only contains a single predicate from the | ||
| /// current decision level) | ||
| OneUIP, | ||
| /// An alternative to 1-UIP which stops as soon as the learned nogood only creates decision | ||
| /// predicates. | ||
| AllDecision, | ||
| } |
There was a problem hiding this comment.
This I don't want to remove. The ConflictResolverType may have a bunch of other variants that have nothing to do with the resolution resolver (e.g. no-learning or the hypercube linear resolver).
| @@ -179,8 +177,87 @@ impl ResolutionResolver { | |||
| // level, and both will be decisions. This is accounted for below. | |||
| while { | |||
| match self.mode { | |||
There was a problem hiding this comment.
... can we not? 😅 Please, extract this into its own function or something...
On a serious note: Keeping the AnalysisMode here would be a good idea. We can give it an associated function keep_resolving(...) -> bool.
| | ConflictResolverType::BoundsExtendedCPIP => dec_level == context.get_checkpoint(), | ||
| ConflictResolverType::AllDecision => !context.is_decision_predicate(predicate), | ||
| ConflictResolverType::NoLearning => unreachable!(), | ||
| } { |
There was a problem hiding this comment.
This could also be a function on AnalysisMode
| #[allow(unused, reason = "Will be reintroduced with database management")] | ||
| handle: PropagatorHandle<NogoodPropagator>, | ||
|
|
||
| analysis_mode: ConflictResolverType, |
There was a problem hiding this comment.
I feel this should be a separate type here. Make it specific to what the variants are that this propagator supports, and remove any unreachable branches.
| impl Eq for Watcher {} | ||
|
|
||
| impl PartialEq for Watcher { | ||
| fn eq(&self, other: &Self) -> bool { | ||
| self.nogood_id.eq(&other.nogood_id) | ||
| } | ||
| } |
There was a problem hiding this comment.
I am afraid this could shoot us in the foot later on. In case we want to write e.g. tests around this, the equality behavior is not what we expect. Instead perhaps we can introduce a method on the Wathcer that tests for this type of equality instead?
| code: u64, | ||
| mut context: ExplanationContext, | ||
| ) -> LazyExplanation<'_> { | ||
| if (code >> 32) > 0 { |
There was a problem hiding this comment.
What is going on here? Can we make use of a bitstruct (like e.g. the element propagator) if we are packing fields into a u64?
| for predicate in nogood.iter().filter_map(|&predicate_id| { | ||
| // First, we filter out all of the predicates which are currently satisfied and | ||
| // which are not concerning the propagated domain id | ||
| let predicate = context.get_predicate(predicate_id); | ||
|
|
||
| is_falsified |= context.is_predicate_id_falsified(predicate_id); | ||
|
|
||
| pumpkin_assert_moderate!( | ||
| predicate.get_domain() == propagated_domain | ||
| || context.is_predicate_id_satisfied(predicate_id), | ||
| "Expected {predicate} to be unassigned with {propagated_domain:?}; nogood: {:?}", | ||
| nogood | ||
| .iter() | ||
| .map(|predicate_id| { | ||
| let predicate = context.get_predicate(*predicate_id); | ||
|
|
||
| ( | ||
| predicate, | ||
| context.lower_bound(&predicate.get_domain()), | ||
| context.upper_bound(&predicate.get_domain()), | ||
| ) | ||
| }) | ||
| .collect::<Vec<_>>() | ||
| ); | ||
|
|
||
| (!context.is_predicate_id_satisfied(predicate_id) | ||
| && predicate.get_domain() == propagated_domain) | ||
| .then_some(predicate) | ||
| }) { |
There was a problem hiding this comment.
Please pull this out into a named binding
| let unit_ic = unit_nogood_inference_codes.get(&predicate).or_else(|| { | ||
| // It could be the case that we attempt to get the reason for the predicate | ||
| // [x >= v] but that the corresponding unit nogood idea is the one for the | ||
| // predicate [x == v] | ||
| let domain_id = predicate.get_domain(); | ||
| let right_hand_side = predicate.get_right_hand_side(); | ||
|
|
||
| unit_nogood_inference_codes.get(&predicate!(domain_id == right_hand_side)) | ||
| }); | ||
|
|
||
| if let Some(unit_ic) = unit_ic { | ||
| let _ = proof_log.log_inference( | ||
| &mut state.constraint_tags, | ||
| unit_ic.clone(), | ||
| [], | ||
| Some(predicate), | ||
| &state.variable_names, | ||
| &state.assignments, | ||
| ); | ||
| } else { | ||
| // Otherwise we log the inference which was used to derive the nogood | ||
| let _ = proof_log.log_inference( | ||
| &mut state.constraint_tags, | ||
| ic.clone(), | ||
| reason_buffer.as_ref().iter().copied(), | ||
| Some(predicate), | ||
| &state.variable_names, | ||
| &state.assignments, | ||
| ); | ||
| } |
There was a problem hiding this comment.
I doubt whether this is correct, but running with proof checking on should make that obvious.
There was a problem hiding this comment.
Interestingly, running the test suite with the extended nogood propagation and CPIP passes all test cases after this change; any idea what could be going wrong? Should I try to create the proof and process it manually?
…ion can take place
…d for calculating info about domain
|
After discussion with @maartenflippo:
|
… extended nogood propagation
|
What is the status of this, is it ready for review/finalised? |
@EmirDe It should indeed be ready to be reviewed. I need to discuss with Maarten how this interacts with proof logging, but the functionality should be there. |
Based on the paper "From Literals to Atomic Constraints: Generalising Conflict-Driven Clause Learning for Constraint Programming - Imko Marijnissen, Maarten Flippo, and Emir Demirović" (to appear at CP'26), I have implemented the extended nogood propagation and CPIP nogood learning.
Overview
The PR consists of the following:
ResolutionResolverto be able to produce CPIP nogoods; this involves updating the stopping criterion for resolving and the structureLearnedNogoodto ensure certain invariants.NogoodPropagatorin several ways:This supersedes the branch
feat/extended-conflict-analysisin several ways:Feedback
The main point I would like feedback on is whether it makes sense to keep the 1UIP + unit propagation and CPIP + extended nogood propagation approaches merged or whether it would be preferable to be separated into their own structs. I have kept it this way to be able to clearly see the differences between the two, but I can imagine that this does not make the code clearer.
Additionally, do we want to include testing this feature in the CI or is that unnecessary?
@maartenflippo I ran into the issue that retrieving from
unit_nogood_inference_codeswhen using CPIP + extended nogood propagation led to some issues. I have resolved this in a way but it would be good to hear your opinion on this!Experimentation
I tested the extended nogood propagation + CPIP learning using different priority schemes. The
(Updated)instances are the ones where incremental stopping condition calculation is included.Overall Results
1UIP + High Priority:CPIP with Extended Nogood Propagation + High Priority:CPIP with Extended Nogood Propagation + High Priority (Updated):1UIP + Very Low Priority:CPIP with Extended Nogood Propagation + Very Low Priority:CPIP with Extended Nogood Propagation + Very Low Priority (Updated):MiniZinc Scoring
Average Primal Integral
Overall Conclusions
In terms of the number of instances solved, 1UIP with a Very Low Priority appears to be best. However, looking at the MiniZinc scoring, both of the CPIP approaches outperform their 1UIP counterparts, with CPIP + Very Low priority performing the best. For the primal integral, the results are more mixed, with CPIP + Very Low Priority having the best anytime performance. This appears to indicate that CPIP has some better anytime performance compared to 1UIP learning (when using a very low priority of the nogood propagator). I think it would be better to keep the 1UIP as the default since I would expect 1UIP to outperform when using free search.
After Update: It appears that while
1UIP + Very Low Priorityis generally the best at proving optimality,CPIP + Very Low Priority (Updated)proves optimality on a similar number of instances while providing solutions on 16 more. Additionally, the MiniZinc score of this approach is significantly higher, and the primal integral is significantly lower.TODO