From 2352e97647ab6efccaf264297baa41312a24699a Mon Sep 17 00:00:00 2001 From: Charles Prud'homme Date: Thu, 1 Oct 2026 15:50:50 +0200 Subject: [PATCH 1/6] Freeze tuples and diagrams; fix FiniteAutomaton.clone - Tuples, HybridTuples and MultivaluedDecisionDiagram can be frozen: once frozen, they cannot be modified anymore and can be safely shared among models solved concurrently. - Tuples.toMatrix() no longer returns null when its soft cache has been cleared by the GC, and supports concurrent calls. - FiniteAutomaton.clone() no longer shares its working buffer with the original and keeps the determinism flag. Co-Authored-By: Claude Opus 5.5 --- .../solver/constraints/extension/Tuples.java | 42 +++++++++++++++++-- .../extension/hybrid/HybridTuples.java | 27 ++++++++++++ .../nary/automata/FA/FiniteAutomaton.java | 5 ++- .../graphs/MultivaluedDecisionDiagram.java | 28 +++++++++++++ 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/solver/src/main/java/org/chocosolver/solver/constraints/extension/Tuples.java b/solver/src/main/java/org/chocosolver/solver/constraints/extension/Tuples.java index 617968f8e2..9b03f82c83 100644 --- a/solver/src/main/java/org/chocosolver/solver/constraints/extension/Tuples.java +++ b/solver/src/main/java/org/chocosolver/solver/constraints/extension/Tuples.java @@ -34,6 +34,10 @@ public class Tuples { private final boolean allowStar; private final int star; private SoftReference cachedMatrix; + /** + * Once frozen, the tuples cannot be modified anymore and can be safely shared among models. + */ + private volatile boolean frozen; //*********************************************************************************** // CONSTRUCTOR @@ -155,6 +159,7 @@ public ESat check(IntVar... vars) { * @throws org.chocosolver.solver.exception.SolverException if the size of the tuple added does not correspond to a the previous ones (if any). */ public void add(int... tuple) { + checkNotFrozen(); if (tuples.isEmpty()) { arity = tuple.length; ranges = new int[2 * arity]; @@ -244,21 +249,52 @@ public String toString() { * @return an array of tuples, each tuple is an int array */ public int[][] toMatrix() { - if (cachedMatrix == null) { + // the cache may have been cleared by the GC, or be computed concurrently when the tuples are shared + SoftReference ref = cachedMatrix; + int[][] matrix = ref == null ? null : ref.get(); + if (matrix == null) { int i = 0; - int[][] matrix = new int[tuples.size()][]; + matrix = new int[tuples.size()][]; for (int[] tuple : tuples) { matrix[i++] = tuple.clone(); } cachedMatrix = new SoftReference<>(matrix); } - return cachedMatrix.get(); + return matrix; } public void sort() { + checkNotFrozen(); tuples.sort(new TupleComparator()); } + /** + * Freeze this set of tuples: any further modification ({@link #add(int...)}, {@link #sort()}) will throw a + * {@link SolverException}. + * A frozen set of tuples can be safely shared among models solved concurrently. + * Freezing is irreversible and idempotent. + * + * @return this + */ + public Tuples freeze() { + this.frozen = true; + return this; + } + + /** + * @return true if this set of tuples is frozen, false otherwise + * @see #freeze() + */ + public boolean isFrozen() { + return frozen; + } + + private void checkNotFrozen() { + if (frozen) { + throw new SolverException("Cannot modify a frozen set of tuples"); + } + } + private static class TupleComparator implements Comparator { @Override diff --git a/solver/src/main/java/org/chocosolver/solver/constraints/extension/hybrid/HybridTuples.java b/solver/src/main/java/org/chocosolver/solver/constraints/extension/hybrid/HybridTuples.java index 321f143387..4c067bb716 100644 --- a/solver/src/main/java/org/chocosolver/solver/constraints/extension/hybrid/HybridTuples.java +++ b/solver/src/main/java/org/chocosolver/solver/constraints/extension/hybrid/HybridTuples.java @@ -50,6 +50,10 @@ public class HybridTuples { * For sanity check only */ private int arity; + /** + * Once frozen, the tuples cannot be modified anymore and can be safely shared among models. + */ + private volatile boolean frozen; /** * Create an empty structure that stores hybrid tuples @@ -68,6 +72,9 @@ public HybridTuples() { * @throws SolverException if the tuple does not the match the arity of previously declared ones. */ public void add(ISupportable... tuple) { + if (frozen) { + throw new SolverException("Cannot modify frozen hybrid tuples"); + } if (hybridTuples.size() == 0) { arity = tuple.length; } else if (arity != tuple.length) { @@ -133,6 +140,26 @@ public void add(ISupportable[]... tuples) { } } + /** + * Freeze these hybrid tuples: any further modification will throw a {@link SolverException}. + * Frozen hybrid tuples can be safely shared among models solved concurrently. + * Freezing is irreversible and idempotent. + * + * @return this + */ + public HybridTuples freeze() { + this.frozen = true; + return this; + } + + /** + * @return true if these hybrid tuples are frozen + * @see #freeze() + */ + public boolean isFrozen() { + return frozen; + } + /** * @return the current tuples as an array of expressions. */ diff --git a/solver/src/main/java/org/chocosolver/solver/constraints/nary/automata/FA/FiniteAutomaton.java b/solver/src/main/java/org/chocosolver/solver/constraints/nary/automata/FA/FiniteAutomaton.java index 8c79805933..84d14c5458 100644 --- a/solver/src/main/java/org/chocosolver/solver/constraints/nary/automata/FA/FiniteAutomaton.java +++ b/solver/src/main/java/org/chocosolver/solver/constraints/nary/automata/FA/FiniteAutomaton.java @@ -37,7 +37,7 @@ public class FiniteAutomaton implements IAutomaton { private ArrayList states; private TIntHashSet alphabet; private int nbStates; - private final HashSet nexts = new HashSet<>(); + private HashSet nexts = new HashSet<>(); private int min = Character.MIN_VALUE; private int max = Character.MAX_VALUE; @@ -595,6 +595,8 @@ public ArrayList _removeSymbolFromAutomaton(int alpha) { public FiniteAutomaton clone() throws CloneNotSupportedException { FiniteAutomaton auto = (FiniteAutomaton) super.clone(); + // working buffer of delta(int, int, TIntHashSet): must not be shared with the clone + auto.nexts = new HashSet<>(); auto.representedBy = new Automaton(); auto.states = new ArrayList<>(); auto.stateToIndex = new TObjectIntHashMap<>(); @@ -613,6 +615,7 @@ public FiniteAutomaton clone() throws CloneNotSupportedException { for (int[] t : transitions) { auto.addTransition(t[0], t[1], t[2]); } + auto.representedBy.setDeterministic(this.representedBy.isDeterministic()); return auto; } diff --git a/solver/src/main/java/org/chocosolver/util/objects/graphs/MultivaluedDecisionDiagram.java b/solver/src/main/java/org/chocosolver/util/objects/graphs/MultivaluedDecisionDiagram.java index 36c3c458f5..1843126adb 100644 --- a/solver/src/main/java/org/chocosolver/util/objects/graphs/MultivaluedDecisionDiagram.java +++ b/solver/src/main/java/org/chocosolver/util/objects/graphs/MultivaluedDecisionDiagram.java @@ -9,6 +9,7 @@ import gnu.trove.list.array.TIntArrayList; import gnu.trove.map.hash.TIntIntHashMap; import org.chocosolver.solver.constraints.extension.Tuples; +import org.chocosolver.solver.exception.SolverException; import org.chocosolver.solver.variables.IntVar; import java.util.ArrayList; @@ -80,6 +81,10 @@ public enum Compact { private final Compact compact; private final boolean sortTuples; + /** + * Once frozen, the diagram cannot be modified anymore and can be safely shared among models. + */ + private volatile boolean frozen; // TEMPORARY DATA STRUCTURE, PREFIX WITH "_", CLEARED AFTER USAGE private TIntIntHashMap _nodesToRemove; // store the nodes to remove and the size of each node @@ -208,6 +213,26 @@ private void init(Tuples TUPLES) { } } + /** + * Freeze this diagram: any further modification will throw a {@link SolverException}. + * A frozen diagram can be safely shared among models solved concurrently. + * Freezing is irreversible and idempotent. + * + * @return this + */ + public MultivaluedDecisionDiagram freeze() { + this.frozen = true; + return this; + } + + /** + * @return true if this diagram is frozen + * @see #freeze() + */ + public boolean isFrozen() { + return frozen; + } + /** * Add all tuples within the MDD * @@ -228,6 +253,9 @@ public boolean addTuples(Tuples TUPLES) { * @param TUPLE tuple to add */ public boolean addTuple(int[] TUPLE) { + if (frozen) { + throw new SolverException("Cannot modify a frozen diagram"); + } for (int i = 0; i < nbLayers; i++) { // if the tuple is out of declared domain if (TUPLE[i] < offsets[i] || TUPLE[i] >= offsets[i] + sizes[i]) { From b744924b75c59de45e4d82765ea8141a8d8ddd6f Mon Sep 17 00:00:00 2001 From: Charles Prud'homme Date: Thu, 1 Oct 2026 15:50:50 +0200 Subject: [PATCH 2/6] Add the choco-codegen module (annotation processor) RecordingModelProcessor generates, at compile time, a subclass of a model which journalizes every factory method of IModel. The solver module declares it as an annotation processor (build-time only dependency). Co-Authored-By: Claude Opus 5.5 --- codegen/pom.xml | 46 ++++ .../codegen/RecordingModelProcessor.java | 228 ++++++++++++++++++ .../javax.annotation.processing.Processor | 1 + pom.xml | 1 + solver/pom.xml | 21 ++ 5 files changed, 297 insertions(+) create mode 100644 codegen/pom.xml create mode 100644 codegen/src/main/java/org/chocosolver/codegen/RecordingModelProcessor.java create mode 100644 codegen/src/main/resources/META-INF/services/javax.annotation.processing.Processor diff --git a/codegen/pom.xml b/codegen/pom.xml new file mode 100644 index 0000000000..ad35393d92 --- /dev/null +++ b/codegen/pom.xml @@ -0,0 +1,46 @@ + + + + 4.0.0 + + + org.choco-solver + choco + 6.0.2-SNAPSHOT + + + org.choco-solver + choco-codegen + 6.0.2-SNAPSHOT + jar + + choco-codegen + + Annotation processors used to build choco-solver (build-time only). + + + + .${file.separator}.. + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + none + + + + + diff --git a/codegen/src/main/java/org/chocosolver/codegen/RecordingModelProcessor.java b/codegen/src/main/java/org/chocosolver/codegen/RecordingModelProcessor.java new file mode 100644 index 0000000000..4af811d00e --- /dev/null +++ b/codegen/src/main/java/org/chocosolver/codegen/RecordingModelProcessor.java @@ -0,0 +1,228 @@ +/* + * This file is part of choco-codegen, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.codegen; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.PackageElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.ElementFilter; +import javax.tools.Diagnostic; +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * Generates the recording subclass of a class annotated with {@code org.chocosolver.solver.spec.GenerateRecordingModel}. + *

+ * The generated class extends the annotated class and overrides every default method of the factory interface + * (but the excluded ones) as follows: + *

{@code
+ * public Constraint arithm(IntVar p0, String p1, int p2) {
+ *     if (!topLevel()) {
+ *         return super.arithm(p0, p1, p2);
+ *     }
+ *     return getRecorder().call(M[i], () -> super.arithm(p0, p1, p2), new Object[]{p0, p1, p2});
+ * }
+ * }
+ * The annotated class must provide {@code topLevel()} and {@code getRecorder()}; the recorder must provide + * {@code call(FactoryMethod, Supplier, Object[])} and {@code run(FactoryMethod, Runnable, Object[])}. + * The generated class also declares a public constructor for each non-private constructor of the annotated class. + * + * @author Charles Prud'homme + */ +@SupportedAnnotationTypes(RecordingModelProcessor.ANNOTATION) +public class RecordingModelProcessor extends AbstractProcessor { + + static final String ANNOTATION = "org.chocosolver.solver.spec.GenerateRecordingModel"; + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(Set annotations, RoundEnvironment env) { + for (TypeElement annotation : annotations) { + for (Element e : env.getElementsAnnotatedWith(annotation)) { + if (e.getKind() != ElementKind.CLASS) { + error(e, "@GenerateRecordingModel only applies to classes"); + continue; + } + try { + generate((TypeElement) e, attributes(e, annotation)); + } catch (IOException ex) { + error(e, "Cannot generate the recording model: " + ex.getMessage()); + } + } + } + return true; + } + + private void error(Element e, String message) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, message, e); + } + + /** + * @return the attributes of the annotation, with default values + */ + private Map attributes(Element e, TypeElement annotation) { + for (AnnotationMirror am : e.getAnnotationMirrors()) { + if (am.getAnnotationType().asElement().equals(annotation)) { + return processingEnv.getElementUtils().getElementValuesWithDefaults(am).entrySet().stream() + .collect(Collectors.toMap(v -> v.getKey().getSimpleName().toString(), v -> v.getValue().getValue())); + } + } + throw new IllegalStateException("annotation not found on " + e); + } + + private void generate(TypeElement annotated, Map attributes) throws IOException { + TypeElement factories = (TypeElement) ((DeclaredType) attributes.get("factories")).asElement(); + String name = (String) attributes.get("name"); + Set excluded = new HashSet<>(); + for (Object v : (List) attributes.get("excluded")) { + excluded.add((String) ((AnnotationValue) v).getValue()); + } + String pkg = ((PackageElement) annotated.getEnclosingElement()).getQualifiedName().toString(); + + List methods = new ArrayList<>(); + for (ExecutableElement m : ElementFilter.methodsIn(processingEnv.getElementUtils().getAllMembers(factories))) { + Set mods = m.getModifiers(); + if (mods.contains(Modifier.DEFAULT) && !mods.contains(Modifier.STATIC) + && !excluded.contains(m.getSimpleName().toString())) { + methods.add(m); + } + } + methods.sort(Comparator.comparing(this::signature)); + + StringBuilder sb = new StringBuilder(); + sb.append("package ").append(pkg).append(";\n\n") + .append("/**\n") + .append(" * Recording subclass of {@link ").append(annotated.getSimpleName()).append("}: overrides the ") + .append(methods.size()).append(" factory methods of\n") + .append(" * {@link ").append(factories.getQualifiedName()).append("} to journalize them.\n") + .append(" *

\n") + .append(" * GENERATED at compile time by {@code ").append(getClass().getName()).append("}.\n") + .append(" */\n") + .append("public class ").append(name).append(" extends ").append(annotated.getSimpleName()).append(" {\n\n") + .append(" /**\n * Names of the factory methods which are not journalized (no effect on the model).\n */\n") + .append(" public static final java.util.Set EXCLUDED = java.util.Set.of(") + .append(excluded.stream().sorted().map(e -> "\"" + e + "\"").collect(Collectors.joining(", "))) + .append(");\n\n") + .append(" private static final FactoryMethod[] M = new FactoryMethod[").append(methods.size()).append("];\n\n") + .append(" static {\n"); + for (int i = 0; i < methods.size(); i++) { + ExecutableElement m = methods.get(i); + sb.append(" M[").append(i).append("] = FactoryMethod.of(\"").append(m.getSimpleName()).append('"'); + for (VariableElement p : m.getParameters()) { + sb.append(", ").append(erasure(p.asType())).append(".class"); + } + sb.append(");\n"); + } + sb.append(" }\n"); + + for (ExecutableElement c : ElementFilter.constructorsIn(annotated.getEnclosedElements())) { + if (c.getModifiers().contains(Modifier.PRIVATE)) { + continue; + } + sb.append("\n public ").append(name).append('(').append(parameters(c)).append(") {\n") + .append(" super(").append(names(c)).append(");\n") + .append(" }\n"); + } + + for (int i = 0; i < methods.size(); i++) { + ExecutableElement m = methods.get(i); + boolean isVoid = m.getReturnType().getKind() == TypeKind.VOID; + String superCall = "super." + m.getSimpleName() + "(" + names(m) + ")"; + String args = "new Object[]{" + names(m) + "}"; + sb.append("\n @Override\n") + .append(" public ").append(typeParameters(m)).append(m.getReturnType()).append(' ') + .append(m.getSimpleName()) + .append('(').append(parameters(m)).append(") {\n") + .append(" if (!topLevel()) {\n"); + if (isVoid) { + sb.append(" ").append(superCall).append(";\n") + .append(" return;\n") + .append(" }\n") + .append(" getRecorder().run(M[").append(i).append("], () -> ").append(superCall) + .append(", ").append(args).append(");\n"); + } else { + sb.append(" return ").append(superCall).append(";\n") + .append(" }\n") + .append(" return getRecorder().call(M[").append(i).append("], () -> ").append(superCall) + .append(", ").append(args).append(");\n"); + } + sb.append(" }\n"); + } + sb.append("}\n"); + + try (Writer w = processingEnv.getFiler().createSourceFile(pkg + "." + name, annotated).openWriter()) { + w.write(sb.toString()); + } + } + + private String erasure(TypeMirror t) { + return processingEnv.getTypeUtils().erasure(t).toString(); + } + + private String signature(ExecutableElement m) { + return m.getSimpleName() + m.getParameters().stream().map(p -> erasure(p.asType())) + .collect(Collectors.joining(",", "(", ")")); + } + + /** + * @return the declaration of the parameters of m, named p0, p1, ... + */ + private static String parameters(ExecutableElement m) { + List params = m.getParameters(); + return IntStream.range(0, params.size()).mapToObj(k -> { + String t = params.get(k).asType().toString(); + if (m.isVarArgs() && k == params.size() - 1) { + t = t.substring(0, t.length() - 2) + "..."; + } + return t + " p" + k; + }).collect(Collectors.joining(", ")); + } + + /** + * @return the declaration of the type parameters of m, e.g. {@code }, or an empty + * string + */ + private static String typeParameters(ExecutableElement m) { + if (m.getTypeParameters().isEmpty()) { + return ""; + } + return m.getTypeParameters().stream().map(tp -> { + List bounds = tp.getBounds().stream().map(TypeMirror::toString) + .filter(b -> !b.equals("java.lang.Object")).toList(); + return bounds.isEmpty() ? tp.toString() : tp + " extends " + String.join(" & ", bounds); + }).collect(Collectors.joining(", ", "<", "> ")); + } + + private static String names(ExecutableElement m) { + return IntStream.range(0, m.getParameters().size()).mapToObj(k -> "p" + k).collect(Collectors.joining(", ")); + } +} diff --git a/codegen/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/codegen/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 0000000000..c00b8c9957 --- /dev/null +++ b/codegen/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +org.chocosolver.codegen.RecordingModelProcessor diff --git a/pom.xml b/pom.xml index 140ba4bf54..8554e1e7d3 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,7 @@ + codegen solver parsers examples diff --git a/solver/pom.xml b/solver/pom.xml index e3b678fd11..abe156e867 100644 --- a/solver/pom.xml +++ b/solver/pom.xml @@ -36,6 +36,14 @@ + + + org.choco-solver + choco-codegen + ${project.version} + provided + true + dk.brics.automaton automaton @@ -61,6 +69,19 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.choco-solver + choco-codegen + ${project.version} + + + + maven-assembly-plugin ${maven.assembly.plugin.version} From d57dc457c00c5a73c1eb60b7d955c850e8708240 Mon Sep 17 00:00:00 2001 From: Charles Prud'homme Date: Thu, 1 Oct 2026 15:51:03 +0200 Subject: [PATCH 3/6] Record the construction of a model to duplicate it A model created with Model.record(...) journalizes its construction into a ModelSpec (package org.chocosolver.solver.spec): calls to the factory methods, posts, reifications, tasks, hooks, seed, objective and groups. Model.duplicate() replays it into an independent copy, which can be solved in another thread. Model.create(...) is equivalent to new Model(...). - RecordingModel is generated by choco-codegen from AbstractRecordingModel; Constraint, Task and OptionalTask journalize their own operations. - Only top-level operations are journalized; what cannot be journalized is reported by snapshot(). - Sharing policy (Values): immutable values and frozen tuples are shared, arrays and automata are copied, capturing lambdas are rejected. - Custom constraints are built with the new factory IConstraintFactory.custom(name, vars[, data], propagator). - A spec can be rewritten (Variant: seed, settings, search, consistency of allDifferent, table algorithm, extra or removed constraints), given a search (SearchDecl), and its solutions exchanged (SpecSolution). - SharedObjects detects mutable objects shared by two models. - Assertions check that constraints and search strategies only involve variables of their own model. Co-Authored-By: Claude Opus 5.5 --- solver/src/main/java/module-info.java | 1 + .../java/org/chocosolver/solver/Model.java | 135 ++++ .../java/org/chocosolver/solver/Solver.java | 3 + .../solver/constraints/Constraint.java | 41 +- .../constraints/IConstraintFactory.java | 42 ++ .../solver/spec/AbstractRecordingModel.java | 211 ++++++ .../java/org/chocosolver/solver/spec/Arg.java | 242 +++++++ .../solver/spec/FactoryMethod.java | 123 ++++ .../solver/spec/GenerateRecordingModel.java | 38 + .../org/chocosolver/solver/spec/Header.java | 56 ++ .../org/chocosolver/solver/spec/IdTable.java | 82 +++ .../chocosolver/solver/spec/ModelSpec.java | 135 ++++ .../org/chocosolver/solver/spec/Recorder.java | 516 +++++++++++++ .../org/chocosolver/solver/spec/Replayer.java | 215 ++++++ .../org/chocosolver/solver/spec/Resolver.java | 89 +++ .../chocosolver/solver/spec/SearchDecl.java | 109 +++ .../solver/spec/SharedObjects.java | 252 +++++++ .../chocosolver/solver/spec/SpecBuilder.java | 322 +++++++++ .../solver/spec/SpecException.java | 26 + .../chocosolver/solver/spec/SpecSolution.java | 102 +++ .../org/chocosolver/solver/spec/Specs.java | 82 +++ .../org/chocosolver/solver/spec/Step.java | 300 ++++++++ .../org/chocosolver/solver/spec/Values.java | 168 +++++ .../org/chocosolver/solver/spec/Variant.java | 213 ++++++ .../solver/variables/OptionalTask.java | 24 +- .../chocosolver/solver/variables/Task.java | 24 +- .../solver/spec/CustomConstraintTest.java | 119 +++ .../solver/spec/IntFactoryCoverageTest.java | 683 ++++++++++++++++++ .../solver/spec/ModelSpecTest.java | 399 ++++++++++ .../chocosolver/solver/spec/RecorderTest.java | 358 +++++++++ .../solver/spec/SearchDeclTest.java | 188 +++++ .../solver/spec/SpecTestUtils.java | 73 ++ .../chocosolver/solver/spec/VariantTest.java | 210 ++++++ 33 files changed, 5565 insertions(+), 16 deletions(-) create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/AbstractRecordingModel.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/Arg.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/FactoryMethod.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/GenerateRecordingModel.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/Header.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/IdTable.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/ModelSpec.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/Recorder.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/Replayer.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/Resolver.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/SearchDecl.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/SharedObjects.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/SpecBuilder.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/SpecException.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/SpecSolution.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/Specs.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/Step.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/Values.java create mode 100644 solver/src/main/java/org/chocosolver/solver/spec/Variant.java create mode 100644 solver/src/test/java/org/chocosolver/solver/spec/CustomConstraintTest.java create mode 100644 solver/src/test/java/org/chocosolver/solver/spec/IntFactoryCoverageTest.java create mode 100644 solver/src/test/java/org/chocosolver/solver/spec/ModelSpecTest.java create mode 100644 solver/src/test/java/org/chocosolver/solver/spec/RecorderTest.java create mode 100644 solver/src/test/java/org/chocosolver/solver/spec/SearchDeclTest.java create mode 100644 solver/src/test/java/org/chocosolver/solver/spec/SpecTestUtils.java create mode 100644 solver/src/test/java/org/chocosolver/solver/spec/VariantTest.java diff --git a/solver/src/main/java/module-info.java b/solver/src/main/java/module-info.java index 0c697c0c8f..4f378291c0 100644 --- a/solver/src/main/java/module-info.java +++ b/solver/src/main/java/module-info.java @@ -92,6 +92,7 @@ exports org.chocosolver.solver.search.strategy.selectors.values.graph.node; exports org.chocosolver.solver.search.strategy.selectors.values.graph.priority; exports org.chocosolver.solver.search.strategy.strategy; + exports org.chocosolver.solver.spec; exports org.chocosolver.solver.variables; exports org.chocosolver.solver.variables.delta; //exports org.chocosolver.solver.variables.delta.monitor; diff --git a/solver/src/main/java/org/chocosolver/solver/Model.java b/solver/src/main/java/org/chocosolver/solver/Model.java index 4e48e165cf..ebb801a392 100755 --- a/solver/src/main/java/org/chocosolver/solver/Model.java +++ b/solver/src/main/java/org/chocosolver/solver/Model.java @@ -19,6 +19,8 @@ import org.chocosolver.solver.objective.IObjectiveManager; import org.chocosolver.solver.objective.ObjectiveFactory; import org.chocosolver.solver.propagation.PropagationEngine; +import org.chocosolver.solver.spec.Recorder; +import org.chocosolver.solver.spec.RecordingModel; import org.chocosolver.solver.variables.*; import org.chocosolver.util.tools.ArrayUtils; import org.chocosolver.util.tools.VariableUtils; @@ -256,6 +258,95 @@ public Model() { this("Model-" + nextModelNum()); } + /** + * Creates a model, as {@link #Model(String, Settings)}. Such a model cannot be duplicated: see + * {@link #record(String, Settings)}. + * + * @param name name of the model + * @param settings settings of the model + * @return a new model + */ + public static Model create(String name, Settings settings) { + return new Model(name, settings); + } + + /** + * Creates a model, as {@link #Model(String)}. Such a model cannot be duplicated: see {@link #record(String)}. + * + * @param name name of the model + * @return a new model + */ + public static Model create(String name) { + return new Model(name); + } + + /** + * Creates a model, as {@link #Model(Settings)}. Such a model cannot be duplicated: see {@link #record(Settings)}. + * + * @param settings settings of the model + * @return a new model + */ + public static Model create(Settings settings) { + return new Model(settings); + } + + /** + * Creates a model, as {@link #Model()}. Such a model cannot be duplicated: see {@link #record()}. + * + * @return a new model + */ + public static Model create() { + return new Model(); + } + + /** + * Creates a model whose construction is recorded, so that it can be duplicated: build it as usual, then call + * {@link #duplicate()} to get independent copies, or use {@link ParallelPortfolio#of(Model, int)} to solve it with + * several threads (it is then the first worker). The copies themselves are not recorded, hence cannot be + * duplicated. Only the construction of the model is recorded, not the configuration of its solver (search, + * limits...), and a model cannot be duplicated once its resolution has started. + *

+ * A custom constraint must be built with {@link #custom(String, Variable[], java.util.function.Function)} (or its + * variant with data), not directly from propagators. + * + * @param name name of the model + * @param settings settings of the model + * @return a model which can be duplicated + * @see org.chocosolver.solver.spec.RecordingModel + */ + public static Model record(String name, Settings settings) { + return new RecordingModel(name, settings); + } + + /** + * Creates a model whose construction is recorded, see {@link #record(String, Settings)}. + * + * @param name name of the model + * @return a model which can be duplicated + */ + public static Model record(String name) { + return new RecordingModel(name); + } + + /** + * Creates a model whose construction is recorded, see {@link #record(String, Settings)}. + * + * @param settings settings of the model + * @return a model which can be duplicated + */ + public static Model record(Settings settings) { + return new RecordingModel(settings); + } + + /** + * Creates a model whose construction is recorded, see {@link #record(String, Settings)}. + * + * @return a model which can be duplicated + */ + public static Model record() { + return new RecordingModel(); + } + /** * For autonumbering anonymous models. */ @@ -968,6 +1059,7 @@ private void _post(boolean permanent, Constraint... cs) throws SolverException { } // specific behavior for dynamic addition and/or reified constraints for (Constraint c : cs) { + assert ownsVariablesOf(c) : "Constraint " + c.getName() + " involves variables of another model"; for (Propagator p : c.getPropagators()) { if (p.isPassive()) { throw new SolverException("Try to add a constraint with a passive propagator"); @@ -983,6 +1075,21 @@ private void _post(boolean permanent, Constraint... cs) throws SolverException { } } + /** + * @return true if all the variables of c belong to this model (e.g., a constraint built by a + * lambda must not capture the variables of another model) + */ + private boolean ownsVariablesOf(Constraint c) { + for (Propagator p : c.getPropagators()) { + for (Variable v : p.getVars()) { + if (v.getModel() != this) { + return false; + } + } + } + return true; + } + /** * Posts constraints cs temporary, that is, they will be unposted upon backtrack. *

@@ -1137,6 +1244,34 @@ public void displayPropagatorOccurrences() { //////////////////////////////////////////// RELATED TO MODELING FACTORIES ///////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + /** + * Message of the exception thrown when duplicating a model which is not recorded. + */ + static final String NOT_RECORDED = "This model cannot be duplicated: create it with Model.record(...)"; + + /** + * Internal use only. + * + * @return the recorder of this model, or null if this model is not a + * {@link org.chocosolver.solver.spec.RecordingModel} + */ + public Recorder getRecorder() { + return null; + } + + /** + * Creates an independent copy of this model: same variables, same constraints, same objective, in the same + * order, hence the same resolution with a deterministic search. The copy can be solved in another thread. + * The copy itself cannot be duplicated. + * + * @return a copy of this model + * @throws SolverException if this model was not created with {@link #record(String)}, or if its resolution + * has started, or if one of its constraints was not built by a factory method + */ + public Model duplicate() { + throw new SolverException(NOT_RECORDED); + } + @Override public Model ref() { return this; diff --git a/solver/src/main/java/org/chocosolver/solver/Solver.java b/solver/src/main/java/org/chocosolver/solver/Solver.java index f317203442..48c3330b0d 100644 --- a/solver/src/main/java/org/chocosolver/solver/Solver.java +++ b/solver/src/main/java/org/chocosolver/solver/Solver.java @@ -1376,6 +1376,9 @@ public void setSearch(AbstractStrategy... strategies) { strategies = Arrays.stream(strategies).filter(Objects::nonNull) .flatMap(s -> (s instanceof StrategiesSequencer) ? Arrays.stream(((StrategiesSequencer) s).getStrategies()) : Stream.of(s)) .toArray(AbstractStrategy[]::new); + assert Arrays.stream(strategies).allMatch(s -> s.getVariables() == null + || Arrays.stream(s.getVariables()).allMatch(v -> v == null || v.getModel() == mModel)) + : "A search strategy involves variables of another model"; if (strategies.length == 0) { M.removeStrategy(); } else if (strategies.length == 1) { diff --git a/solver/src/main/java/org/chocosolver/solver/constraints/Constraint.java b/solver/src/main/java/org/chocosolver/solver/constraints/Constraint.java index 307b742a7b..d8400e9842 100755 --- a/solver/src/main/java/org/chocosolver/solver/constraints/Constraint.java +++ b/solver/src/main/java/org/chocosolver/solver/constraints/Constraint.java @@ -10,6 +10,8 @@ import org.chocosolver.solver.constraints.reification.Opposite; import org.chocosolver.solver.exception.SolverException; import org.chocosolver.solver.search.SearchState; +import org.chocosolver.solver.spec.Recorder; +import org.chocosolver.solver.spec.Step; import org.chocosolver.solver.variables.BoolVar; import org.chocosolver.util.ESat; @@ -185,6 +187,15 @@ public final boolean isReified() { * @param bool the variable to reify with */ public void reifyWith(BoolVar bool) { + Recorder r = recorder(); + if (r != null) { + r.link(Step.Link.Kind.REIFY_WITH, this, bool, () -> doReifyWith(bool)); + } else { + doReifyWith(bool); + } + } + + private void doReifyWith(BoolVar bool) { if (boolReif != null) { if (opposite == null) { throw new SolverException("try to reify an implied constraint"); @@ -219,6 +230,11 @@ public void reifyWith(BoolVar bool) { * @return the boolean reifying the constraint */ public final BoolVar reify() { + Recorder r = recorder(); + return r != null ? r.reify(this, this::doReify) : doReify(); + } + + private BoolVar doReify() { if (boolReif == null) { Model model = propagators[0].getModel(); reifyWith(model.boolVar(model.generateName("REIF_"))); @@ -243,7 +259,12 @@ public final BoolVar reify() { * @param r a boolean variable */ public final void implies(BoolVar r) { - this.reify().imp(r).post(); + Recorder rec = recorder(); + if (rec != null) { + rec.link(Step.Link.Kind.IMPLIES, this, r, () -> this.reify().imp(r).post()); + } else { + this.reify().imp(r).post(); + } } /** @@ -262,6 +283,15 @@ public final void implies(BoolVar r) { * @param r a boolean variable */ public final void impliedBy(BoolVar r) { + Recorder rec = recorder(); + if (rec != null) { + rec.link(Step.Link.Kind.IMPLIED_BY, this, r, () -> doImpliedBy(r)); + } else { + doImpliedBy(r); + } + } + + private void doImpliedBy(BoolVar r) { if (boolReif == null) { boolReif = r; if (boolReif.isInstantiatedTo(1) @@ -275,6 +305,15 @@ public final void impliedBy(BoolVar r) { } } + /** + * @return the recorder of the model of this constraint if the current operation must be journalized (the model + * is recorded and the operation is not triggered by another journalized one), null otherwise + */ + private Recorder recorder() { + Recorder r = propagators.length > 0 ? propagators[0].getModel().getRecorder() : null; + return r == null || r.busy() ? null : r; + } + /** * Posts the constraint to its model so that the constraint must be satisfied. * This should not be reified. diff --git a/solver/src/main/java/org/chocosolver/solver/constraints/IConstraintFactory.java b/solver/src/main/java/org/chocosolver/solver/constraints/IConstraintFactory.java index 87c9da500e..b589032fb5 100644 --- a/solver/src/main/java/org/chocosolver/solver/constraints/IConstraintFactory.java +++ b/solver/src/main/java/org/chocosolver/solver/constraints/IConstraintFactory.java @@ -9,6 +9,7 @@ import org.chocosolver.solver.constraints.reification.PropConditional; import org.chocosolver.solver.variables.Variable; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Supplier; @@ -45,4 +46,45 @@ default Constraint conditional( new PropConditional(scope, checker, provider) ); } + + /** + * Create a custom constraint, made of the propagator built by {@code propagator} on {@code vars}. + *

+ * Prefer this factory to {@code new Constraint(name, propagator)}: a model created with + * {@link org.chocosolver.solver.Model#record()} can then be duplicated, provided that {@code propagator} captures + * nothing (e.g., a constructor reference such as {@code PropFoo::new}), since it is shared by all the copies. + * + * @param name name of the constraint + * @param vars variables of the constraint + * @param propagator builds the propagator from the variables; must capture nothing + * @param type of the variables + * @return a custom constraint + */ + default Constraint custom(String name, V[] vars, Function> propagator) { + return new Constraint(name, propagator.apply(vars)); + } + + /** + * Create a custom constraint, made of the propagator built by {@code propagator} on {@code vars} and + * {@code data}, e.g. {@code custom("atMostK", x, 3, PropAtMostK::new)}. + *

+ * Prefer this factory to {@code new Constraint(name, propagator)}: a model created with + * {@link org.chocosolver.solver.Model#record()} can then be duplicated, provided that {@code propagator} captures + * nothing (e.g., a constructor reference such as {@code PropFoo::new}), since it is shared by all the copies. + * What the propagator needs, besides the variables, is passed in {@code data}, which is journalized as any + * constant argument: immutable values and arrays of them, tuples, or a {@code record} of such values (several + * parameters). An array of variables is mapped to the variables of each copy. + * + * @param name name of the constraint + * @param vars variables of the constraint + * @param data the other parameters of the propagator + * @param propagator builds the propagator from the variables and the data; must capture nothing + * @param type of the variables + * @param type of the data + * @return a custom constraint + */ + default Constraint custom(String name, V[] vars, D data, + BiFunction> propagator) { + return new Constraint(name, propagator.apply(vars, data)); + } } diff --git a/solver/src/main/java/org/chocosolver/solver/spec/AbstractRecordingModel.java b/solver/src/main/java/org/chocosolver/solver/spec/AbstractRecordingModel.java new file mode 100644 index 0000000000..941d51f263 --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/AbstractRecordingModel.java @@ -0,0 +1,211 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.IModel; +import org.chocosolver.solver.Model; +import org.chocosolver.solver.Settings; +import org.chocosolver.solver.SettingsBuilder; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.constraints.nary.cnf.SatConstraint; +import org.chocosolver.solver.constraints.real.IbexHandler; +import org.chocosolver.solver.exception.SolverException; +import org.chocosolver.solver.variables.Group; +import org.chocosolver.solver.variables.Variable; + +import java.util.Set; + +/** + * The hand-written part of {@link RecordingModel}: journalization of the operations of {@link Model} which are + * not factory methods. + *

+ * {@link RecordingModel}, which journalizes the factory methods, is generated at compile time. + * + * @author Charles Prud'homme + */ +@GenerateRecordingModel(name = "RecordingModel", factories = IModel.class, + excluded = {"checkIntDomainRange", "checkRealDomainRange", "toBoolVar", "getDomainUnion"}) +public abstract class AbstractRecordingModel extends Model { + + /** + * Hooks managed internally by the solver: never journalized. + */ + private static final Set INTERNAL_HOOKS = Set.of("cinstances", + MINISAT_HOOK_NAME, IBEX_HOOK_NAME, TASK_SET_HOOK_NAME); + + private final Recorder recorder; + + protected AbstractRecordingModel(String name, Settings settings) { + super(name, settings); + this.recorder = new Recorder(this, new Header(name, settings, getSeed())); + } + + protected AbstractRecordingModel(String name) { + this(name, SettingsBuilder.init().build()); + } + + protected AbstractRecordingModel(Settings settings) { + this("RecordingModel", settings); + } + + protected AbstractRecordingModel() { + this("RecordingModel"); + } + + /** + * @return the recorder of this model + */ + @Override + public final Recorder getRecorder() { + return recorder; + } + + /** + * @return the spec of the model built so far + * @throws SpecException if some operations could not be journalized, or if the resolution has started + * @see Recorder#snapshot() + */ + public final ModelSpec snapshot() { + return recorder.snapshot(); + } + + @Override + public final Model duplicate() { + return snapshot().instantiate().model(); + } + + /** + * @return true if the operation must be journalized + */ + protected final boolean topLevel() { + // null while the constructor of Model runs + return recorder != null && !recorder.busy(); + } + + @Override + public void post(Constraint... cs) throws SolverException { + if (topLevel()) { + recorder.post(cs, () -> super.post(cs)); + } else { + super.post(cs); + } + } + + @Override + public void unpost(Constraint... constraints) throws SolverException { + if (topLevel()) { + recorder.unpost(constraints, () -> super.unpost(constraints)); + } else { + super.unpost(constraints); + } + } + + @Override + public void addHook(String hookName, Object hookObject) { + if (topLevel() && !INTERNAL_HOOKS.contains(hookName)) { + recorder.hook(hookName, hookObject, () -> super.addHook(hookName, hookObject)); + } else { + super.addHook(hookName, hookObject); + } + } + + @Override + public void removeHook(String hookName) { + if (topLevel() && !INTERNAL_HOOKS.contains(hookName)) { + recorder.hook(hookName, null, () -> super.removeHook(hookName)); + } else { + super.removeHook(hookName); + } + } + + @Override + public void removeAllHooks() { + if (topLevel()) { + recorder.removeAllHooks(super::removeAllHooks); + } else { + super.removeAllHooks(); + } + } + + @Override + public void setName(String name) { + if (topLevel()) { + recorder.name(name, () -> super.setName(name)); + } else { + super.setName(name); + } + } + + @Override + public void addGroup(Group g) { + if (topLevel()) { + recorder.group(g.getName(), g.getVariables(), () -> super.addGroup(g)); + } else { + super.addGroup(g); + } + } + + @Override + public void setPrecision(double p) { + if (topLevel()) { + recorder.unsupported("setPrecision", "real variables are not supported"); + } + super.setPrecision(p); + } + + @Override + public void setSeed(long seed) { + if (topLevel()) { + recorder.seed(seed, () -> super.setSeed(seed)); + } else { + super.setSeed(seed); + } + } + + @Override + public void setObjective(boolean maximize, Variable objective) { + if (topLevel()) { + recorder.objective(maximize, objective, () -> super.setObjective(maximize, objective)); + } else { + super.setObjective(maximize, objective); + } + } + + @Override + public void clearObjective() { + if (topLevel()) { + recorder.objective(false, null, super::clearObjective); + } else { + super.clearObjective(); + } + } + + // The following methods are not factories: what they do internally must not be journalized. + // Their effects are replayed by the factories which need them. + + @Override + public SatConstraint getMinisat() { + // also called by search monitors (nogoods), which are not part of the model: never journalized + return topLevel() ? recorder.silently(super::getMinisat) : super.getMinisat(); + } + + @Override + public void removeMinisat() { + if (topLevel()) { + recorder.unsupported("removeMinisat", "not supported"); + } + super.removeMinisat(); + } + + @Override + public IbexHandler getIbexHandler() { + if (topLevel()) { + recorder.unsupported("getIbexHandler", "real constraints are not supported"); + } + return super.getIbexHandler(); + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/Arg.java b/solver/src/main/java/org/chocosolver/solver/spec/Arg.java new file mode 100644 index 0000000000..2bbec32b67 --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/Arg.java @@ -0,0 +1,242 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.constraints.nary.cnf.LogOp; +import org.chocosolver.solver.variables.BoolVar; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.IntConsumer; + +/** + * An argument of a {@link Step.Call}. + * Objects of the model (variables, constraints, tasks) are referred to by identifier, never by name. + *

+ * Arguments are immutable: public accessors return copies, the package reads the arrays directly. + * + * @author Charles Prud'homme + */ +public sealed interface Arg permits Arg.Ref, Arg.RefArray, Arg.RefMatrix, Arg.RefList, Arg.Const, Arg.Logic, + Arg.Literal { + + /** + * Apply action on each identifier this argument refers to. + */ + default void forEachRef(IntConsumer action) { + } + + /** + * A reference to one object of the model. + */ + record Ref(int id) implements Arg { + public Ref { + if (id < 0) throw new SpecException("Negative identifier: " + id); + } + + @Override + public void forEachRef(IntConsumer action) { + action.accept(id); + } + } + + /** + * An array of objects of the model, e.g. an {@code IntVar[]}. + * + * @param componentType type of the elements of the array to build, e.g. {@code IntVar.class} + * @param ids identifiers of the elements + */ + record RefArray(Class componentType, int[] ids) implements Arg { + public RefArray { + Objects.requireNonNull(componentType); + ids = ids.clone(); + } + + @Override + public int[] ids() { + return ids.clone(); + } + + int[] rawIds() { + return ids; + } + + @Override + public void forEachRef(IntConsumer action) { + for (int id : ids) action.accept(id); + } + + @Override + public String toString() { + return componentType.getSimpleName() + Arrays.toString(ids); + } + } + + /** + * A matrix of objects of the model, e.g. an {@code IntVar[][]}. + * + * @param componentType type of the elements of the matrix to build, e.g. {@code IntVar.class} + * @param ids identifiers of the elements, row by row + */ + record RefMatrix(Class componentType, int[][] ids) implements Arg { + public RefMatrix { + Objects.requireNonNull(componentType); + ids = (int[][]) Values.capture(ids); + } + + @Override + public int[][] ids() { + return (int[][]) Values.materialize(ids); + } + + int[][] rawIds() { + return ids; + } + + @Override + public void forEachRef(IntConsumer action) { + for (int[] row : ids) for (int id : row) action.accept(id); + } + + @Override + public String toString() { + return componentType.getSimpleName() + Arrays.deepToString(ids); + } + } + + /** + * A {@link java.util.List} of objects of the model. + */ + record RefList(int[] ids) implements Arg { + public RefList { + ids = ids.clone(); + } + + @Override + public int[] ids() { + return ids.clone(); + } + + int[] rawIds() { + return ids; + } + + @Override + public void forEachRef(IntConsumer action) { + for (int id : ids) action.accept(id); + } + + @Override + public String toString() { + return "List" + Arrays.toString(ids); + } + } + + /** + * A constant, captured according to the sharing policy of {@link Values}. + */ + record Const(Object value) implements Arg { + public Const { + value = Values.capture(value); + } + + /** + * @return the value, shared or copied according to the sharing policy of {@link Values} + */ + @Override + public Object value() { + return Values.materialize(value); + } + + /** + * @return the captured value, as stored in the spec (not to be modified) + */ + Object rawValue() { + return value; + } + + @Override + public String toString() { + if (value instanceof int[] a) return Arrays.toString(a); + if (value instanceof Object[] a) return Arrays.deepToString(a); + return String.valueOf(value); + } + } + + /** + * A logical tree ({@link LogOp}) over boolean variables. + * + * @param and true for a conjunction, false for a disjunction + * @param negated true for the negation of the conjunction (resp. disjunction) + * @param children either references to {@link BoolVar} or nested {@link Logic} + */ + record Logic(boolean and, boolean negated, Arg[] children) implements Arg { + public Logic { + children = children.clone(); + for (Arg c : children) { + if (!(c instanceof Ref) && !(c instanceof Logic)) { + throw new SpecException("A logical tree only accepts references and logical trees, not " + c); + } + } + } + + @Override + public Arg[] children() { + return children.clone(); + } + + Arg[] rawChildren() { + return children; + } + + @Override + public void forEachRef(IntConsumer action) { + for (Arg c : children) c.forEachRef(action); + } + + @Override + public String toString() { + return (negated ? "n" : "") + (and ? "and" : "or") + Arrays.toString(children); + } + } + + /** + * A {@link org.chocosolver.sat.Literalizer}, which binds a variable to a literal of the SAT solver. + * Literalizers are mutable (the SAT solver numbers them): a new one is built for each instance. + * + * @param kind the kind of literalizer + * @param var identifier of the variable + * @param value the value (for {@link Kind#INT_EQ} and {@link Kind#INT_LE} only) + */ + record Literal(Kind kind, int var, int value) implements Arg { + + /** + * The kinds of literalizers: {@code BoolLit}, {@code IntEqLit} and {@code IntLeLit}. + */ + public enum Kind { + BOOL, INT_EQ, INT_LE + } + + public Literal { + Objects.requireNonNull(kind); + if (var < 0) throw new SpecException("Negative identifier: " + var); + } + + @Override + public void forEachRef(IntConsumer action) { + action.accept(var); + } + } + + static Logic and(Arg... children) { + return new Logic(true, false, children); + } + + static Logic or(Arg... children) { + return new Logic(false, false, children); + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/FactoryMethod.java b/solver/src/main/java/org/chocosolver/solver/spec/FactoryMethod.java new file mode 100644 index 0000000000..ef9f4df4cf --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/FactoryMethod.java @@ -0,0 +1,123 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.IModel; +import org.chocosolver.solver.Model; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * A factory method of {@link IModel}, identified by its exact signature (the overload is never ambiguous). + * It is replayed on a {@link Model} through a {@link MethodHandle}. + *

+ * Instances are immutable and can be shared among threads. + * + * @author Charles Prud'homme + */ +public final class FactoryMethod { + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + private final Method method; + private final Class[] types; + /** + * (Model, Object[]) -> Object: the arguments are spread, the result boxed (null for void). + */ + private final MethodHandle invoker; + + private FactoryMethod(Method method) { + if (Modifier.isStatic(method.getModifiers()) + || !method.getDeclaringClass().isInterface() + || !method.getDeclaringClass().isAssignableFrom(IModel.class)) { + throw new SpecException(method + " is not a factory method of IModel"); + } + this.method = method; + this.types = method.getParameterTypes(); + try { + // fixed arity: a varargs parameter receives the array built from the spec, as is + this.invoker = MethodHandles.publicLookup().unreflect(method).asFixedArity() + .asSpreader(Object[].class, types.length) + .asType(MethodType.methodType(Object.class, Model.class, Object[].class)); + } catch (IllegalAccessException e) { + throw new SpecException("Cannot access " + method, e); + } + } + + /** + * @param method a (non-static) method declared by one of the factory interfaces of {@link IModel} + * @return the corresponding factory method + * @throws SpecException if method is not a factory method + */ + public static FactoryMethod of(Method method) { + return CACHE.computeIfAbsent(method, FactoryMethod::new); + } + + /** + * @param name name of the factory method + * @param paramTypes exact parameter types, which select the overload + * @return the corresponding factory method + * @throws SpecException if no such factory method exists + */ + public static FactoryMethod of(String name, Class... paramTypes) { + try { + return of(IModel.class.getMethod(name, paramTypes)); + } catch (NoSuchMethodException e) { + throw new SpecException("No factory method " + signature(name, paramTypes), e); + } + } + + public String name() { + return method.getName(); + } + + public Class[] parameterTypes() { + return types.clone(); + } + + Class[] rawParameterTypes() { + return types; + } + + public int parameterCount() { + return types.length; + } + + public Class returnType() { + return method.getReturnType(); + } + + Object invoke(Model target, Object[] args) throws Throwable { + return (Object) invoker.invokeExact(target, args); + } + + private static String signature(String name, Class[] types) { + return name + Arrays.stream(types).map(Class::getSimpleName).collect(Collectors.joining(", ", "(", ")")); + } + + @Override + public boolean equals(Object o) { + return this == o || (o instanceof FactoryMethod f && method.equals(f.method)); + } + + @Override + public int hashCode() { + return method.hashCode(); + } + + @Override + public String toString() { + return signature(method.getName(), types); + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/GenerateRecordingModel.java b/solver/src/main/java/org/chocosolver/solver/spec/GenerateRecordingModel.java new file mode 100644 index 0000000000..a54c7a3e02 --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/GenerateRecordingModel.java @@ -0,0 +1,38 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Asks the annotation processor {@code org.chocosolver.codegen.RecordingModelProcessor} (module choco-codegen) to + * generate, at compile time, a subclass of the annotated class which journalizes every factory method. + * + * @author Charles Prud'homme + */ +@Retention(RetentionPolicy.SOURCE) +@Target(ElementType.TYPE) +@interface GenerateRecordingModel { + + /** + * @return the simple name of the generated class, in the package of the annotated class + */ + String name(); + + /** + * @return the interface whose default methods are the factory methods to journalize + */ + Class factories(); + + /** + * @return names of the factory methods without effect on the model, which are not journalized + */ + String[] excluded() default {}; +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/Header.java b/solver/src/main/java/org/chocosolver/solver/spec/Header.java new file mode 100644 index 0000000000..a35ae3119c --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/Header.java @@ -0,0 +1,56 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Settings; +import org.chocosolver.solver.SettingsBuilder; + +import java.util.Objects; + +/** + * What must be set on a model before replaying the steps of a {@link ModelSpec}. + * + * @param name name of the model + * @param settings settings of the model; immutable, hence shared by all instances, which may call its functions + * ({@code modelChecker}, {@code defaultSearch}, {@code environmentSupplier}...) concurrently: they + * must be stateless + * @param seed seed of the model; some propagators draw their random numbers from it when created + * @author Charles Prud'homme + */ +public record Header(String name, Settings settings, long seed) { + + public Header { + Objects.requireNonNull(name); + Objects.requireNonNull(settings); + } + + /** + * @return a header with default settings and seed 0 + */ + public static Header of(String name) { + return of(name, SettingsBuilder.init().build()); + } + + /** + * @return a header with seed 0 + */ + public static Header of(String name, Settings settings) { + return new Header(name, settings, 0L); + } + + public Header withName(String name) { + return new Header(name, settings, seed); + } + + public Header withSeed(long seed) { + return new Header(name, settings, seed); + } + + public Header withSettings(Settings settings) { + return new Header(name, settings, seed); + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/IdTable.java b/solver/src/main/java/org/chocosolver/solver/spec/IdTable.java new file mode 100644 index 0000000000..2bd60d332b --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/IdTable.java @@ -0,0 +1,82 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; + +import java.util.Arrays; +import java.util.IdentityHashMap; + +/** + * The mapping from identifiers to the objects of a model, shared by {@link Recorder} and {@link Replayer}. + * The reverse mapping ({@link #idOf(Object)}) is built on demand. + * + * @author Charles Prud'homme + */ +abstract class IdTable implements Resolver { + + private final Model model; + private Object[] objects = new Object[64]; + private int capacity; + private IdentityHashMap reverse; + + IdTable(Model model) { + this.model = model; + } + + /** + * Bind id to o. + * + * @return the object previously bound to id, or null + */ + final Object put(int id, Object o) { + if (id >= objects.length) { + objects = Arrays.copyOf(objects, Math.max(id + 1, objects.length * 2)); + } + Object prev = objects[id]; + objects[id] = o; + capacity = Math.max(capacity, id + 1); + reverse = null; + return prev; + } + + @Override + public final Model model() { + return model; + } + + @Override + public final Object get(int id) { + if (!isBound(id)) { + throw new SpecException("Unbound identifier " + id); + } + return objects[id]; + } + + @Override + public final int capacity() { + return capacity; + } + + @Override + public final boolean isBound(int id) { + return id >= 0 && id < capacity && objects[id] != null; + } + + @Override + public int idOf(Object o) { + if (reverse == null) { + reverse = new IdentityHashMap<>(); + for (int id = 0; id < capacity; id++) { + if (objects[id] != null) { + reverse.putIfAbsent(objects[id], id); + } + } + } + return reverse.getOrDefault(o, -1); + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/ModelSpec.java b/solver/src/main/java/org/chocosolver/solver/spec/ModelSpec.java new file mode 100644 index 0000000000..d424bdb96b --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/ModelSpec.java @@ -0,0 +1,135 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.Settings; + +import java.util.BitSet; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * An immutable description of a model: a {@link Header} and an ordered journal of {@link Step}s. + *

+ * Each call to {@link #instantiate()} builds a new, independent {@link Model}. Distinct instances can be built and + * solved concurrently, in distinct threads: they only share immutable data (settings, frozen tuples, ...). + *

+ * Replaying the same spec twice produces two models with identical structure (same variables, same constraints, + * in the same order), hence identical search when the search is deterministic. + * + * @param header what must be set on a model before replaying the steps + * @param steps the steps, replayed in order + * @param search the search, applied once the steps are replayed; null for the default search + * @author Charles Prud'homme + */ +public record ModelSpec(Header header, List steps, SearchDecl search) { + + /** + * @param header what must be set on a model before replaying the steps + * @param steps the steps, replayed in order + * @param search the search, applied once the steps are replayed; null for the default search + */ + public ModelSpec { + Objects.requireNonNull(header); + steps = List.copyOf(steps); + checkIdentifiers(steps); + } + + /** + * A spec with the default search. + */ + public ModelSpec(Header header, List steps) { + this(header, steps, null); + } + + /** + * @return this spec with another search + */ + public ModelSpec withSearch(SearchDecl search) { + return new ModelSpec(header, steps, search); + } + + /** + * @return this spec with another header + */ + public ModelSpec withHeader(Header header) { + return new ModelSpec(header, steps, search); + } + + /** + * @return this spec with other steps + */ + public ModelSpec withSteps(List steps) { + return new ModelSpec(header, steps, search); + } + + /** + * @return the spec rewritten by variant + */ + public ModelSpec with(Variant variant) { + return variant.apply(this); + } + + /** + * Build a new model from a variant of this spec. When several models are built from the same variant, prefer + * {@code with(variant)} once, then {@link #instantiate()} on the result. + * + * @return the mapping from the identifiers to the objects of the new model ({@link Resolver#model()}) + */ + public Resolver instantiate(Variant variant) { + return with(variant).instantiate(); + } + + /** + * Record the construction of a model. + * + * @param name name of the model + * @param settings settings of the model + * @param builder builds the model + * @return the spec of the model + * @throws SpecException if some operations could not be journalized + * @see RecordingModel + */ + public static ModelSpec record(String name, Settings settings, Consumer builder) { + RecordingModel model = new RecordingModel(name, settings); + builder.accept(model); + return model.snapshot(); + } + + /** + * Build a new model from this spec. + * + * @return the mapping from the identifiers to the objects of the new model ({@link Resolver#model()}) + * @throws SpecException if a step cannot be replayed + */ + public Resolver instantiate() { + Resolver resolver = new Replayer(header).replay(steps); + if (search != null) { + search.configure(resolver); + } + return resolver; + } + + /** + * Check that each identifier is defined before being used. + */ + private static void checkIdentifiers(List steps) { + BitSet defined = new BitSet(); + for (int i = 0; i < steps.size(); i++) { + Step step = steps.get(i); + final int s = i; + step.forEachUse(id -> { + if (!defined.get(id)) { + throw new SpecException("Step #" + s + " (" + step + ") uses undefined identifier " + id); + } + }); + step.forEachDef(defined::set); + } + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/Recorder.java b/solver/src/main/java/org/chocosolver/solver/spec/Recorder.java new file mode 100644 index 0000000000..f6f208eb6d --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/Recorder.java @@ -0,0 +1,516 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.sat.Literalizer; +import org.chocosolver.solver.Model; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.constraints.nary.cnf.ILogical; +import org.chocosolver.solver.constraints.nary.cnf.LogOp; +import org.chocosolver.solver.search.SearchState; +import org.chocosolver.solver.variables.BoolVar; +import org.chocosolver.solver.variables.IntVar; +import org.chocosolver.solver.variables.Task; +import org.chocosolver.solver.variables.Variable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.function.Supplier; + +/** + * Journalizes the construction of a {@link RecordingModel} into a {@link ModelSpec}. + *

+ * Only top-level operations are journalized: a depth counter ensures that the operations triggered internally by a + * factory (decompositions, intermediate variables, internal posts...) are not, since replaying the top-level + * operation replays them too. + *

+ * An operation that cannot be journalized (unknown object, unsupported argument...) does not interrupt the + * construction of the model: it is reported, and {@link #snapshot()} fails. + *

+ * The recorder is also the {@link Resolver} of the recorded model: identifiers of the spec map to its objects. + *

+ * Not thread-safe: a model is built by a single thread. + * + * @author Charles Prud'homme + */ +public final class Recorder extends IdTable { + + /** + * An operation which could not be journalized. + * + * @param step index of the step at which the operation occurred + * @param operation the operation + * @param reason why it could not be journalized + */ + public record Unsupported(int step, String operation, String reason) { + @Override + public String toString() { + return "#" + step + " " + operation + ": " + reason; + } + } + + /** + * Thrown when an argument cannot be captured. + */ + private static final class Uncapturable extends Exception { + Uncapturable(String message) { + super(message, null, false, false); + } + } + + private static final FactoryMethod INT_CONSTANT = FactoryMethod.of("intVar", int.class); + private static final FactoryMethod BOOL_NOT_VIEW = FactoryMethod.of("boolNotView", BoolVar.class); + + private Header header; + private final List steps = new ArrayList<>(); + private final List unsupported = new ArrayList<>(); + /** + * Reverse mapping, maintained eagerly: it is queried for each argument recorded. + */ + private final IdentityHashMap ids = new IdentityHashMap<>(); + private int depth; + + Recorder(Model model, Header header) { + super(model); + this.header = header; + } + + /** + * @return true when an operation is being journalized: nested operations must not be journalized + */ + public boolean busy() { + return depth > 0; + } + + /** + * @return the operations which could not be journalized so far + */ + public List unsupported() { + return Collections.unmodifiableList(unsupported); + } + + /** + * @param o an object of the model + * @return the identifier of o in the spec, or -1 if o is unknown + */ + @Override + public int idOf(Object o) { + return ids.getOrDefault(o, -1); + } + + /** + * @param vars variables of the recorded model + * @return their identifiers in the spec, e.g. to declare a {@link SearchDecl} + * @throws SpecException if a variable is unknown + */ + public int[] idsOf(Variable... vars) { + int[] r = new int[vars.length]; + for (int i = 0; i < vars.length; i++) { + r[i] = idOf(vars[i]); + if (r[i] < 0) { + throw new SpecException("Unknown variable " + vars[i].getName()); + } + } + return r; + } + + /** + * @return the spec of the model built so far + * @throws SpecException if some operations could not be journalized, or if the resolution has started + */ + public ModelSpec snapshot() { + if (model().getSolver().getSearchState() != SearchState.NEW) { + throw new SpecException("Cannot snapshot a model whose resolution has started"); + } + if (!unsupported.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append(unsupported.size()).append(" operation(s) cannot be journalized:"); + unsupported.stream().limit(10).forEach(u -> sb.append("\n\t").append(u)); + if (unsupported.size() > 10) { + sb.append("\n\t..."); + } + throw new SpecException(sb.toString()); + } + return new ModelSpec(header, steps); + } + + //*********************************************************************************** + // OPERATIONS + //*********************************************************************************** + + /** + * Journalize a call to a factory method. + * + * @param method the factory method + * @param body the actual call + * @param args the arguments of the call + * @return the result of the call + */ + R call(FactoryMethod method, Supplier body, Object[] args) { + Arg[] captured = new Arg[args.length]; + String failure = null; + try { + for (int i = 0; i < args.length; i++) { + captured[i] = capture(args[i]); + } + } catch (Uncapturable e) { + failure = e.getMessage(); + } + R result = nested(body); + int[] out = outputs(result); + if (failure == null) { + steps.add(new Step.Call(out, method, captured)); + } else { + unsupported(method.toString(), failure); + } + return result; + } + + /** + * Journalize a call to a factory method which returns nothing. + */ + void run(FactoryMethod method, Runnable body, Object[] args) { + call(method, () -> { + body.run(); + return null; + }, args); + } + + void post(Constraint[] cstrs, Runnable body) { + int[] cids = constraintIds("post", cstrs); + nested(body); + if (cids != null) { + steps.add(new Step.Post(cids)); + } + } + + void unpost(Constraint[] cstrs, Runnable body) { + int[] cids = constraintIds("unpost", cstrs); + nested(body); + if (cids != null) { + steps.add(new Step.Unpost(cids)); + } + } + + /** + * Journalize {@link Constraint#reify()}. + */ + public BoolVar reify(Constraint cstr, Supplier body) { + int cid = ref("reify", cstr); + BoolVar b = nested(body); + int out = idFor(b); + if (cid >= 0) { + steps.add(new Step.Reify(cid, out)); + } + return b; + } + + /** + * Journalize {@link Constraint#reifyWith}, {@link Constraint#implies} or {@link Constraint#impliedBy}. + */ + public void link(Step.Link.Kind kind, Constraint cstr, BoolVar bool, Runnable body) { + int[] r = refs(kind.name(), cstr, bool); + nested(body); + if (r != null) { + steps.add(new Step.Link(kind, r[0], r[1])); + } + } + + /** + * Journalize the creation of a task with its canonical constructor (the task is already built). + * + * @param performed the performed variable of an optional task, null for a mandatory task + */ + public void newTask(Task task, IntVar start, IntVar duration, IntVar end, BoolVar performed) { + if (busy()) { + return; + } + int[] r = performed == null + ? refs("new Task", start, duration, end) + : refs("new OptionalTask", start, duration, end, performed); + int out = idFor(task); + if (r != null) { + steps.add(new Step.NewTask(out, r[0], r[1], r[2], performed == null ? -1 : r[3])); + } + } + + void hook(String name, Object value, Runnable body) { + Arg captured = tryCapture("addHook(" + name + ")", value); + nested(body); + if (captured != null) { + steps.add(new Step.Hook(name, captured)); + } + } + + void removeAllHooks(Runnable body) { + nested(body); + steps.add(new Step.Hook(null, new Arg.Const(null))); + } + + void name(String name, Runnable body) { + nested(body); + header = header.withName(name); + } + + void group(String name, Variable[] vars, Runnable body) { + Arg captured = tryCapture("addGroup(" + name + ")", vars); + nested(body); + if (captured instanceof Arg.RefArray a) { + steps.add(new Step.Group(name, a)); + } else if (captured != null) { + unsupported("addGroup(" + name + ")", "not an array of variables"); + } + } + + void seed(long seed, Runnable body) { + nested(body); + steps.add(new Step.Seed(seed)); + } + + void objective(boolean maximize, Variable objective, Runnable body) { + int id = objective == null ? -1 : ref("setObjective", objective); + nested(body); + if (objective == null || id >= 0) { + steps.add(new Step.Objective(maximize, id)); + } + } + + /** + * Run body without journalizing what it does. + */ + R silently(Supplier body) { + return nested(body); + } + + void unsupported(String operation, String reason) { + unsupported.add(new Unsupported(steps.size(), operation, reason)); + } + + //*********************************************************************************** + // INTERNALS + //*********************************************************************************** + + private R nested(Supplier body) { + depth++; + try { + return body.get(); + } finally { + depth--; + } + } + + private void nested(Runnable body) { + nested(() -> { + body.run(); + return null; + }); + } + + /** + * @return the captured argument, or null (and the operation is reported) if it cannot be captured + */ + private Arg tryCapture(String operation, Object value) { + try { + return capture(value); + } catch (Uncapturable e) { + unsupported(operation, e.getMessage()); + return null; + } + } + + /** + * @return the identifier of o, or -1 (and the operation is reported) if o is unknown + */ + private int ref(String operation, Object o) { + try { + return refOf(o); + } catch (Uncapturable e) { + unsupported(operation, e.getMessage()); + return -1; + } + } + + /** + * @return the identifiers of objects, or null (and the operation is reported) if one is unknown + */ + private int[] refs(String operation, Object... objects) { + int[] r = new int[objects.length]; + for (int i = 0; i < objects.length; i++) { + r[i] = ref(operation, objects[i]); + if (r[i] < 0) { + return null; + } + } + return r; + } + + private int[] constraintIds(String operation, Constraint[] cstrs) { + return cstrs == null ? Specs.NO_INT : refs(operation, (Object[]) cstrs); + } + + private static boolean isModelType(Class c) { + return Variable.class.isAssignableFrom(c) || Constraint.class.isAssignableFrom(c) || Task.class.isAssignableFrom(c); + } + + private static boolean isModelObject(Object o) { + return o != null && isModelType(o.getClass()); + } + + private Arg capture(Object v) throws Uncapturable { + if (isModelObject(v)) { + return new Arg.Ref(refOf(v)); + } + if (v instanceof LogOp op) { + return logic(op); + } + if (v instanceof Literalizer.BoolLit l) { + return new Arg.Literal(Arg.Literal.Kind.BOOL, refOf(l.cpVar), 0); + } + if (v instanceof Literalizer.IntEqLit l) { + return new Arg.Literal(Arg.Literal.Kind.INT_EQ, refOf(l.cpVar), l.val); + } + if (v instanceof Literalizer.IntLeLit l) { + return new Arg.Literal(Arg.Literal.Kind.INT_LE, refOf(l.cpVar), l.val); + } + if (v instanceof List list) { + int[] lids = new int[list.size()]; + for (int i = 0; i < lids.length; i++) { + if (!isModelObject(list.get(i))) { + throw new Uncapturable("list of " + (list.get(i) == null ? "null" : list.get(i).getClass().getSimpleName())); + } + lids[i] = refOf(list.get(i)); + } + return new Arg.RefList(lids); + } + if (v != null && v.getClass().isArray()) { + Class ct = v.getClass().getComponentType(); + if (isModelType(ct)) { + return new Arg.RefArray(ct, refsOf((Object[]) v)); + } + if (ct.isArray() && isModelType(ct.getComponentType())) { + Object[][] m = (Object[][]) v; + int[][] mids = new int[m.length][]; + for (int i = 0; i < m.length; i++) { + mids[i] = refsOf(m[i]); + } + return new Arg.RefMatrix(ct.getComponentType(), mids); + } + } + try { + return new Arg.Const(v); + } catch (SpecException e) { + throw new Uncapturable(e.getMessage()); + } + } + + private Arg.Logic logic(LogOp op) throws Uncapturable { + ILogical[] children = op.getChildren(); + Arg[] args = new Arg[children.length]; + for (int i = 0; i < children.length; i++) { + if (children[i] instanceof LogOp sub) { + args[i] = logic(sub); + } else if (children[i] instanceof BoolVar) { + args[i] = new Arg.Ref(refOf(children[i])); + } else { + throw new Uncapturable("logical tree with a " + children[i].getClass().getSimpleName()); + } + } + return new Arg.Logic(op.is(LogOp.Operator.AND), op.isNot(), args); + } + + private int[] refsOf(Object[] objects) throws Uncapturable { + if (objects == null) { + throw new Uncapturable("null array"); + } + int[] r = new int[objects.length]; + for (int i = 0; i < objects.length; i++) { + r[i] = refOf(objects[i]); + } + return r; + } + + /** + * @return the identifier of o; an unknown object is derived, when possible, from known objects + * (cached constant, negation of a known boolean variable) + */ + private int refOf(Object o) throws Uncapturable { + if (o == null) { + throw new Uncapturable("null object"); + } + Integer id = ids.get(o); + if (id != null) { + return id; + } + if (o instanceof Variable v) { + if (v.getModel() != model()) { + throw new Uncapturable("variable " + v.getName() + " belongs to another model"); + } + if (v instanceof IntVar iv && v.isAConstant()) { + int value = iv.getValue(); + if (model().getCachedConstants().get(value) == v) { + return derive(INT_CONSTANT, v, new Arg.Const(value)); + } + } + if (v instanceof BoolVar b && b.hasNot()) { + Integer nid = ids.get(b.not()); + if (nid != null) { + return derive(BOOL_NOT_VIEW, v, new Arg.Ref(nid)); + } + } + throw new Uncapturable("unknown variable " + v.getName() + + " (created outside of the factories, or internally by a factory)"); + } + if (o instanceof Constraint c) { + throw new Uncapturable("unknown constraint " + c.getName() + + " (custom constraint not built with Model.custom(...), or created internally by a factory)"); + } + throw new Uncapturable("unknown " + o.getClass().getSimpleName()); + } + + /** + * Journalize a call which returns the already existing object o (e.g., a cached constant). + */ + private int derive(FactoryMethod method, Object o, Arg arg) { + int id = idFor(o); + steps.add(new Step.Call(new int[]{id}, method, new Arg[]{arg})); + return id; + } + + /** + * @return the identifier of o, a new one if o is unknown + */ + private int idFor(Object o) { + Integer id = ids.get(o); + if (id == null) { + id = capacity(); + ids.put(o, id); + put(id, o); + } + return id; + } + + /** + * @return the identifiers of the model objects returned by a call (flattened for arrays) + */ + private int[] outputs(Object result) { + if (isModelObject(result)) { + return new int[]{idFor(result)}; + } + if (result instanceof Object[] array && isModelType(Specs.elementType(array.getClass()))) { + List flat = new ArrayList<>(); + Specs.flatten(array, flat); + int[] out = new int[flat.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = flat.get(i) == null ? -1 : idFor(flat.get(i)); + } + return out; + } + return Specs.NO_INT; + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/Replayer.java b/solver/src/main/java/org/chocosolver/solver/spec/Replayer.java new file mode 100644 index 0000000000..bd7c66c2ec --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/Replayer.java @@ -0,0 +1,215 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.sat.Literalizer; +import org.chocosolver.solver.Model; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.constraints.nary.cnf.ILogical; +import org.chocosolver.solver.constraints.nary.cnf.LogOp; +import org.chocosolver.solver.variables.Group; +import org.chocosolver.solver.variables.IntVar; +import org.chocosolver.solver.variables.OptionalTask; +import org.chocosolver.solver.variables.Task; +import org.chocosolver.solver.variables.Variable; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.List; + +/** + * Replays the steps of a {@link ModelSpec} on a new model. + * Not thread-safe: one replayer per instantiation. + * + * @author Charles Prud'homme + */ +final class Replayer extends IdTable { + + Replayer(Header header) { + super(new Model(header.name(), header.settings())); + model().setSeed(header.seed()); + } + + Resolver replay(List steps) { + for (int i = 0; i < steps.size(); i++) { + Step step = steps.get(i); + try { + apply(step); + } catch (VirtualMachineError e) { + // e.g., OutOfMemoryError: not a problem of the spec + throw e; + } catch (Throwable t) { + throw new SpecException("Step #" + i + " (" + step + ") failed: " + t.getMessage(), t); + } + } + return this; + } + + private void apply(Step step) throws Throwable { + Model model = model(); + if (step instanceof Step.Call c) { + Class[] types = c.method().rawParameterTypes(); + Arg[] args = c.rawArgs(); + Object[] values = new Object[args.length]; + for (int k = 0; k < args.length; k++) { + values[k] = materialize(args[k], types[k]); + } + bindOutputs(c.rawOut(), c.method().invoke(model, values)); + } else if (step instanceof Step.Post p) { + model.post(constraints(p.rawCstrs())); + } else if (step instanceof Step.Reify r) { + bind(r.out(), constraint(r.cstr()).reify()); + } else if (step instanceof Step.Link l) { + Constraint c = constraint(l.cstr()); + switch (l.kind()) { + case REIFY_WITH -> c.reifyWith(boolVar(l.bool())); + case IMPLIES -> c.implies(boolVar(l.bool())); + case IMPLIED_BY -> c.impliedBy(boolVar(l.bool())); + } + } else if (step instanceof Step.Unpost u) { + model.unpost(constraints(u.rawCstrs())); + } else if (step instanceof Step.NewTask t) { + IntVar s = intVar(t.start()); + IntVar d = intVar(t.duration()); + IntVar e = intVar(t.end()); + bind(t.out(), t.performed() < 0 ? new Task(s, d, e) : new OptionalTask(s, d, e, boolVar(t.performed()))); + } else if (step instanceof Step.Custom c) { + Constraint cstr = c.factory().apply(this); + if (cstr == null) { + throw new SpecException("Custom constraint " + c.name() + " built nothing"); + } + if (c.out() >= 0) { + bind(c.out(), cstr); + } + } else if (step instanceof Step.Hook h) { + Object value = materialize(h.value(), Object.class); + if (h.name() == null) { + model.removeAllHooks(); + } else if (value == null) { + model.removeHook(h.name()); + } else { + model.addHook(h.name(), value); + } + } else if (step instanceof Step.Seed s) { + model.setSeed(s.seed()); + } else if (step instanceof Step.Objective o) { + if (o.var() < 0) { + model.clearObjective(); + } else { + model.setObjective(o.maximize(), get(o.var(), Variable.class)); + } + } else if (step instanceof Step.Group g) { + model.addGroup(new Group<>(g.name(), (Variable[]) materialize(g.vars(), Variable[].class))); + } else { + throw new SpecException("Unknown step " + step); + } + } + + private Constraint[] constraints(int[] cids) { + Constraint[] cs = new Constraint[cids.length]; + for (int i = 0; i < cids.length; i++) { + cs[i] = constraint(cids[i]); + } + return cs; + } + + private Object materialize(Arg arg, Class type) { + Object value; + if (arg instanceof Arg.Ref r) { + value = get(r.id()); + } else if (arg instanceof Arg.RefArray a) { + value = array(a.componentType(), a.rawIds()); + } else if (arg instanceof Arg.RefMatrix a) { + int[][] rows = a.rawIds(); + Object[] matrix = (Object[]) Array.newInstance(a.componentType().arrayType(), rows.length); + for (int i = 0; i < rows.length; i++) { + matrix[i] = array(a.componentType(), rows[i]); + } + value = matrix; + } else if (arg instanceof Arg.RefList l) { + List list = new ArrayList<>(l.rawIds().length); + for (int id : l.rawIds()) { + list.add(get(id)); + } + value = list; + } else if (arg instanceof Arg.Const c) { + value = c.value(); + } else if (arg instanceof Arg.Logic l) { + value = logic(l); + } else if (arg instanceof Arg.Literal l) { + value = switch (l.kind()) { + case BOOL -> new Literalizer.BoolLit(boolVar(l.var())); + case INT_EQ -> new Literalizer.IntEqLit(intVar(l.var()), l.value()); + case INT_LE -> new Literalizer.IntLeLit(intVar(l.var()), l.value()); + }; + } else { + throw new SpecException("Unknown argument " + arg); + } + if (value == null ? type.isPrimitive() : !Specs.wrap(type).isInstance(value)) { + throw new SpecException("Argument " + arg + " is not a " + type.getSimpleName()); + } + return value; + } + + private Object[] array(Class componentType, int[] aids) { + Object[] array = (Object[]) Array.newInstance(componentType, aids.length); + for (int i = 0; i < aids.length; i++) { + try { + array[i] = get(aids[i]); + } catch (ArrayStoreException e) { + throw new SpecException("Identifier " + aids[i] + " is bound to a " + + get(aids[i]).getClass().getSimpleName() + ", not a " + componentType.getSimpleName()); + } + } + return array; + } + + private LogOp logic(Arg.Logic l) { + Arg[] children = l.rawChildren(); + ILogical[] ops = new ILogical[children.length]; + for (int i = 0; i < children.length; i++) { + ops[i] = children[i] instanceof Arg.Logic sub ? logic(sub) : boolVar(((Arg.Ref) children[i]).id()); + } + if (l.and()) { + return l.negated() ? LogOp.nand(ops) : LogOp.and(ops); + } + return l.negated() ? LogOp.nor(ops) : LogOp.or(ops); + } + + private void bindOutputs(int[] out, Object result) { + if (out.length == 0) { + return; + } + if (result instanceof Object[] array) { + List flat = new ArrayList<>(out.length); + Specs.flatten(array, flat); + if (flat.size() != out.length) { + throw new SpecException("Expected " + out.length + " outputs, got " + flat.size()); + } + for (int i = 0; i < out.length; i++) { + if (out[i] >= 0) { + bind(out[i], flat.get(i)); + } + } + } else { + if (out.length != 1) { + throw new SpecException("Expected " + out.length + " outputs, got 1"); + } + bind(out[0], result); + } + } + + private void bind(int id, Object o) { + if (o == null) { + throw new SpecException("Cannot bind identifier " + id + " to null"); + } + Object prev = put(id, o); + if (prev != null && prev != o) { + throw new SpecException("Divergence: identifier " + id + " is bound to " + prev + ", now to " + o); + } + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/Resolver.java b/solver/src/main/java/org/chocosolver/solver/spec/Resolver.java new file mode 100644 index 0000000000..4d5325bf9d --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/Resolver.java @@ -0,0 +1,89 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.variables.BoolVar; +import org.chocosolver.solver.variables.IntVar; +import org.chocosolver.solver.variables.Task; + +/** + * Maps the identifiers of a {@link ModelSpec} to the objects of one instantiated model. + * + * @author Charles Prud'homme + */ +public interface Resolver { + + /** + * @return the model under instantiation (or instantiated) + */ + Model model(); + + /** + * @param id an identifier + * @return the object bound to id + * @throws SpecException if id is not bound + */ + Object get(int id); + + /** + * @param id an identifier + * @param type expected type of the object + * @return the object bound to id + * @throws SpecException if id is not bound or not of the expected type + */ + default T get(int id, Class type) { + Object o = get(id); + if (!type.isInstance(o)) { + throw new SpecException("Identifier " + id + " is bound to a " + o.getClass().getSimpleName() + + ", not a " + type.getSimpleName()); + } + return type.cast(o); + } + + /** + * @return an upper bound (exclusive) of the identifiers + */ + int capacity(); + + /** + * @param id an identifier + * @return true if id is bound to an object + */ + boolean isBound(int id); + + /** + * @param o an object of the model + * @return the (first) identifier bound to o, or -1 if none + */ + int idOf(Object o); + + default IntVar intVar(int id) { + return get(id, IntVar.class); + } + + default IntVar[] intVars(int... ids) { + IntVar[] vars = new IntVar[ids.length]; + for (int i = 0; i < ids.length; i++) { + vars[i] = intVar(ids[i]); + } + return vars; + } + + default BoolVar boolVar(int id) { + return get(id, BoolVar.class); + } + + default Constraint constraint(int id) { + return get(id, Constraint.class); + } + + default Task task(int id) { + return get(id, Task.class); + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/SearchDecl.java b/solver/src/main/java/org/chocosolver/solver/spec/SearchDecl.java new file mode 100644 index 0000000000..551ce179c2 --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/SearchDecl.java @@ -0,0 +1,109 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.Solver; +import org.chocosolver.solver.search.strategy.Search; +import org.chocosolver.solver.search.strategy.SearchParams; +import org.chocosolver.solver.search.strategy.strategy.AbstractStrategy; +import org.chocosolver.solver.variables.IntVar; + +import java.util.Objects; +import java.util.function.Consumer; + +/** + * The search of a {@link ModelSpec}: configures the solver of each instance, once all the steps are replayed. + *

+ * A search declaration is shared by all the instances, possibly configured concurrently: it must be stateless and + * must only use the objects of the instance, obtained from the {@link Resolver} (never objects of another model, + * which is checked by assertions when the strategy is set). + *

+ * Search strategies cannot be journalized: they are declared, either from the catalog of this interface + * (heuristics and identifiers of variables), or as a function of the model or of the {@link Resolver}. + * + * @author Charles Prud'homme + */ +@FunctionalInterface +public interface SearchDecl { + + /** + * Configure the solver of an instance. + * + * @param resolver maps the identifiers of the spec to the objects of the instance + */ + void configure(Resolver resolver); + + /** + * @return a declaration which applies this one, then next + */ + default SearchDecl andThen(SearchDecl next) { + Objects.requireNonNull(next); + return r -> { + configure(r); + next.configure(r); + }; + } + + /** + * A declaration based on the model only, e.g. {@code SearchDecl.of(Search::defaultSearch)} or + * {@code SearchDecl.of(BlackBoxConfigurator.forCOP()::make)}: the model of each instance holds the same + * variables, groups, hooks and objective as the recorded one. + * + * @param configurer configures a model; must be stateless + */ + static SearchDecl of(Consumer configurer) { + Objects.requireNonNull(configurer); + return r -> configurer.accept(r.model()); + } + + /** + * An integer search strategy over the variables identified by ids. + * + * @param varSel variable selection + * @param valSel value selection + * @param ids identifiers of the decision variables, in order + */ + static SearchDecl intVarSearch(SearchParams.VarSelConf varSel, SearchParams.ValSelConf valSel, int... ids) { + Objects.requireNonNull(varSel); + Objects.requireNonNull(valSel); + int[] vids = ids.clone(); + return r -> { + IntVar[] vars = r.intVars(vids); + r.model().getSolver().setSearch(varSel.make().apply(vars, valSel.make().apply(r.model()))); + }; + } + + /** + * Add a restart policy. + * + * @param conf the restart policy + */ + static SearchDecl restarts(SearchParams.ResConf conf) { + Objects.requireNonNull(conf); + return r -> { + Solver solver = r.model().getSolver(); + solver.addRestarter(conf.make().apply(solver)); + }; + } + + /** + * Wrap the current search strategy with last conflict. + * + * @param k number of conflicting variables to remember + */ + static SearchDecl lastConflict(int k) { + return r -> { + Solver solver = r.model().getSolver(); + AbstractStrategy current = solver.getSearch(); + if (current == null) { + throw new SpecException("lastConflict requires a search strategy to wrap"); + } + solver.setSearch(Search.lastConflict(current, k)); + }; + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/SharedObjects.java b/solver/src/main/java/org/chocosolver/solver/spec/SharedObjects.java new file mode 100644 index 0000000000..33afd410cf --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/SharedObjects.java @@ -0,0 +1,252 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Settings; +import org.chocosolver.solver.constraints.nary.automata.FA.utils.ICounter; + +import java.lang.invoke.MethodHandle; +import java.lang.ref.Reference; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; + +/** + * Diagnostic tool: finds the objects shared by two object graphs (typically, two models instantiated from the same + * {@link ModelSpec}), which must be immutable for the models to be solved concurrently. + *

+ * Objects are compared by identity. The traversal follows instance fields (static fields are ignored), array + * elements, the elements of collections and maps, and referents. Objects of classes which cannot be introspected + * (e.g., of the JDK) are compared but not traversed. + *

+ * Are neither reported nor traversed: immutable objects (classes, methods, the values shared according to the policy + * of {@link Values}), and the data shared by design ({@link Settings}, {@link ICounter}, {@link FactoryMethod}, frozen + * tuples and diagrams). What the latter own is not reported either, even when reached by another path. + * + * @author Charles Prud'homme + */ +public final class SharedObjects { + + private static final Map, Field[]> FIELDS = new ConcurrentHashMap<>(); + + private SharedObjects() { + } + + /** + * A shared object. + * + * @param type type of the object + * @param path a path to the object from the root of the second graph + */ + public record Shared(Class type, String path) { + @Override + public String toString() { + return type.getName() + " at " + path; + } + } + + /** + * @param a root of the first graph + * @param b root of the second graph + * @param allowed types whose instances may be shared (neither reported nor traversed) + * @return the mutable objects reachable from both a and b, one example per type + */ + public static List between(Object a, Object b, Set> allowed) { + return between(a, b, allowed, List.of()); + } + + /** + * @param a root of the first graph + * @param b root of the second graph + * @param allowed types whose instances may be shared (neither reported nor traversed) + * @param byDesign objects which may be shared, e.g. the constants of a {@link ModelSpec} (see + * {@link #constantsOf(ModelSpec)}): neither they nor what they own are reported + * @return the mutable objects reachable from both a and b, one example per type + */ + public static List between(Object a, Object b, Set> allowed, Collection byDesign) { + Set common = common(a, b, allowed, byDesign); + if (common.isEmpty()) { + return List.of(); + } + // a path to one shared object per type + Map, Shared> shared = new LinkedHashMap<>(); + traverse(b, allowed, identitySet(), identitySet(), (o, node) -> { + if (common.contains(o)) { + shared.putIfAbsent(o.getClass(), new Shared(o.getClass(), node.path())); + } + }, true); + return new ArrayList<>(shared.values()); + } + + /** + * @return the mutable objects reachable from both a and b, but those shared by design and what they + * own (see {@link #between(Object, Object, Set, Collection)}) + */ + public static Set common(Object a, Object b, Set> allowed, Collection byDesign) { + Set designed = identitySet(); + designed.addAll(byDesign); + // no path kept, to save memory + Set fromA = identitySet(); + traverse(a, allowed, fromA, designed, null, false); + Set common = identitySet(); + traverse(b, allowed, identitySet(), designed, (o, node) -> { + if (fromA.contains(o)) { + common.add(o); + } + }, false); + fromA.clear(); + // what the data shared by design own may be reached by other paths (e.g., the matrix of frozen tuples + // referenced by a propagator): it is shared by design too + if (!common.isEmpty()) { + Set owned = identitySet(); + for (Object d : designed) { + traverse(d, null, owned, null, null, false); + } + common.removeAll(owned); + } + return common; + } + + /** + * @return the constants of spec, which are shared by design by its instances + */ + public static List constantsOf(ModelSpec spec) { + List constants = new ArrayList<>(); + for (Step s : spec.steps()) { + if (s instanceof Step.Call c) { + for (Arg arg : c.rawArgs()) { + if (arg instanceof Arg.Const k && k.rawValue() != null) { + constants.add(k.rawValue()); + } + } + } + } + return constants; + } + + private static Set identitySet() { + return Collections.newSetFromMap(new IdentityHashMap<>()); + } + + private record Node(Object object, Node parent, String edge) { + String path() { + return parent == null ? edge : parent.path() + edge; + } + } + + /** + * Traverse the graph of root. When allowed is not null, immutable objects are skipped; when + * byDesign is not null, the data shared by design are skipped and collected in it. Paths from the root + * are kept only if paths is set. + */ + private static void traverse(Object root, Set> allowed, Set seen, Set byDesign, + BiConsumer visitor, boolean paths) { + Deque stack = new ArrayDeque<>(); + stack.push(new Node(root, null, root.getClass().getSimpleName())); + while (!stack.isEmpty()) { + Node node = stack.pop(); + Object o = node.object; + if (o == null || !seen.add(o)) { + continue; + } + if (allowed != null && isImmutable(o, allowed)) { + continue; + } + if (byDesign != null && (byDesign.contains(o) || isSharedByDesign(o))) { + byDesign.add(o); + continue; + } + if (visitor != null) { + visitor.accept(o, node); + } + Node parent = paths ? node : null; + Class c = o.getClass(); + if (c.isArray()) { + if (!c.getComponentType().isPrimitive()) { + int n = Array.getLength(o); + for (int i = 0; i < n; i++) { + stack.push(new Node(Array.get(o, i), parent, paths ? "[" + i + "]" : null)); + } + } + } else if (o instanceof Collection col) { + try { + for (Object e : col) { + stack.push(new Node(e, parent, paths ? "{}" : null)); + } + } catch (RuntimeException ignored) { + // concurrent or lazy collection: elements not traversed + } + } else if (o instanceof Reference ref) { + stack.push(new Node(ref.get(), parent, paths ? ".get()" : null)); + } else if (o instanceof Map map) { + try { + for (Map.Entry e : map.entrySet()) { + stack.push(new Node(e.getKey(), parent, paths ? "{key}" : null)); + stack.push(new Node(e.getValue(), parent, paths ? "{" + e.getKey() + "}" : null)); + } + } catch (RuntimeException ignored) { + // elements not traversed + } + } else { + for (Field f : fields(c)) { + try { + stack.push(new Node(f.get(o), parent, paths ? "." + f.getName() : null)); + } catch (IllegalAccessException ignored) { + // not traversed + } + } + } + } + } + + private static boolean isSharedByDesign(Object o) { + return o instanceof Settings || o instanceof ICounter || o instanceof FactoryMethod || Values.isShared(o); + } + + private static boolean isImmutable(Object o, Set> allowed) { + if (o instanceof String || o instanceof Number || o instanceof Boolean || o instanceof Character + || o instanceof Enum || o instanceof Class || o instanceof Method || o instanceof MethodHandle) { + return true; + } + for (Class a : allowed) { + if (a.isInstance(o)) { + return true; + } + } + return !(o instanceof Collection) && !(o instanceof Map) && Specs.isStateless(o.getClass()); + } + + /** + * @return the accessible instance fields of c and its superclasses + */ + private static Field[] fields(Class c) { + return FIELDS.computeIfAbsent(c, k -> { + List list = new ArrayList<>(); + for (Class x = k; x != null; x = x.getSuperclass()) { + for (Field f : x.getDeclaredFields()) { + if (!Modifier.isStatic(f.getModifiers()) && !f.getType().isPrimitive() && f.trySetAccessible()) { + list.add(f); + } + } + } + return list.toArray(new Field[0]); + }); + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/SpecBuilder.java b/solver/src/main/java/org/chocosolver/solver/spec/SpecBuilder.java new file mode 100644 index 0000000000..518d897103 --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/SpecBuilder.java @@ -0,0 +1,322 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.sat.Literalizer; +import org.chocosolver.solver.IModel; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.constraints.nary.cnf.LogOp; +import org.chocosolver.solver.variables.BoolVar; +import org.chocosolver.solver.variables.OptionalTask; +import org.chocosolver.solver.variables.Task; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Helps building a {@link ModelSpec} by hand: allocates identifiers and resolves the overload of factory methods + * from the types of the arguments. + *

+ * Arguments passed to {@code call*} methods are either {@link Arg}s or constants (wrapped in {@link Arg.Const}). + *

{@code
+ * SpecBuilder b = new SpecBuilder(Header.of("demo"));
+ * int[] x = b.callArray(3, "intVarArray", "x", 3, 1, 3);
+ * b.post(b.call("allDifferent", b.refs(IntVar.class, x)));
+ * ModelSpec spec = b.build();
+ * }
+ * + * @author Charles Prud'homme + */ +public final class SpecBuilder { + + /** + * The (non-static) methods of {@link IModel}, by name. + */ + private static final Map> METHODS = Arrays.stream(IModel.class.getMethods()) + .filter(m -> !Modifier.isStatic(m.getModifiers())) + .collect(Collectors.groupingBy(Method::getName)); + + private final Header header; + private final List steps = new ArrayList<>(); + /** + * Static type of each identifier, to resolve overloads. + */ + private final Map> types = new HashMap<>(); + private int nextId; + private SearchDecl search; + + public SpecBuilder(Header header) { + this.header = header; + } + + /** + * @return a builder which continues spec: its steps, identifiers and search are kept + */ + public static SpecBuilder from(ModelSpec spec) { + SpecBuilder b = new SpecBuilder(spec.header()); + b.search = spec.search(); + for (Step s : spec.steps()) { + b.steps.add(s); + if (s instanceof Step.Call c) { + Class t = Specs.elementType(c.method().returnType()); + for (int o : c.rawOut()) { + if (o >= 0) { + b.types.put(o, t); + } + } + } else if (s instanceof Step.Reify r) { + b.types.put(r.out(), BoolVar.class); + } else if (s instanceof Step.NewTask t) { + b.types.put(t.out(), t.performed() < 0 ? Task.class : OptionalTask.class); + } else if (s instanceof Step.Custom c) { + b.types.put(c.out(), Constraint.class); + } + s.forEachDef(id -> b.nextId = Math.max(b.nextId, id + 1)); + } + return b; + } + + /** + * Set the search of the spec. + */ + public SpecBuilder search(SearchDecl search) { + this.search = search; + return this; + } + + /** + * @return a reference to identifier id + */ + public Arg.Ref ref(int id) { + return new Arg.Ref(id); + } + + /** + * @return a reference to an array of objects of type componentType + */ + public Arg.RefArray refs(Class componentType, int... ids) { + return new Arg.RefArray(componentType, ids); + } + + /** + * @return a reference to a matrix of objects of type componentType + */ + public Arg.RefMatrix refs(Class componentType, int[][] ids) { + return new Arg.RefMatrix(componentType, ids); + } + + /** + * Call a factory method whose result is ignored (or which returns nothing). + */ + public SpecBuilder effect(String name, Object... args) { + return effect(resolve(name, args), args); + } + + public SpecBuilder effect(FactoryMethod m, Object... args) { + steps.add(new Step.Call(Specs.NO_INT, m, toArgs(args))); + return this; + } + + /** + * Call a factory method which returns an object (variable, constraint, task...). + * + * @return the identifier of the object + */ + public int call(String name, Object... args) { + return call(resolve(name, args), args); + } + + public int call(FactoryMethod m, Object... args) { + int id = define(m.returnType()); + steps.add(new Step.Call(new int[]{id}, m, toArgs(args))); + return id; + } + + /** + * Call a factory method which returns an array (or a matrix, flattened row by row) of size objects. + * + * @return the identifiers of the objects + */ + public int[] callArray(int size, String name, Object... args) { + return callArray(size, resolve(name, args), args); + } + + public int[] callArray(int size, FactoryMethod m, Object... args) { + int[] out = new int[size]; + Class component = Specs.elementType(m.returnType()); + for (int i = 0; i < size; i++) { + out[i] = define(component); + } + steps.add(new Step.Call(out, m, toArgs(args))); + return out; + } + + public SpecBuilder post(int... cstrs) { + steps.add(new Step.Post(cstrs)); + return this; + } + + /** + * @return the identifier of the boolean variable reifying cstr + */ + public int reify(int cstr) { + int id = define(BoolVar.class); + steps.add(new Step.Reify(cstr, id)); + return id; + } + + public SpecBuilder reifyWith(int cstr, int bool) { + return link(Step.Link.Kind.REIFY_WITH, cstr, bool); + } + + public SpecBuilder implies(int cstr, int bool) { + return link(Step.Link.Kind.IMPLIES, cstr, bool); + } + + public SpecBuilder impliedBy(int cstr, int bool) { + return link(Step.Link.Kind.IMPLIED_BY, cstr, bool); + } + + private SpecBuilder link(Step.Link.Kind kind, int cstr, int bool) { + steps.add(new Step.Link(kind, cstr, bool)); + return this; + } + + public SpecBuilder unpost(int... cstrs) { + steps.add(new Step.Unpost(cstrs)); + return this; + } + + /** + * @return the identifier of the new task + */ + public int newTask(int start, int duration, int end) { + int id = define(Task.class); + steps.add(new Step.NewTask(id, start, duration, end)); + return id; + } + + /** + * @return the identifier of the new optional task + */ + public int newOptionalTask(int start, int duration, int end, int performed) { + int id = define(OptionalTask.class); + steps.add(new Step.NewTask(id, start, duration, end, performed)); + return id; + } + + /** + * @return the identifier of the custom constraint + */ + public int custom(String name, Function factory) { + int id = define(Constraint.class); + steps.add(new Step.Custom(id, name, factory)); + return id; + } + + public ModelSpec build() { + return new ModelSpec(header, steps, search); + } + + /** + * @return a new identifier, of static type type + */ + private int define(Class type) { + int id = nextId++; + types.put(id, type); + return id; + } + + private static Arg[] toArgs(Object[] args) { + Arg[] res = new Arg[args.length]; + for (int i = 0; i < args.length; i++) { + res[i] = args[i] instanceof Arg a ? a : new Arg.Const(args[i]); + } + return res; + } + + /** + * Resolve the overload of name which accepts args, choosing the most specific one as javac would. + */ + private FactoryMethod resolve(String name, Object[] args) { + List candidates = new ArrayList<>(); + for (Method m : METHODS.getOrDefault(name, List.of())) { + if (m.getParameterCount() == args.length && accepts(m.getParameterTypes(), args)) { + candidates.add(m); + } + } + List mostSpecific = new ArrayList<>(); + for (Method m : candidates) { + if (candidates.stream().allMatch(o -> moreSpecific(m, o))) { + mostSpecific.add(m); + } + } + if (mostSpecific.size() != 1) { + throw new SpecException((candidates.isEmpty() ? "No" : "Ambiguous") + " factory method " + name + + " for arguments " + Arrays.stream(args).map(this::describe).collect(Collectors.joining(", ", "(", ")")) + + (candidates.isEmpty() ? "" : ": " + candidates)); + } + return FactoryMethod.of(mostSpecific.get(0)); + } + + private static boolean moreSpecific(Method m, Method o) { + Class[] pm = m.getParameterTypes(); + Class[] po = o.getParameterTypes(); + for (int i = 0; i < pm.length; i++) { + if (!po[i].isAssignableFrom(pm[i])) return false; + } + return true; + } + + private boolean accepts(Class[] params, Object[] args) { + for (int i = 0; i < params.length; i++) { + Class t = typeOf(args[i]); + if (t == null ? params[i].isPrimitive() : !Specs.wrap(params[i]).isAssignableFrom(t)) { + return false; + } + } + return true; + } + + /** + * @return the static type of an argument, null for a null constant + */ + private Class typeOf(Object arg) { + if (arg instanceof Arg.Ref r) { + Class t = types.get(r.id()); + if (t == null) { + throw new SpecException("Undefined identifier " + r.id()); + } + return t; + } else if (arg instanceof Arg.RefArray a) { + return a.componentType().arrayType(); + } else if (arg instanceof Arg.RefMatrix a) { + return a.componentType().arrayType().arrayType(); + } else if (arg instanceof Arg.RefList) { + return List.class; + } else if (arg instanceof Arg.Logic) { + return LogOp.class; + } else if (arg instanceof Arg.Literal) { + return Literalizer.class; + } else if (arg instanceof Arg.Const c) { + return typeOf(c.rawValue()); + } + return arg == null ? null : arg.getClass(); + } + + private String describe(Object arg) { + Class t = typeOf(arg); + return t == null ? "null" : t.getSimpleName(); + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/SpecException.java b/solver/src/main/java/org/chocosolver/solver/spec/SpecException.java new file mode 100644 index 0000000000..c9a6057f75 --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/SpecException.java @@ -0,0 +1,26 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.exception.SolverException; + +/** + * Thrown when a {@link ModelSpec} is ill-formed or cannot be instantiated. + * + * @author Charles Prud'homme + */ +public class SpecException extends SolverException { + + public SpecException(String message) { + super(message); + } + + public SpecException(String message, Throwable cause) { + super(message); + initCause(cause); + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/SpecSolution.java b/solver/src/main/java/org/chocosolver/solver/spec/SpecSolution.java new file mode 100644 index 0000000000..46b38fd6b2 --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/SpecSolution.java @@ -0,0 +1,102 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Solution; +import org.chocosolver.solver.variables.IntVar; + +import java.util.Arrays; + +/** + * A solution expressed with the identifiers of a {@link ModelSpec}: found by one instance, it can be read by any + * other instance, or by the recorded model. + *

+ * Only integer (and boolean) variables are stored. Immutable, hence shareable among threads. + * + * @author Charles Prud'homme + */ +public final class SpecSolution { + + /** + * Value of each identifier, meaningful only when stored. + */ + private final int[] values; + private final boolean[] stored; + + private SpecSolution(int[] values, boolean[] stored) { + this.values = values; + this.stored = stored; + } + + /** + * Record the current values of the instantiated integer variables of an instance, typically when a solution + * is found. + * + * @param resolver the resolver of the instance + * @return the solution + */ + public static SpecSolution record(Resolver resolver) { + int n = resolver.capacity(); + int[] values = new int[n]; + boolean[] stored = new boolean[n]; + for (int id = 0; id < n; id++) { + if (resolver.isBound(id) && resolver.get(id) instanceof IntVar v && v.isInstantiated()) { + values[id] = v.getValue(); + stored[id] = true; + } + } + return new SpecSolution(values, stored); + } + + /** + * @param id an identifier + * @return true if this solution holds a value for id + */ + public boolean contains(int id) { + return id >= 0 && id < stored.length && stored[id]; + } + + /** + * @param id identifier of an integer variable + * @return its value in this solution + * @throws SpecException if this solution holds no value for id + */ + public int valueOf(int id) { + if (!contains(id)) { + throw new SpecException("No value for identifier " + id); + } + return values[id]; + } + + /** + * Express this solution in terms of the variables of a model which follows the spec (an instance, or the + * recorded model). + * + * @param resolver identifies the variables of the target model + * @return a solution of the target model + */ + public Solution toSolution(Resolver resolver) { + Solution sol = new Solution(resolver.model()); + for (int id = 0; id < stored.length; id++) { + if (stored[id] && resolver.isBound(id) && resolver.get(id) instanceof IntVar v) { + sol.setIntVal(v, values[id]); + } + } + return sol; + } + + @Override + public boolean equals(Object o) { + return this == o || (o instanceof SpecSolution s + && Arrays.equals(values, s.values) && Arrays.equals(stored, s.stored)); + } + + @Override + public int hashCode() { + return 31 * Arrays.hashCode(values) + Arrays.hashCode(stored); + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/Specs.java b/solver/src/main/java/org/chocosolver/solver/spec/Specs.java new file mode 100644 index 0000000000..ed104d2645 --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/Specs.java @@ -0,0 +1,82 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.List; + +/** + * Helpers shared by the classes of this package. + * + * @author Charles Prud'homme + */ +final class Specs { + + static final int[] NO_INT = new int[0]; + + /** + * Whether the instances of a class have no instance field. + */ + private static final ClassValue STATELESS = new ClassValue<>() { + @Override + protected Boolean computeValue(Class c) { + if (c.isArray()) { + return false; + } + for (Class k = c; k != null; k = k.getSuperclass()) { + for (Field f : k.getDeclaredFields()) { + if (!Modifier.isStatic(f.getModifiers())) { + return false; + } + } + } + return true; + } + }; + + private Specs() { + } + + /** + * @return true if the instances of c have no instance field, hence are immutable + */ + static boolean isStateless(Class c) { + return STATELESS.get(c); + } + + /** + * @return the type of the elements of c, whatever the number of dimensions (c if not an array) + */ + static Class elementType(Class c) { + while (c.isArray()) { + c = c.getComponentType(); + } + return c; + } + + /** + * Add the elements of array, row by row whatever the number of dimensions, to flat. + */ + static void flatten(Object[] array, List flat) { + for (Object o : array) { + if (o instanceof Object[] sub) { + flatten(sub, flat); + } else { + flat.add(o); + } + } + } + + /** + * @return the wrapper class of c if primitive, c otherwise + */ + static Class wrap(Class c) { + return c.isPrimitive() ? MethodType.methodType(c).wrap().returnType() : c; + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/Step.java b/solver/src/main/java/org/chocosolver/solver/spec/Step.java new file mode 100644 index 0000000000..48ec7ce8d4 --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/Step.java @@ -0,0 +1,300 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.variables.OptionalTask; +import org.chocosolver.solver.variables.Task; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.IntConsumer; + +/** + * A step of a {@link ModelSpec}. Steps are replayed in order. + *

+ * A step may define identifiers (its outputs) and use identifiers defined by previous steps. + * An identifier may be defined more than once, provided that the replay binds it to the same object each time + * (e.g., a cached constant). + *

+ * Steps are immutable: public accessors return copies, the package reads the arrays directly. + * + * @author Charles Prud'homme + */ +public sealed interface Step permits Step.Call, Step.Post, Step.Reify, Step.Link, Step.Unpost, Step.NewTask, + Step.Custom, Step.Hook, Step.Seed, Step.Objective, Step.Group { + + /** + * Apply action on each identifier this step uses. + */ + default void forEachUse(IntConsumer action) { + } + + /** + * Apply action on each identifier this step defines. + */ + default void forEachDef(IntConsumer action) { + } + + /** + * A call to a factory method of the model. + * + * @param out identifiers of the outputs: empty if the result is ignored, one identifier for an object, + * one identifier per element for an array (row by row for a matrix), -1 to skip an element + * @param method the factory method + * @param args the arguments, one per parameter of method + */ + record Call(int[] out, FactoryMethod method, Arg[] args) implements Step { + public Call { + Objects.requireNonNull(method); + out = out.clone(); + args = args.clone(); + if (args.length != method.parameterCount()) { + throw new SpecException(method + " expects " + method.parameterCount() + + " arguments, got " + args.length); + } + if (out.length > 0 && method.returnType() == void.class) { + throw new SpecException(method + " returns nothing"); + } + } + + @Override + public int[] out() { + return out.clone(); + } + + @Override + public Arg[] args() { + return args.clone(); + } + + int[] rawOut() { + return out; + } + + Arg[] rawArgs() { + return args; + } + + @Override + public void forEachUse(IntConsumer action) { + for (Arg a : args) a.forEachRef(action); + } + + @Override + public void forEachDef(IntConsumer action) { + for (int o : out) if (o >= 0) action.accept(o); + } + + @Override + public String toString() { + return Arrays.toString(out) + " = " + method.name() + Arrays.toString(args); + } + } + + /** + * Post constraints, as {@link org.chocosolver.solver.Model#post(Constraint...)}. + */ + record Post(int[] cstrs) implements Step { + public Post { + cstrs = cstrs.clone(); + } + + @Override + public int[] cstrs() { + return cstrs.clone(); + } + + int[] rawCstrs() { + return cstrs; + } + + @Override + public void forEachUse(IntConsumer action) { + for (int c : cstrs) action.accept(c); + } + + @Override + public String toString() { + return "post" + Arrays.toString(cstrs); + } + } + + /** + * Reify a constraint, as {@link Constraint#reify()}, and bind the resulting boolean variable to out. + */ + record Reify(int cstr, int out) implements Step { + @Override + public void forEachUse(IntConsumer action) { + action.accept(cstr); + } + + @Override + public void forEachDef(IntConsumer action) { + action.accept(out); + } + } + + /** + * Link a constraint to a boolean variable: {@link Constraint#reifyWith}, {@link Constraint#implies} or + * {@link Constraint#impliedBy}. + */ + record Link(Kind kind, int cstr, int bool) implements Step { + + /** + * The kinds of links between a constraint and a boolean variable. + */ + public enum Kind { + REIFY_WITH, IMPLIES, IMPLIED_BY + } + + public Link { + Objects.requireNonNull(kind); + } + + @Override + public void forEachUse(IntConsumer action) { + action.accept(cstr); + action.accept(bool); + } + } + + /** + * Unpost constraints, as {@link org.chocosolver.solver.Model#unpost(Constraint...)}. + */ + record Unpost(int[] cstrs) implements Step { + public Unpost { + cstrs = cstrs.clone(); + } + + @Override + public int[] cstrs() { + return cstrs.clone(); + } + + int[] rawCstrs() { + return cstrs; + } + + @Override + public void forEachUse(IntConsumer action) { + for (int c : cstrs) action.accept(c); + } + + @Override + public String toString() { + return "unpost" + Arrays.toString(cstrs); + } + } + + /** + * Create a {@link Task} (or an {@link OptionalTask} when performed is not -1) with its canonical + * constructor, from existing variables. + * + * @param out identifier of the task + * @param start identifier of the start variable + * @param duration identifier of the duration variable + * @param end identifier of the end variable + * @param performed identifier of the boolean variable of an optional task, -1 for a mandatory task + */ + record NewTask(int out, int start, int duration, int end, int performed) implements Step { + + public NewTask(int out, int start, int duration, int end) { + this(out, start, duration, end, -1); + } + + @Override + public void forEachUse(IntConsumer action) { + action.accept(start); + action.accept(duration); + action.accept(end); + if (performed >= 0) action.accept(performed); + } + + @Override + public void forEachDef(IntConsumer action) { + action.accept(out); + } + } + + /** + * A custom constraint, built by factory on the model under instantiation, for specs built by hand + * ({@link SpecBuilder#custom}) or extended by a variant ({@link Variant#extend}); a recorded model journalizes + * its custom constraints as calls to {@code IConstraintFactory#custom}. + * The factory must only use objects obtained from the {@link Resolver}. + * The constraint is neither posted nor reified: use subsequent steps. + * + * @param out identifier of the constraint, -1 to ignore it + * @param name name of the custom constraint (for error messages) + * @param factory builds the constraint; must be stateless as it may be called concurrently + */ + record Custom(int out, String name, Function factory) implements Step { + public Custom { + Objects.requireNonNull(name); + Objects.requireNonNull(factory); + } + + @Override + public void forEachDef(IntConsumer action) { + if (out >= 0) action.accept(out); + } + } + + /** + * Add a hook to the model, as {@link org.chocosolver.solver.Model#addHook(String, Object)}, or remove it + * when value is a null constant; remove all the hooks, as + * {@link org.chocosolver.solver.Model#removeAllHooks()}, when name is null. + */ + record Hook(String name, Arg value) implements Step { + public Hook { + Objects.requireNonNull(value); + if (name == null && !(value instanceof Arg.Const c && c.value() == null)) { + throw new IllegalArgumentException("removing all the hooks takes no value"); + } + } + + @Override + public void forEachUse(IntConsumer action) { + value.forEachRef(action); + } + } + + /** + * Set the seed of the model, as {@link org.chocosolver.solver.Model#setSeed(long)}. + */ + record Seed(long seed) implements Step { + } + + /** + * Set the objective of the model, as {@link org.chocosolver.solver.Model#setObjective(boolean, + * org.chocosolver.solver.variables.Variable)}, or clear it when var is -1. + */ + record Objective(boolean maximize, int var) implements Step { + @Override + public void forEachUse(IntConsumer action) { + if (var >= 0) action.accept(var); + } + } + + /** + * Declare a group of variables, as + * {@link org.chocosolver.solver.Model#addGroup(org.chocosolver.solver.variables.Group)}. + * Groups are used by some search strategies to select the decision variables. + */ + record Group(String name, Arg.RefArray vars) implements Step { + public Group { + Objects.requireNonNull(name); + Objects.requireNonNull(vars); + } + + @Override + public void forEachUse(IntConsumer action) { + vars.forEachRef(action); + } + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/Values.java b/solver/src/main/java/org/chocosolver/solver/spec/Values.java new file mode 100644 index 0000000000..2c31f0cf1e --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/Values.java @@ -0,0 +1,168 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.constraints.extension.Tuples; +import org.chocosolver.solver.constraints.extension.hybrid.HybridTuples; +import org.chocosolver.solver.constraints.nary.automata.FA.FiniteAutomaton; +import org.chocosolver.util.objects.graphs.MultivaluedDecisionDiagram; +import org.chocosolver.util.objects.setDataStructures.iterable.IntIterableRangeSet; + +import java.lang.reflect.Array; +import java.lang.reflect.Constructor; +import java.lang.reflect.RecordComponent; +import java.util.function.UnaryOperator; + +/** + * Sharing policy of the constant arguments of a {@link ModelSpec}: the single place which decides what is shared by + * the instances of a spec. + *

    + *
  • immutable values (boxed primitives, strings, enums) are shared;
  • + *
  • heavy read-only data ({@link Tuples}, {@link HybridTuples}, {@link MultivaluedDecisionDiagram}) are frozen on + * capture, then shared;
  • + *
  • objects without instance field (e.g., non-capturing lambdas, stateless singletons such as + * {@code Condition.EXCEPT_0}) cannot capture anything: they are shared;
  • + *
  • arrays (of any dimension, whose elements are supported) and {@link IntIterableRangeSet} are copied on capture + * and on each instantiation;
  • + *
  • automata ({@link FiniteAutomaton} and subclasses) are not thread-safe: copied on capture and on each + * instantiation;
  • + *
  • records whose components are supported: shared if all their components are, otherwise rebuilt with + * their canonical constructor from copied components.
  • + *
+ * Any other type is rejected, in particular capturing lambdas (they may capture objects of the recorded model). + * + * @author Charles Prud'homme + */ +final class Values { + + private Values() { + } + + /** + * Capture a constant when building a spec, detaching it from the caller. + * + * @param value a constant + * @return the value to store in the spec + * @throws SpecException if the type of value is not supported + */ + static Object capture(Object value) { + if (value instanceof Tuples t) { + return t.freeze(); + } + if (value instanceof HybridTuples t) { + return t.freeze(); + } + if (value instanceof MultivaluedDecisionDiagram mdd) { + return mdd.freeze(); + } + return isShared(value) ? value : copy(value, Values::capture); + } + + /** + * @param value a captured constant + * @return the value to pass to a model under instantiation + */ + static Object materialize(Object value) { + return isShared(value) ? value : copy(value, Values::materialize); + } + + /** + * @return true if value can be shared by models solved concurrently + */ + static boolean isShared(Object value) { + return value == null + || value instanceof Number + || value instanceof Boolean + || value instanceof Character + || value instanceof String + || value instanceof Enum + || (value instanceof Tuples t && t.isFrozen()) + || (value instanceof HybridTuples t && t.isFrozen()) + || (value instanceof MultivaluedDecisionDiagram mdd && mdd.isFrozen()) + || Specs.isStateless(value.getClass()) + || (value instanceof Record r && allShared(r)); + } + + private static boolean allShared(Record r) { + try { + for (RecordComponent rc : r.getClass().getRecordComponents()) { + if (!isShared(component(r, rc))) { + return false; + } + } + return true; + } catch (SpecException e) { + // a record which cannot be read is not shared: copying it reports the problem + return false; + } + } + + private static Object component(Record r, RecordComponent rc) { + try { + rc.getAccessor().setAccessible(true); + return rc.getAccessor().invoke(r); + } catch (ReflectiveOperationException | RuntimeException e) { + throw new SpecException("Cannot read the component " + rc.getName() + " of " + r.getClass().getName() + + " (is its package open to org.chocosolver.solver?)", e); + } + } + + /** + * @param elements copies the elements of an array or the components of a record + */ + private static Object copy(Object value, UnaryOperator elements) { + Class c = value.getClass(); + if (c.isArray()) { + int n = Array.getLength(value); + Object dst = Array.newInstance(c.getComponentType(), n); + if (c.getComponentType().isPrimitive()) { + System.arraycopy(value, 0, dst, 0, n); + } else { + Object[] src = (Object[]) value; + Object[] d = (Object[]) dst; + for (int i = 0; i < n; i++) { + d[i] = elements.apply(src[i]); + } + } + return dst; + } + if (value instanceof IntIterableRangeSet set) { + return set.duplicate(); + } + if (value instanceof FiniteAutomaton fa) { + // reading an automaton may lazily modify its internal (dk.brics) state + synchronized (fa) { + try { + return fa.clone(); + } catch (CloneNotSupportedException e) { + throw new SpecException("Cannot copy automaton", e); + } + } + } + if (value instanceof Record r) { + RecordComponent[] rcs = c.getRecordComponents(); + Object[] args = new Object[rcs.length]; + Class[] types = new Class[rcs.length]; + for (int i = 0; i < rcs.length; i++) { + args[i] = elements.apply(component(r, rcs[i])); + types[i] = rcs[i].getType(); + } + try { + Constructor canonical = c.getDeclaredConstructor(types); + canonical.setAccessible(true); + return canonical.newInstance(args); + } catch (ReflectiveOperationException | RuntimeException e) { + throw new SpecException("Cannot copy the record " + c.getName(), e); + } + } + if (c.isSynthetic() || c.isHidden()) { + throw new SpecException("Unsupported capturing lambda " + c.getName() + + ": it may capture objects of the recorded model; use a non-capturing lambda"); + } + throw new SpecException("Unsupported constant of type " + c.getName()); + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/spec/Variant.java b/solver/src/main/java/org/chocosolver/solver/spec/Variant.java new file mode 100644 index 0000000000..d095e159a3 --- /dev/null +++ b/solver/src/main/java/org/chocosolver/solver/spec/Variant.java @@ -0,0 +1,213 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Settings; +import org.chocosolver.solver.constraints.extension.Tuples; +import org.chocosolver.solver.variables.IntVar; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.BitSet; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; + +/** + * A variant of a {@link ModelSpec}: a rewriting of the spec, applied once, whose result is instantiated by as many + * workers as needed (see {@link ModelSpec#with(Variant)}). + *

+ * A variant may change: + *

    + *
  • the header: {@link #seed(long)}, {@link #settings(Settings)};
  • + *
  • the calls to the factories, e.g. the consistency of {@code allDifferent} ({@link #allDifferent(String)}) + * or the algorithm of {@code table} ({@link #tableAlgorithm(String)}), or any call ({@link #mapCalls});
  • + *
  • the constraints: redundant constraints or symmetry breaking ({@link #extend(Consumer)}), removals + * ({@link #unpostIf(Predicate)});
  • + *
  • the search ({@link #search(SearchDecl)}).
  • + *
+ * Variants compose with {@link #andThen(Variant)}. + * + * @author Charles Prud'homme + */ +@FunctionalInterface +public interface Variant { + + /** + * The variant which changes nothing: a carbon copy. + */ + Variant IDENTITY = spec -> spec; + + /** + * @param spec a spec + * @return the rewritten spec + */ + ModelSpec apply(ModelSpec spec); + + /** + * @return a variant which applies this one, then next + */ + default Variant andThen(Variant next) { + Objects.requireNonNull(next); + return spec -> next.apply(apply(spec)); + } + + //*********************************************************************************** + // HEADER AND SEARCH + //*********************************************************************************** + + /** + * Change the seed: propagators drawing random numbers and randomized search strategies behave differently. + * Note that it may also change the encoding of expressions which are sampled ({@code ReExpression#embody}). + * The seeds set during the construction ({@link Step.Seed}) are removed, otherwise they would override this one. + */ + static Variant seed(long seed) { + return spec -> spec.withHeader(spec.header().withSeed(seed)) + .withSteps(spec.steps().stream().filter(s -> !(s instanceof Step.Seed)).toList()); + } + + /** + * Change the settings (e.g., enable views or LCG), which may change the way constraints are built. + */ + static Variant settings(Settings settings) { + Objects.requireNonNull(settings); + return spec -> spec.withHeader(spec.header().withSettings(settings)); + } + + /** + * Change the search. + */ + static Variant search(SearchDecl search) { + return spec -> spec.withSearch(search); + } + + //*********************************************************************************** + // CALLS + //*********************************************************************************** + + /** + * Rewrite each call to a factory method. + * + * @param rewriter returns the call to replay instead (possibly the same), which must define the same + * identifiers + */ + static Variant mapCalls(UnaryOperator rewriter) { + Objects.requireNonNull(rewriter); + return spec -> { + List steps = new ArrayList<>(spec.steps().size()); + for (Step s : spec.steps()) { + steps.add(s instanceof Step.Call c ? rewriter.apply(c) : s); + } + return spec.withSteps(steps); + }; + } + + /** + * Set the consistency of every {@code allDifferent} constraint. + * + * @param consistency among "DEFAULT", "AC", "AC_REGIN", "AC_ZHANG", "BC", "FC", "NEQS" + */ + static Variant allDifferent(String consistency) { + return withOption("allDifferent", consistency, IntVar[].class); + } + + /** + * Set the algorithm of every table constraint of arity greater than 2. + * + * @param algo e.g. "CT+", "STR2+", "GAC3rm", "MDD+", "FC" (see {@code IIntConstraintFactory#table}) + */ + static Variant tableAlgorithm(String algo) { + return withOption("table", algo, IntVar[].class, Tuples.class); + } + + /** + * Set the algorithm of every binary table constraint. + * + * @param algo e.g. "CT+", "STR2+", "AC3rm", "AC2001", "FC" (see {@code IIntConstraintFactory#table}) + */ + static Variant binaryTableAlgorithm(String algo) { + return withOption("table", algo, IntVar.class, IntVar.class, Tuples.class); + } + + /** + * Rewrite the calls to a factory method which exists with and without a trailing {@code String} option (e.g. + * {@code allDifferent(IntVar[])} and {@code allDifferent(IntVar[], String)}) so that they all use + * option. + */ + private static Variant withOption(String name, String option, Class... base) { + Objects.requireNonNull(option); + FactoryMethod without = FactoryMethod.of(name, base); + Class[] withTypes = Arrays.copyOf(base, base.length + 1); + withTypes[base.length] = String.class; + FactoryMethod with = FactoryMethod.of(name, withTypes); + Arg opt = new Arg.Const(option); + return mapCalls(c -> { + boolean hasOption = c.method().equals(with); + if (!hasOption && !c.method().equals(without)) { + return c; + } + Arg[] args = c.rawArgs(); + Arg[] a = Arrays.copyOf(args, hasOption ? args.length : args.length + 1); + a[a.length - 1] = opt; + return new Step.Call(c.rawOut(), with, a); + }); + } + + //*********************************************************************************** + // CONSTRAINTS + //*********************************************************************************** + + /** + * Add steps at the end of the spec, e.g. redundant constraints or symmetry breaking constraints. + * The builder knows the identifiers of the spec (see {@link Recorder#idsOf} to get those of the variables of a + * recorded model). + */ + static Variant extend(Consumer extension) { + Objects.requireNonNull(extension); + return spec -> { + SpecBuilder b = SpecBuilder.from(spec); + extension.accept(b); + return b.build(); + }; + } + + /** + * Do not post the constraints built by the calls which satisfy filter (the calls are still replayed, + * with their side effects, e.g. intermediate variables). + */ + static Variant unpostIf(Predicate filter) { + Objects.requireNonNull(filter); + return spec -> { + BitSet removed = new BitSet(); + for (Step s : spec.steps()) { + if (s instanceof Step.Call c && filter.test(c)) { + s.forEachDef(removed::set); + } + } + List steps = new ArrayList<>(spec.steps().size()); + for (Step s : spec.steps()) { + // a constraint no longer posted cannot be unposted either + if (s instanceof Step.Post p) { + int[] kept = Arrays.stream(p.rawCstrs()).filter(c -> !removed.get(c)).toArray(); + if (kept.length > 0) { + steps.add(new Step.Post(kept)); + } + } else if (s instanceof Step.Unpost u) { + int[] kept = Arrays.stream(u.rawCstrs()).filter(c -> !removed.get(c)).toArray(); + if (kept.length > 0) { + steps.add(new Step.Unpost(kept)); + } + } else { + steps.add(s); + } + } + return spec.withSteps(steps); + }; + } +} diff --git a/solver/src/main/java/org/chocosolver/solver/variables/OptionalTask.java b/solver/src/main/java/org/chocosolver/solver/variables/OptionalTask.java index 78b161f5a7..ac9ee35efb 100644 --- a/solver/src/main/java/org/chocosolver/solver/variables/OptionalTask.java +++ b/solver/src/main/java/org/chocosolver/solver/variables/OptionalTask.java @@ -10,6 +10,7 @@ import org.chocosolver.solver.ICause; import org.chocosolver.solver.Model; import org.chocosolver.solver.exception.ContradictionException; +import org.chocosolver.solver.spec.Recorder; /** * Container representing an optional task: @@ -58,8 +59,11 @@ public OptionalTask(Model model, int est, int lst, int d, int ect, int lct) { * @param performed performed variable */ public OptionalTask(Model model, int est, int lst, int d, int ect, int lct, BoolVar performed) { - super(model, est, lst, d, ect, lct); - this.performed = performed; + this(buildVars(model, est, lst, d, ect, lct), performed); + } + + private OptionalTask(IntVar[] vars, BoolVar performed) { + this(vars[0], vars[1], vars[2], performed); } /** @@ -82,8 +86,7 @@ public OptionalTask(IntVar s, int d) { * @param performed performed variable */ public OptionalTask(IntVar s, int d, BoolVar performed) { - super(s, d); - this.performed = performed; + this(s, s.getModel().intVar(d), s.getModel().offset(s, d), performed); } /** @@ -106,8 +109,7 @@ public OptionalTask(IntVar s, IntVar d) { * @param performed performed variable */ public OptionalTask(IntVar s, IntVar d, BoolVar performed) { - super(s, d); - this.performed = performed; + this(s, d, endOf(s, d), performed); } /** @@ -132,8 +134,7 @@ public OptionalTask(IntVar s, int d, IntVar e) { * @param performed performed variable */ public OptionalTask(IntVar s, int d, IntVar e, BoolVar performed) { - super(s, d, e); - this.performed = performed; + this(s, s.getModel().intVar(d), e, performed); } /** @@ -160,6 +161,13 @@ public OptionalTask(IntVar s, IntVar d, IntVar e) { public OptionalTask(IntVar s, IntVar d, IntVar e, BoolVar performed) { super(s, d, e); this.performed = performed; + // every constructor ends here: journalize the creation of this task, if its model is recorded + if (getClass() == OptionalTask.class) { + Recorder r = s.getModel().getRecorder(); + if (r != null) { + r.newTask(this, s, d, e, performed); + } + } } //*********************************************************************************** diff --git a/solver/src/main/java/org/chocosolver/solver/variables/Task.java b/solver/src/main/java/org/chocosolver/solver/variables/Task.java index 3f85ec41b2..02204fcb57 100644 --- a/solver/src/main/java/org/chocosolver/solver/variables/Task.java +++ b/solver/src/main/java/org/chocosolver/solver/variables/Task.java @@ -14,6 +14,7 @@ import org.chocosolver.solver.constraints.Propagator; import org.chocosolver.solver.constraints.PropagatorPriority; import org.chocosolver.solver.exception.ContradictionException; +import org.chocosolver.solver.spec.Recorder; import org.chocosolver.solver.variables.events.PropagatorEventType; import org.chocosolver.solver.variables.view.integer.IntAffineView; import org.chocosolver.util.ESat; @@ -61,7 +62,7 @@ public Task(Model model, int est, int lst, int d, int ect, int lct) { this(buildVars(model, est, lst, d, ect, lct)); } - private static IntVar[] buildVars(Model model, int est, int lst, int d, int ect, int lct) { + static IntVar[] buildVars(Model model, int est, int lst, int d, int ect, int lct) { IntVar start = model.intVar(Math.max(est, ect - d), Math.min(lst, lct - d)); IntVar duration = model.intVar(d); IntVar end = start.getModel().offset(start, d); @@ -87,12 +88,15 @@ public Task(IntVar s, int d) { * @param d duration value */ public Task(IntVar s, IntVar d) { - this( - s, - d, - d.isInstantiated() ? s.getModel().offset(s, d.getValue()) - : s.getModel().intVar(s.getLB() + d.getLB(), s.getUB() + d.getUB()) - ); + this(s, d, endOf(s, d)); + } + + /** + * @return the end variable of a task of start s and duration d + */ + static IntVar endOf(IntVar s, IntVar d) { + return d.isInstantiated() ? s.getModel().offset(s, d.getValue()) + : s.getModel().intVar(s.getLB() + d.getLB(), s.getUB() + d.getUB()); } /** @@ -124,6 +128,12 @@ public Task(IntVar s, IntVar d, IntVar e) { setActive(); setPassive(); } + if (getClass() == Task.class) { + Recorder r = s.getModel().getRecorder(); + if (r != null) { + r.newTask(this, s, d, e, null); + } + } } public void post() { diff --git a/solver/src/test/java/org/chocosolver/solver/spec/CustomConstraintTest.java b/solver/src/test/java/org/chocosolver/solver/spec/CustomConstraintTest.java new file mode 100644 index 0000000000..b74a4aad20 --- /dev/null +++ b/solver/src/test/java/org/chocosolver/solver/spec/CustomConstraintTest.java @@ -0,0 +1,119 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.constraints.Propagator; +import org.chocosolver.solver.exception.ContradictionException; +import org.chocosolver.solver.variables.IntVar; +import org.chocosolver.util.ESat; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.Arrays; + +/** + * Tests of the custom constraints ({@code Model.custom}) built with parameters. + * + * @author Charles Prud'homme + */ +public class CustomConstraintTest { + + /** + * A custom propagator with parameters: each variable is at most its bound. + */ + static final class PropUpperBounds extends Propagator { + final int[] bounds; + + PropUpperBounds(IntVar[] vars, int[] bounds) { + super(vars); + this.bounds = bounds; + } + + @Override + public void propagate(int evtmask) throws ContradictionException { + for (int i = 0; i < vars.length; i++) { + vars[i].updateUpperBound(bounds[i], this); + } + } + + @Override + public ESat isEntailed() { + boolean all = true; + for (int i = 0; i < vars.length; i++) { + if (vars[i].getLB() > bounds[i]) { + return ESat.FALSE; + } + all &= vars[i].getUB() <= bounds[i]; + } + return all ? ESat.TRUE : ESat.UNDEFINED; + } + } + + /** + * Several parameters, in a record. + */ + record Bounds(int[] ub, int shift) { + } + + /** + * A record which holds a variable: not supported, variables must be passed in {@code vars}. + */ + record WithVar(IntVar var) { + } + + private static PropUpperBounds of(IntVar[] vs, Bounds b) { + return new PropUpperBounds(vs, Arrays.stream(b.ub()).map(u -> u + b.shift()).toArray()); + } + + private static Model build(Model m) { + IntVar[] x = m.intVarArray("x", 3, 0, 9); + m.custom("int", x, 7, (vs, k) -> new PropUpperBounds(vs, new int[]{k, k, k})).post(); + m.custom("array", x, new int[]{6, 5, 8}, PropUpperBounds::new).post(); + m.custom("record", x, new Bounds(new int[]{3, 7, 7}, 1), CustomConstraintTest::of).post(); + // data holding variables: mapped to those of each copy + m.custom("vars", new IntVar[]{x[0]}, new IntVar[]{x[1], x[2]}, + (vs, ws) -> new PropUpperBounds(new IntVar[]{vs[0], ws[0], ws[1]}, new int[]{9, 9, 9})).post(); + m.sum(x, "=", 14).post(); + return m; + } + + private static int[] bounds(Model m, String name) { + Constraint c = Arrays.stream(m.getCstrs()).filter(k -> k.getName().equals(name)).findFirst().orElseThrow(); + return ((PropUpperBounds) c.getPropagator(0)).bounds; + } + + @Test(groups = "1s", timeOut = 60000) + public void testParametersAreReplayed() { + String expected = SpecTestUtils.structure(build(new Model("m"))); + String stats = SpecTestUtils.solve(build(new Model("m")), 100_000); + Model original = build(Model.record("m")); + ModelSpec spec = original.getRecorder().snapshot(); + Model a = spec.instantiate().model(); + Model b = spec.instantiate().model(); + for (Model m : new Model[]{a, b}) { + Assert.assertEquals(SpecTestUtils.structure(m), expected); + Assert.assertEquals(bounds(m, "int"), new int[]{7, 7, 7}); + Assert.assertEquals(bounds(m, "array"), new int[]{6, 5, 8}); + Assert.assertEquals(bounds(m, "record"), new int[]{4, 8, 8}); + } + // mutable data are not shared + Assert.assertNotSame(bounds(a, "array"), bounds(original, "array")); + Assert.assertNotSame(bounds(a, "array"), bounds(b, "array")); + Assert.assertEquals(SpecTestUtils.solve(a, 100_000), stats); + Assert.assertEquals(SpecTestUtils.solve(original, 100_000), stats); + } + + @Test(groups = "1s", timeOut = 60000) + public void testRecordOfVariablesIsReported() { + Model m = Model.record("m"); + IntVar[] x = m.intVarArray("x", 2, 0, 3); + m.custom("withVar", x, new WithVar(x[0]), (vs, w) -> new PropUpperBounds(vs, new int[]{3, 3})).post(); + Assert.assertThrows(SpecException.class, m::duplicate); + } +} diff --git a/solver/src/test/java/org/chocosolver/solver/spec/IntFactoryCoverageTest.java b/solver/src/test/java/org/chocosolver/solver/spec/IntFactoryCoverageTest.java new file mode 100644 index 0000000000..b2ce78bea4 --- /dev/null +++ b/solver/src/test/java/org/chocosolver/solver/spec/IntFactoryCoverageTest.java @@ -0,0 +1,683 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.IModel; +import org.chocosolver.solver.Model; +import org.chocosolver.solver.Settings; +import org.chocosolver.solver.SettingsBuilder; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.constraints.binary.PropGreaterOrEqualX_Y; +import org.chocosolver.solver.constraints.ISchedulingFactory; +import org.chocosolver.solver.constraints.extension.Tuples; +import org.chocosolver.solver.constraints.extension.hybrid.HybridTuples; +import org.chocosolver.solver.constraints.nary.alldifferent.conditions.Condition; +import org.chocosolver.solver.constraints.nary.automata.FA.CostAutomaton; +import org.chocosolver.solver.constraints.nary.automata.FA.FiniteAutomaton; +import org.chocosolver.solver.constraints.nary.circuit.CircuitConf; +import org.chocosolver.solver.constraints.nary.cnf.LogOp; +import org.chocosolver.solver.variables.BoolVar; +import org.chocosolver.solver.variables.IntVar; +import org.chocosolver.solver.variables.Task; +import org.chocosolver.solver.variables.Variable; +import org.chocosolver.util.objects.graphs.MultivaluedDecisionDiagram; +import org.chocosolver.util.objects.setDataStructures.iterable.IntIterableRangeSet; +import org.testng.Assert; +import org.testng.SkipException; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Exhaustive check that the factory methods of {@link IModel} dealing only with integer and boolean variables + * (variables, views, constraints, reifications, clauses, decompositions, tasks) are supported by + * {@link ModelSpec}: each one is called by a case, and each case built directly, recorded then replayed gives + * the same structure and the same search (all solutions). Constraints are also checked reified. + *

+ * {@link #testEveryIntFactoryMethodIsCovered()} fails if a factory method of the scope is called by no case: add + * a case when adding a factory method. + * + * @author Charles Prud'homme + */ +public class IntFactoryCoverageTest { + + /** + * Factory methods not implemented by the solver (they always throw an exception). + */ + private static final Set NOT_IMPLEMENTED = Set.of("addTable(IntVar[],HybridTuples)"); + + /** + * Types out of the scope (set, real and graph variables and constraints). + */ + private static final List OUT_OF_SCOPE = List.of("SetVar", "RealVar", "GraphVar", "DirectedGraph", + "UndirectedGraph", "ISet", "IGraph", "RealConstraint", "NodeInduced"); + + private record Case(String name, Consumer body) { + @Override + public String toString() { + return name; + } + } + + private static final List CASES = new ArrayList<>(); + + /** + * A case which posts the constraint, and a case which reifies it. + */ + private static void c(String name, Function f) { + CASES.add(new Case(name, m -> f.apply(m).post())); + CASES.add(new Case(name + " [reified]", m -> f.apply(m).reify())); + } + + /** + * A case which runs f. + */ + private static void s(String name, Consumer f) { + CASES.add(new Case(name, f)); + } + + private static IntVar[] x(Model m, int n, int lb, int ub) { + return m.intVarArray("x", n, lb, ub); + } + + private static IntVar v(Model m, String name, int lb, int ub) { + return m.intVar(name, lb, ub); + } + + private static BoolVar[] b(Model m, int n) { + return m.boolVarArray("b", n); + } + + private static int[] ints(int... values) { + return values; + } + + /** + * Post a unary cumulative over the tasks, so that they matter. + */ + private static void cum(Model m, Task... tasks) { + IntVar[] h = new IntVar[tasks.length]; + Arrays.fill(h, m.intVar(1)); + m.cumulative(tasks, h, m.intVar(1)).post(); + } + + private static Tuples tuples(boolean feasible, int[]... ts) { + Tuples t = new Tuples(feasible); + t.add(ts); + return t; + } + + /** + * The numbering of the states of an automaton built from a regular expression is not deterministic (it + * depends on identity hash codes): the direct and the recorded models of a case must share the automaton. + */ + private static final FiniteAutomaton AUTOMATON = new FiniteAutomaton("0*1*2*"); + + private static FiniteAutomaton automaton() { + return AUTOMATON; + } + + private static Task[] tasks(Model m, int n) { + Task[] ts = new Task[n]; + for (int i = 0; i < n; i++) { + ts[i] = m.taskVar(v(m, "s" + i, 0, 3), 1 + (i % 2)); + } + return ts; + } + + static { + // ---------------------------------------------------------------- variables + s("boolVar()", m -> m.boolVar()); + s("boolVar(String)", m -> m.boolVar("b")); + s("boolVar(String,boolean)", m -> m.arithm(m.boolVar("t", true), "=", m.boolVar("b")).post()); + s("boolVar(boolean)", m -> m.arithm(m.boolVar(false), "=", m.boolVar("b")).post()); + s("boolVarArray(String,int)", m -> m.boolVarArray("b", 3)); + s("boolVarArray(int)", m -> m.boolVarArray(3)); + s("boolVarMatrix(String,int,int)", m -> m.boolVarMatrix("b", 2, 2)); + s("boolVarMatrix(int,int)", m -> m.boolVarMatrix(2, 2)); + s("generateName()", m -> { + m.generateName(); + m.boolVar(); + }); + s("generateName(String)", m -> { + m.generateName("X_"); + m.intVar(0, 2); + }); + s("intVar(IntVar)", m -> m.intVar(m.intVar("x", new int[]{1, 3, 5}))); + s("intVar(String,IntVar)", m -> m.intVar("y", v(m, "x", 0, 3))); + s("intVar(String,int)", m -> m.arithm(m.intVar("c", 7), ">", v(m, "x", 5, 9)).post()); + s("intVar(String,int,int)", m -> m.intVar("x", 0, 3)); + s("intVar(String,int,int,boolean)", m -> m.intVar("x", 0, 3, true)); + s("intVar(String,int[])", m -> m.intVar("x", new int[]{1, 4, 6})); + s("intVar(int)", m -> m.arithm(m.intVar(4), ">", v(m, "x", 2, 6)).post()); + s("intVar(int,int)", m -> m.intVar(0, 3)); + s("intVar(int,int,boolean)", m -> m.intVar(0, 3, false)); + s("intVar(int[])", m -> m.intVar(new int[]{2, 5})); + s("intVarArray(String,int,int,int)", m -> m.intVarArray("x", 2, 0, 2)); + s("intVarArray(String,int,int,int,boolean)", m -> m.intVarArray("x", 2, 0, 2, true)); + s("intVarArray(String,int,int[])", m -> m.intVarArray("x", 2, new int[]{1, 3})); + s("intVarArray(int,int,int)", m -> m.intVarArray(2, 0, 2)); + s("intVarArray(int,int,int,boolean)", m -> m.intVarArray(2, 0, 2, false)); + s("intVarArray(int,int[])", m -> m.intVarArray(2, new int[]{1, 3})); + s("intVarMatrix(String,int,int,int,int)", m -> m.intVarMatrix("x", 2, 2, 0, 1)); + s("intVarMatrix(String,int,int,int,int,boolean)", m -> m.intVarMatrix("x", 2, 2, 0, 1, true)); + s("intVarMatrix(String,int,int,int[])", m -> m.intVarMatrix("x", 2, 2, new int[]{0, 2})); + s("intVarMatrix(int,int,int,int)", m -> m.intVarMatrix(2, 2, 0, 1)); + s("intVarMatrix(int,int,int,int,boolean)", m -> m.intVarMatrix(2, 2, 0, 1, false)); + s("intVarMatrix(int,int,int[])", m -> m.intVarMatrix(2, 2, new int[]{0, 2})); + // tasks + s("taskVar(IntVar,IntVar)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), v(m, "d", 1, 2)), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(IntVar,IntVar,BoolVar)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), v(m, "d", 1, 2), m.boolVar("p")), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(IntVar,IntVar,IntVar)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), v(m, "d", 1, 2), v(m, "e", 1, 5)), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(IntVar,IntVar,IntVar,BoolVar)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), v(m, "d", 1, 2), v(m, "e", 1, 5), m.boolVar("p")), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(IntVar,IntVar,IntVar,boolean)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), v(m, "d", 1, 2), v(m, "e", 1, 5), true), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(IntVar,IntVar,boolean)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), v(m, "d", 1, 2), true), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(IntVar,int)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), 2), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(IntVar,int,BoolVar)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), 2, m.boolVar("p")), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(IntVar,int,IntVar)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), 2, v(m, "e", 2, 5)), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(IntVar,int,IntVar,BoolVar)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), 2, v(m, "e", 2, 5), m.boolVar("p")), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(IntVar,int,IntVar,boolean)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), 2, v(m, "e", 2, 5), true), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(IntVar,int,boolean)", m -> cum(m, m.taskVar(v(m, "s", 0, 3), 2, true), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(int,int,int,int,int)", m -> cum(m, m.taskVar(0, 3, 2, 2, 5), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(int,int,int,int,int,BoolVar)", m -> cum(m, m.taskVar(0, 3, 2, 2, 5, m.boolVar("p")), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVar(int,int,int,int,int,boolean)", m -> cum(m, m.taskVar(0, 3, 2, 2, 5, true), m.taskVar(v(m, "t", 0, 3), 1))); + s("taskVarArray(IntVar[],IntVar[],IntVar[])", m -> cum(m, m.taskVarArray(x(m, 2, 0, 2), m.intVarArray("d", 2, 1, 2), m.intVarArray("e", 2, 1, 4)))); + s("taskVarMatrix(IntVar[][],IntVar[][],IntVar[][])", m -> { + Task[][] t = m.taskVarMatrix(m.intVarMatrix("s", 1, 2, 0, 2), m.intVarMatrix("d", 1, 2, 1, 2), m.intVarMatrix("e", 1, 2, 1, 4)); + cum(m, t[0]); + }); + + // ---------------------------------------------------------------- views + s("abs(IntVar)", m -> m.arithm(m.abs(v(m, "x", -2, 2)), "!=", 1).post()); + s("boolNotView(BoolVar)", m -> { + BoolVar[] bs = b(m, 2); + m.arithm(m.boolNotView(bs[0]), "=", bs[1]).post(); + }); + s("intAbsView(IntVar)", m -> m.arithm(m.intAbsView(v(m, "x", -2, 2)), "!=", 1).post()); + s("intAffineView(int,IntVar,int)", m -> m.arithm(m.intAffineView(2, v(m, "x", 0, 3), 1), "!=", 3).post()); + s("intEqView(IntVar,int)", m -> m.arithm(m.intEqView(v(m, "x", 0, 3), 1), "=", 0).post()); + s("intGeView(IntVar,int)", m -> m.arithm(m.intGeView(v(m, "x", 0, 3), 2), "=", 1).post()); + s("intLeView(IntVar,int)", m -> m.arithm(m.intLeView(v(m, "x", 0, 3), 1), "=", 1).post()); + s("intMinusView(IntVar)", m -> m.arithm(m.intMinusView(v(m, "x", 0, 3)), ">", -2).post()); + s("intNeView(IntVar,int)", m -> m.arithm(m.intNeView(v(m, "x", 0, 3), 1), "=", 1).post()); + s("intOffsetView(IntVar,int)", m -> m.arithm(m.intOffsetView(v(m, "x", 0, 3), 2), "!=", 3).post()); + s("intScaleView(IntVar,int)", m -> m.arithm(m.intScaleView(v(m, "x", 0, 3), 3), "!=", 3).post()); + s("intView(int,IntVar,int)", m -> m.arithm(m.intView(2, v(m, "x", 0, 3), -1), "!=", 1).post()); + s("isEq(IntVar,int)", m -> m.arithm(m.isEq(v(m, "x", 0, 3), 1), "=", 0).post()); + s("isGeq(IntVar,int)", m -> m.arithm(m.isGeq(v(m, "x", 0, 3), 2), "=", 1).post()); + s("isLeq(IntVar,int)", m -> m.arithm(m.isLeq(v(m, "x", 0, 3), 1), "=", 1).post()); + s("isNeq(IntVar,int)", m -> m.arithm(m.isNeq(v(m, "x", 0, 3), 1), "=", 1).post()); + s("mul(IntVar,int)", m -> m.arithm(m.mul(v(m, "x", 0, 3), 2), "!=", 2).post()); + s("neg(IntVar)", m -> m.arithm(m.neg(v(m, "x", 0, 3)), ">", -2).post()); + s("offset(IntVar,int)", m -> m.arithm(m.offset(v(m, "x", 0, 3), 2), "!=", 3).post()); + + // ---------------------------------------------------------------- integer constraints + c("absolute(IntVar,IntVar)", m -> m.absolute(v(m, "y", 0, 2), v(m, "x", -2, 2))); + c("allDiffPrec(IntVar[],boolean[][])", m -> m.allDiffPrec(x(m, 3, 0, 2), new boolean[][]{{false, true, false}, {false, false, false}, {false, false, false}})); + c("allDiffPrec(IntVar[],boolean[][],String)", m -> m.allDiffPrec(x(m, 3, 0, 2), new boolean[][]{{false, true, false}, {false, false, false}, {false, false, false}}, "BESSIERE")); + c("allDiffPrec(IntVar[],int[][],int[][])", m -> m.allDiffPrec(x(m, 3, 0, 2), new int[][]{{}, {0}, {}}, new int[][]{{1}, {}, {}})); + c("allDiffPrec(IntVar[],int[][],int[][],String)", m -> m.allDiffPrec(x(m, 3, 0, 2), new int[][]{{}, {0}, {}}, new int[][]{{1}, {}, {}}, "GREEDY")); + c("allDifferent(IntVar[])", m -> m.allDifferent(x(m, 3, 0, 3))); + c("allDifferent(IntVar[],String)", m -> m.allDifferent(x(m, 3, 0, 3), "AC")); + c("allDifferentExcept0(IntVar[])", m -> m.allDifferentExcept0(x(m, 3, 0, 2))); + c("allDifferentExceptValues(IntVar[],int[])", m -> m.allDifferentExceptValues(x(m, 3, 0, 2), ints(0, 1))); + c("allDifferentUnderCondition(IntVar[],Condition,boolean)", m -> m.allDifferentUnderCondition(x(m, 3, 0, 2), Condition.EXCEPT_0, true)); + c("allDifferentUnderCondition(IntVar[],Condition,boolean,String)", m -> m.allDifferentUnderCondition(x(m, 3, 0, 2), v -> !v.contains(0), false, "AC")); + c("allEqual(IntVar[])", m -> m.allEqual(x(m, 3, 0, 2))); + c("among(IntVar,IntVar[],int[])", m -> m.among(v(m, "n", 0, 3), x(m, 3, 0, 2), ints(1, 2))); + c("and(BoolVar[])", m -> m.and(b(m, 3))); + c("and(Constraint[])", m -> { + IntVar[] x = x(m, 2, 0, 2); + return m.and(m.arithm(x[0], "<", x[1]), m.arithm(x[1], "!=", 1)); + }); + c("argmax(IntVar,int,IntVar[])", m -> m.argmax(v(m, "z", 0, 2), 0, x(m, 3, 0, 2))); + c("argmin(IntVar,int,IntVar[])", m -> m.argmin(v(m, "z", 1, 3), 1, x(m, 3, 0, 2))); + c("arithm(IntVar,String,IntVar)", m -> m.arithm(v(m, "x", 0, 3), "<", v(m, "y", 0, 3))); + c("arithm(IntVar,String,IntVar,String,IntVar)", m -> m.arithm(v(m, "x", 0, 3), "+", v(m, "y", 0, 3), "=", v(m, "z", 0, 3))); + c("arithm(IntVar,String,IntVar,String,int)", m -> m.arithm(v(m, "x", 0, 3), "-", v(m, "y", 0, 3), "<=", 1)); + c("arithm(IntVar,String,int)", m -> m.arithm(v(m, "x", 0, 3), "!=", 2)); + c("atLeastNValues(IntVar[],IntVar,boolean)", m -> m.atLeastNValues(x(m, 3, 0, 2), v(m, "n", 2, 3), true)); + c("atMostNValues(IntVar[],IntVar,boolean)", m -> m.atMostNValues(x(m, 3, 0, 2), v(m, "n", 1, 2), false)); + c("binPacking(IntVar[],int[],IntVar[],int)", m -> m.binPacking(x(m, 3, 0, 1), ints(1, 2, 2), m.intVarArray("l", 2, 0, 4), 0)); + c("bitsIntChanneling(BoolVar[],IntVar)", m -> m.bitsIntChanneling(b(m, 3), v(m, "x", 0, 7))); + c("boolsIntChanneling(BoolVar[],IntVar,int)", m -> m.boolsIntChanneling(b(m, 3), v(m, "x", 0, 2), 0)); + c("circuit(IntVar[])", m -> m.circuit(x(m, 4, 0, 3))); + c("circuit(IntVar[],int)", m -> m.circuit(x(m, 4, 1, 4), 1)); + c("circuit(IntVar[],int,CircuitConf)", m -> m.circuit(x(m, 4, 0, 3), 0, CircuitConf.RD)); + c("clausesIntChanneling(IntVar,BoolVar[],BoolVar[])", m -> m.clausesIntChanneling(v(m, "x", 0, 2), m.boolVarArray("e", 3), m.boolVarArray("l", 3))); + c("costRegular(IntVar[],IntVar,ICostAutomaton)", m -> { + int[][] costs = new int[3][3]; + for (int[] c : costs) Arrays.fill(c, 1); + costs[1][2] = 3; + return m.costRegular(x(m, 3, 0, 2), v(m, "c", 0, 5), CostAutomaton.makeSingleResource(automaton(), costs, 0, 5)); + }); + c("count(IntVar,IntVar[],IntVar)", m -> m.count(v(m, "val", 0, 2), x(m, 3, 0, 2), v(m, "n", 1, 2))); + c("count(int,IntVar[],IntVar)", m -> m.count(1, x(m, 3, 0, 2), v(m, "n", 1, 2))); + c("custom(String,Variable[],Function)", m -> m.custom("geq", x(m, 2, 0, 2), PropGreaterOrEqualX_Y::new)); + c("custom(String,Variable[],Object,BiFunction)", m -> m.custom("geq", x(m, 1, 0, 2), x(m, 1, 0, 2), (u, v) -> new PropGreaterOrEqualX_Y(new IntVar[]{u[0], v[0]}))); + c("decreasing(IntVar[],int)", m -> m.decreasing(x(m, 3, 0, 3), 1)); + c("diffN(IntVar[],IntVar[],IntVar[],IntVar[],boolean)", m -> m.diffN(m.intVarArray("x", 2, 0, 2), m.intVarArray("y", 2, 0, 2), m.intVarArray("w", 2, 1, 2), m.intVarArray("h", 2, 1, 2), true)); + c("diffN(IntVar[][],int[][])", m -> m.diffN(m.intVarMatrix("o", 2, 2, 0, 2), new int[][]{{1, 2}, {2, 1}})); + c("distance(IntVar,IntVar,String,IntVar)", m -> m.distance(v(m, "x", 0, 3), v(m, "y", 0, 3), "<", v(m, "z", 1, 2))); + c("distance(IntVar,IntVar,String,int)", m -> m.distance(v(m, "x", 0, 3), v(m, "y", 0, 3), "=", 1)); + c("div(IntVar,IntVar,IntVar)", m -> m.div(v(m, "x", 0, 6), v(m, "y", 1, 3), v(m, "z", 0, 6))); + c("element(IntVar,IntVar[],IntVar,int)", m -> m.element(v(m, "v", 0, 2), x(m, 3, 0, 2), v(m, "i", 1, 3), 1)); + c("element(IntVar,int[],IntVar)", m -> m.element(v(m, "v", 0, 5), ints(3, 1, 4, 1, 5), v(m, "i", 0, 4))); + c("element(IntVar,int[],IntVar,int)", m -> m.element(v(m, "v", 0, 5), ints(3, 1, 4, 1, 5), v(m, "i", 0, 6), 2)); + c("globalCardinality(IntVar[],int[],IntVar[],boolean)", m -> m.globalCardinality(x(m, 3, 0, 2), ints(0, 1), m.intVarArray("o", 2, 0, 3), false)); + c("globalCardinality(IntVar[],int[],IntVar[],boolean,String)", m -> m.globalCardinality(x(m, 3, 0, 2), ints(0, 1), m.intVarArray("o", 2, 0, 3), true, "AC")); + c("increasing(IntVar[],int)", m -> m.increasing(x(m, 3, 0, 3), 0)); + c("intValuePrecedeChain(IntVar[],int,int)", m -> m.intValuePrecedeChain(x(m, 4, 0, 2), 1, 2)); + c("intValuePrecedeChain(IntVar[],int[])", m -> m.intValuePrecedeChain(x(m, 4, 0, 2), ints(0, 1, 2))); + c("inverseChanneling(IntVar[],IntVar[])", m -> m.inverseChanneling(x(m, 3, 0, 2), m.intVarArray("y", 3, 0, 2))); + c("inverseChanneling(IntVar[],IntVar[],int,int)", m -> m.inverseChanneling(x(m, 3, 1, 3), m.intVarArray("y", 3, 1, 3), 1, 1)); + c("inverseChanneling(IntVar[],IntVar[],int,int,boolean)", m -> m.inverseChanneling(x(m, 3, 1, 3), m.intVarArray("y", 3, 0, 2), 0, 1, true)); + c("keySort(IntVar[][],IntVar[],IntVar[][],int)", m -> m.keySort(m.intVarMatrix("a", 3, 2, 0, 1), m.intVarArray("p", 3, 1, 3), m.intVarMatrix("s", 3, 2, 0, 1), 1)); + c("knapsack(IntVar[],IntVar,IntVar,int[],int[])", m -> m.knapsack(x(m, 3, 0, 2), v(m, "w", 0, 6), v(m, "e", 0, 10), ints(1, 2, 3), ints(2, 3, 4))); + c("lexChainLess(IntVar[][])", m -> m.lexChainLess(m.intVarMatrix("a", 3, 2, 0, 1))); + c("lexChainLessEq(IntVar[][])", m -> m.lexChainLessEq(m.intVarMatrix("a", 3, 2, 0, 1))); + c("lexLess(IntVar[],IntVar[])", m -> m.lexLess(x(m, 2, 0, 2), m.intVarArray("y", 2, 0, 2))); + c("lexLessEq(IntVar[],IntVar[])", m -> m.lexLessEq(x(m, 2, 0, 2), m.intVarArray("y", 2, 0, 2))); + c("max(BoolVar,BoolVar[])", m -> m.max(m.boolVar("m"), b(m, 3))); + c("max(IntVar,IntVar,IntVar)", m -> m.max(v(m, "m", 0, 3), v(m, "x", 0, 3), v(m, "y", 0, 3))); + c("max(IntVar,IntVar[])", m -> m.max(v(m, "m", 0, 3), x(m, 3, 0, 3))); + c("mddc(IntVar[],MultivaluedDecisionDiagram)", m -> { + IntVar[] x = x(m, 3, 0, 2); + return m.mddc(x, new MultivaluedDecisionDiagram(x, tuples(true, ints(0, 1, 2), ints(1, 1, 1), ints(2, 0, 1)))); + }); + c("member(IntVar,IntIterableRangeSet)", m -> m.member(v(m, "x", 0, 5), new IntIterableRangeSet(ints(1, 3, 4)))); + c("member(IntVar,int,int)", m -> m.member(v(m, "x", 0, 5), 2, 4)); + c("member(IntVar,int[])", m -> m.member(v(m, "x", 0, 5), ints(1, 5))); + c("min(BoolVar,BoolVar[])", m -> m.min(m.boolVar("m"), b(m, 3))); + c("min(IntVar,IntVar,IntVar)", m -> m.min(v(m, "m", 0, 3), v(m, "x", 0, 3), v(m, "y", 0, 3))); + c("min(IntVar,IntVar[])", m -> m.min(v(m, "m", 0, 3), x(m, 3, 0, 3))); + c("mod(IntVar,IntVar,IntVar)", m -> m.mod(v(m, "x", 0, 6), v(m, "y", 1, 3), v(m, "z", 0, 2))); + c("mod(IntVar,int,IntVar)", m -> m.mod(v(m, "x", 0, 6), 3, v(m, "z", 0, 2))); + c("mod(IntVar,int,int)", m -> m.mod(v(m, "x", 0, 6), 3, 1)); + c("multiCostRegular(IntVar[],IntVar[],ICostAutomaton)", m -> { + int[][][] costs = new int[3][3][2]; + for (int[][] l : costs) for (int[] c : l) Arrays.fill(c, 1); + return m.multiCostRegular(x(m, 3, 0, 2), m.intVarArray("c", 2, 0, 3), CostAutomaton.makeMultiResources(automaton(), costs, ints(0, 0), ints(3, 3))); + }); + c("multiCostRegular(IntVar[],IntVar[],ICostAutomaton,double)", m -> { + int[][][] costs = new int[3][3][2]; + for (int[][] l : costs) for (int[] c : l) Arrays.fill(c, 1); + return m.multiCostRegular(x(m, 3, 0, 2), m.intVarArray("c", 2, 0, 3), CostAutomaton.makeMultiResources(automaton(), costs, ints(0, 0), ints(3, 3)), 1e-4); + }); + c("nValues(IntVar[],IntVar)", m -> m.nValues(x(m, 3, 0, 2), v(m, "n", 1, 2))); + c("not(Constraint)", m -> m.not(m.arithm(v(m, "x", 0, 3), "=", 1))); + c("notAllEqual(IntVar[])", m -> m.notAllEqual(x(m, 3, 0, 1))); + c("notMember(IntVar,IntIterableRangeSet)", m -> m.notMember(v(m, "x", 0, 5), new IntIterableRangeSet(ints(1, 3, 4)))); + c("notMember(IntVar,int,int)", m -> m.notMember(v(m, "x", 0, 5), 2, 4)); + c("notMember(IntVar,int[])", m -> m.notMember(v(m, "x", 0, 5), ints(1, 5))); + c("or(BoolVar[])", m -> m.or(b(m, 3))); + c("or(Constraint[])", m -> { + IntVar[] x = x(m, 2, 0, 2); + return m.or(m.arithm(x[0], "<", x[1]), m.arithm(x[1], "=", 0)); + }); + c("path(IntVar[],IntVar,IntVar)", m -> m.path(x(m, 4, 0, 4), v(m, "s", 0, 3), v(m, "e", 0, 3))); + c("path(IntVar[],IntVar,IntVar,int)", m -> m.path(x(m, 4, 1, 5), v(m, "s", 1, 4), v(m, "e", 1, 4), 1)); + c("pow(IntVar,IntVar,IntVar)", m -> m.pow(v(m, "b", 0, 2), v(m, "e", 0, 2), v(m, "r", 0, 4))); + c("pow(IntVar,int,IntVar)", m -> m.pow(v(m, "b", -2, 2), 2, v(m, "r", 0, 4))); + c("regular(IntVar[],IAutomaton)", m -> m.regular(x(m, 3, 0, 2), automaton())); + c("scalar(IntVar[],int[],String,IntVar)", m -> m.scalar(x(m, 3, 0, 2), ints(1, 2, 3), "<=", v(m, "s", 0, 6))); + c("scalar(IntVar[],int[],String,IntVar,int)", m -> m.scalar(x(m, 3, 0, 2), ints(1, 2, 3), "=", v(m, "s", 0, 6), 2)); + c("scalar(IntVar[],int[],String,int)", m -> m.scalar(x(m, 3, 0, 2), ints(1, 2, 3), "=", 4)); + c("scalar(IntVar[],int[],String,int,int)", m -> m.scalar(x(m, 3, 0, 2), ints(1, -2, 3), ">=", 2, 2)); + c("sort(IntVar[],IntVar[])", m -> m.sort(x(m, 3, 0, 2), m.intVarArray("y", 3, 0, 2))); + c("square(IntVar,IntVar)", m -> m.square(v(m, "y", 0, 9), v(m, "x", -3, 3))); + c("subCircuit(IntVar[],int,IntVar)", m -> m.subCircuit(x(m, 4, 0, 3), 0, v(m, "l", 0, 4))); + c("subPath(IntVar[],IntVar,IntVar,int,IntVar)", m -> m.subPath(x(m, 4, 0, 4), v(m, "s", 0, 3), v(m, "e", 0, 3), 0, v(m, "l", 1, 4))); + c("sum(BoolVar[],String,IntVar)", m -> m.sum(b(m, 3), "=", v(m, "s", 0, 2))); + c("sum(BoolVar[],String,IntVar,int)", m -> m.sum(b(m, 3), "<=", v(m, "s", 0, 2), 2)); + c("sum(BoolVar[],String,int)", m -> m.sum(b(m, 3), "=", 2)); + c("sum(IntVar[],String,IntVar)", m -> m.sum(x(m, 3, 0, 2), "=", v(m, "s", 0, 6))); + c("sum(IntVar[],String,IntVar,int)", m -> m.sum(x(m, 3, 0, 2), "<", v(m, "s", 0, 6), 2)); + c("sum(IntVar[],String,IntVar[])", m -> m.sum(x(m, 2, 0, 2), "=", m.intVarArray("y", 2, 0, 2))); + c("sum(IntVar[],String,int)", m -> m.sum(x(m, 3, 0, 2), "=", 3)); + c("sum(IntVar[],String,int,int)", m -> m.sum(x(m, 3, 0, 2), "!=", 3, 2)); + c("table(IntVar,IntVar,Tuples)", m -> m.table(v(m, "x", 0, 2), v(m, "y", 0, 2), tuples(true, ints(0, 1), ints(1, 2), ints(2, 2)))); + c("table(IntVar,IntVar,Tuples,String)", m -> m.table(v(m, "x", 0, 2), v(m, "y", 0, 2), tuples(false, ints(0, 1), ints(1, 2)), "AC3")); + c("table(IntVar[],HybridTuples)", m -> { + HybridTuples h = new HybridTuples(); + h.add(HybridTuples.eq(1), HybridTuples.any(), HybridTuples.ge(1)); + h.add(HybridTuples.ne(1), HybridTuples.col(0), HybridTuples.lt(2)); + return m.table(x(m, 3, 0, 2), h); + }); + c("table(IntVar[],Tuples)", m -> m.table(x(m, 3, 0, 2), tuples(true, ints(0, 1, 2), ints(1, 1, 1), ints(2, 0, 1)))); + c("table(IntVar[],Tuples,String)", m -> m.table(x(m, 3, 0, 2), tuples(true, ints(0, 1, 2), ints(1, 1, 1), ints(2, 0, 1)), "STR2+")); + c("times(IntVar,IntVar,IntVar)", m -> m.times(v(m, "x", -2, 2), v(m, "y", 0, 2), v(m, "z", -4, 4))); + c("times(IntVar,IntVar,int)", m -> m.times(v(m, "x", 0, 3), v(m, "y", 0, 3), 2)); + c("times(IntVar,int,IntVar)", m -> m.times(v(m, "x", 0, 3), 2, v(m, "z", 0, 6))); + c("tree(IntVar[],IntVar)", m -> m.tree(x(m, 4, 0, 3), v(m, "n", 1, 2))); + c("tree(IntVar[],IntVar,int)", m -> m.tree(x(m, 4, 1, 4), v(m, "n", 1, 2), 1)); + s("conditional(Variable[],Function,Supplier) [unsupported by design]", m -> { + IntVar[] x = x(m, 2, 0, 2); + m.conditional(x, vs -> vs[0].isInstantiated(), () -> new Constraint[]{m.arithm(x[0], "!=", x[1])}); + }); + + // ---------------------------------------------------------------- decompositions + s("allDifferentExceptDec(IntVar[],int[])", m -> m.allDifferentExceptDec(x(m, 3, 0, 2), ints(0))); + s("allDifferentExceptDecWithGCC(IntVar[],int[])", m -> m.allDifferentExceptDecWithGCC(x(m, 3, 0, 2), ints(0))); + s("amongDec(IntVar,IntVar[],IntVar[])", m -> m.amongDec(v(m, "n", 0, 3), x(m, 3, 0, 2), new IntVar[]{m.intVar(1), m.intVar(2)})); + s("argmaxDec(IntVar,int,IntVar[])", m -> m.argmaxDec(v(m, "z", 0, 2), 0, x(m, 3, 0, 2))); + s("argminDec(IntVar,int,IntVar[])", m -> m.argminDec(v(m, "z", 0, 2), 0, x(m, 3, 0, 2))); + s("binPackingDec(IntVar[],int[],IntVar[],int)", m -> m.binPackingDec(x(m, 3, 0, 1), ints(1, 2, 2), m.intVarArray("l", 2, 0, 4), 0)); + s("circuitDec(IntVar[],int)", m -> m.circuitDec(x(m, 4, 0, 3), 0)); + s("costFlow(int[],int[],int[],int[],IntVar[],IntVar,int)", m -> m.costFlow(ints(0, 1, 0), ints(1, 2, 2), ints(1, 0, -1), ints(1, 1, 3), m.intVarArray("f", 3, 0, 1), v(m, "c", 0, 5), 0)); + s("cumulativeDec(IntVar[],IntVar[],IntVar[],IntVar)", m -> m.cumulativeDec(x(m, 2, 0, 2), m.intVarArray("d", 2, 1, 2), m.intVarArray("h", 2, 1, 1), v(m, "c", 1, 1))); + s("cumulativeDec(Task[],IntVar[],IntVar)", m -> m.cumulativeDec(tasks(m, 2), m.intVarArray("h", 2, 1, 1), v(m, "c", 1, 1))); + s("cumulativeTimeDec(IntVar[],int[],int[],int)", m -> m.cumulativeTimeDec(x(m, 2, 0, 2), ints(1, 2), ints(1, 1), 1)); + s("disjunctiveDec(Task[],boolean)", m -> m.disjunctiveDec(tasks(m, 2), true)); + s("element(IntVar,IntVar[][],IntVar,int,IntVar,int)", m -> m.element(v(m, "v", 0, 2), m.intVarMatrix("t", 2, 2, 0, 2), v(m, "r", 0, 1), 0, v(m, "c", 1, 2), 1)); + s("element(IntVar,int[][],IntVar,int,IntVar,int)", m -> m.element(v(m, "v", 0, 5), new int[][]{{1, 2}, {3, 4}}, v(m, "r", 0, 1), 0, v(m, "c", 0, 1), 0)); + s("globalCardinalityDec(IntVar[],IntVar[],IntVar[],boolean)", m -> m.globalCardinalityDec(x(m, 3, 0, 2), new IntVar[]{m.intVar(0), m.intVar(1)}, m.intVarArray("o", 2, 0, 3), false)); + s("ifThenElseDec(BoolVar[],IntVar[],IntVar)", m -> m.ifThenElseDec(b(m, 2), x(m, 2, 0, 2), v(m, "y", 0, 2))); + s("ifThenElseDec(BoolVar[],int[],IntVar)", m -> m.ifThenElseDec(b(m, 2), ints(1, 2), v(m, "y", 0, 2))); + s("intValuePrecedeChainDec(IntVar[],int,int)", m -> m.intValuePrecedeChainDec(x(m, 4, 0, 2), 1, 2)); + s("intValuePrecedeChainDec(IntVar[],int[])", m -> m.intValuePrecedeChainDec(x(m, 4, 0, 2), ints(0, 1, 2))); + s("product(BoolVar[][],BoolVar[][],BoolVar[][])", m -> m.product(m.boolVarMatrix("a", 2, 2), m.boolVarMatrix("b", 2, 2), m.boolVarMatrix("c", 2, 2))); + s("product(IntVar[][],IntVar[][],IntVar[][])", m -> m.product(m.intVarMatrix("a", 2, 2, 0, 1), m.intVarMatrix("b", 2, 2, 0, 1), m.intVarMatrix("c", 2, 2, 0, 2))); + s("regularDec(IntVar[],IAutomaton)", m -> m.regularDec(x(m, 3, 0, 2), automaton())); + + // ---------------------------------------------------------------- reifications + s("ifOnlyIf(Constraint,Constraint)", m -> { + IntVar[] x = x(m, 2, 0, 2); + m.ifOnlyIf(m.arithm(x[0], "=", 1), m.arithm(x[1], ">", 0)); + }); + s("ifThen(BoolVar,Constraint)", m -> m.ifThen(m.boolVar("b"), m.arithm(v(m, "x", 0, 2), "=", 1))); + s("ifThen(Constraint,Constraint)", m -> { + IntVar[] x = x(m, 2, 0, 2); + m.ifThen(m.arithm(x[0], "=", 1), m.arithm(x[1], ">", 0)); + }); + s("ifThenElse(BoolVar,Constraint,Constraint)", m -> { + IntVar x = v(m, "x", 0, 2); + m.ifThenElse(m.boolVar("b"), m.arithm(x, "=", 1), m.arithm(x, "=", 2)); + }); + s("ifThenElse(Constraint,Constraint,Constraint)", m -> { + IntVar[] x = x(m, 2, 0, 2); + m.ifThenElse(m.arithm(x[0], "=", 1), m.arithm(x[1], ">", 0), m.arithm(x[1], "=", 0)); + }); + s("impXrelC(IntVar,String,int,BoolVar)", m -> m.impXrelC(v(m, "x", 0, 3), "<", 2, m.boolVar("b"))); + s("impXrelYC(IntVar,String,IntVar,int,BoolVar)", m -> m.impXrelYC(v(m, "x", 0, 3), "<=", v(m, "y", 0, 3), 1, m.boolVar("b"))); + s("reifXrelC(IntVar,String,int,BoolVar)", m -> m.reifXrelC(v(m, "x", 0, 3), ">=", 1, m.boolVar("b"))); + s("reifXrelYC(IntVar,String,IntVar,int,BoolVar)", m -> m.reifXrelYC(v(m, "x", 0, 3), "=", v(m, "y", 0, 3), 1, m.boolVar("b"))); + s("reification(BoolVar,Constraint)", m -> m.reification(m.boolVar("b"), m.arithm(v(m, "x", 0, 3), "=", 1))); + s("reifyXeqC(IntVar,int,BoolVar)", m -> m.reifyXeqC(v(m, "x", 0, 3), 1, m.boolVar("b"))); + s("reifyXeqY(IntVar,IntVar,BoolVar)", m -> m.reifyXeqY(v(m, "x", 0, 3), v(m, "y", 0, 3), m.boolVar("b"))); + s("reifyXeqYC(IntVar,IntVar,int,BoolVar)", m -> m.reifyXeqYC(v(m, "x", 0, 3), v(m, "y", 0, 3), 1, m.boolVar("b"))); + s("reifyXgeY(IntVar,IntVar,BoolVar)", m -> m.reifyXgeY(v(m, "x", 0, 3), v(m, "y", 0, 3), m.boolVar("b"))); + s("reifyXgtC(IntVar,int,BoolVar)", m -> m.reifyXgtC(v(m, "x", 0, 3), 1, m.boolVar("b"))); + s("reifyXgtY(IntVar,IntVar,BoolVar)", m -> m.reifyXgtY(v(m, "x", 0, 3), v(m, "y", 0, 3), m.boolVar("b"))); + s("reifyXgtYC(IntVar,IntVar,int,BoolVar)", m -> m.reifyXgtYC(v(m, "x", 0, 3), v(m, "y", 0, 3), 1, m.boolVar("b"))); + s("reifyXinS(IntVar,IntIterableRangeSet,BoolVar)", m -> m.reifyXinS(v(m, "x", 0, 5), new IntIterableRangeSet(ints(1, 4)), m.boolVar("b"))); + s("reifyXleY(IntVar,IntVar,BoolVar)", m -> m.reifyXleY(v(m, "x", 0, 3), v(m, "y", 0, 3), m.boolVar("b"))); + s("reifyXltC(IntVar,int,BoolVar)", m -> m.reifyXltC(v(m, "x", 0, 3), 2, m.boolVar("b"))); + s("reifyXltY(IntVar,IntVar,BoolVar)", m -> m.reifyXltY(v(m, "x", 0, 3), v(m, "y", 0, 3), m.boolVar("b"))); + s("reifyXltYC(IntVar,IntVar,int,BoolVar)", m -> m.reifyXltYC(v(m, "x", 0, 3), v(m, "y", 0, 3), 1, m.boolVar("b"))); + s("reifyXneC(IntVar,int,BoolVar)", m -> m.reifyXneC(v(m, "x", 0, 3), 1, m.boolVar("b"))); + s("reifyXneY(IntVar,IntVar,BoolVar)", m -> m.reifyXneY(v(m, "x", 0, 3), v(m, "y", 0, 3), m.boolVar("b"))); + s("reifyXneYC(IntVar,IntVar,int,BoolVar)", m -> m.reifyXneYC(v(m, "x", 0, 3), v(m, "y", 0, 3), 1, m.boolVar("b"))); + s("reifyXnotinS(IntVar,IntIterableRangeSet,BoolVar)", m -> m.reifyXnotinS(v(m, "x", 0, 5), new IntIterableRangeSet(ints(1, 4)), m.boolVar("b"))); + + // ---------------------------------------------------------------- result variables + s("argmax(String,IntVar[])", m -> m.arithm(m.argmax("a", x(m, 3, 0, 2)), "!=", 1).post()); + s("argmin(String,IntVar[])", m -> m.arithm(m.argmin("a", x(m, 3, 0, 2)), "!=", 1).post()); + s("count(String,IntVar,IntVar[])", m -> m.arithm(m.count("c", v(m, "v", 0, 2), x(m, 3, 0, 2)), ">", 1).post()); + s("count(String,int,IntVar[])", m -> m.arithm(m.count("c", 1, x(m, 3, 0, 2)), ">", 1).post()); + s("element(String,IntVar[],IntVar,int)", m -> m.arithm(m.element("e", x(m, 3, 0, 2), v(m, "i", 1, 3), 1), "!=", 1).post()); + s("element(String,int[],IntVar)", m -> m.arithm(m.element("e", ints(3, 1, 4), v(m, "i", 0, 2)), "!=", 1).post()); + s("element(String,int[],IntVar,int)", m -> m.arithm(m.element("e", ints(3, 1, 4), v(m, "i", 1, 3), 1), "!=", 1).post()); + s("max(String,IntVar[])", m -> m.arithm(m.max("m", x(m, 3, 0, 2)), "!=", 1).post()); + s("min(String,IntVar[])", m -> m.arithm(m.min("m", x(m, 3, 0, 2)), "!=", 1).post()); + s("mod(String,IntVar,IntVar)", m -> m.arithm(m.mod("r", v(m, "x", 0, 6), v(m, "y", 1, 3)), "!=", 1).post()); + s("mod(String,IntVar,int)", m -> m.arithm(m.mod("r", v(m, "x", 0, 6), 3), "!=", 1).post()); + s("sum(String,IntVar[])", m -> m.arithm(m.sum("s", x(m, 3, 0, 2)), "=", 3).post()); + + // ---------------------------------------------------------------- clauses + s("addClause(int[]), lit(int), neg(int), satVar(Variable,Literalizer)", m -> { + BoolVar[] b = b(m, 3); + m.addClause(m.lit(b[0].satVar()), m.neg(b[1].satVar())); + m.addClause(m.lit(b[2].satVar()), m.lit(b[1].satVar())); + }); + s("sat()", m -> { + b(m, 2); + m.sat(); + }); + s("addClauseFalse(BoolVar)", m -> m.addClauseFalse(b(m, 2)[0])); + s("addClauseTrue(BoolVar)", m -> m.addClauseTrue(b(m, 2)[0])); + s("addClauses(BoolVar[],BoolVar[])", m -> { + BoolVar[] b = b(m, 3); + m.addClauses(new BoolVar[]{b[0], b[1]}, new BoolVar[]{b[2]}); + }); + s("addClauses(LogOp)", m -> { + BoolVar[] b = b(m, 3); + m.addClauses(LogOp.or(b[0], LogOp.and(b[1], LogOp.nor(b[2])))); + }); + s("addClausesAtMostNMinusOne(BoolVar[])", m -> m.addClausesAtMostNMinusOne(b(m, 3))); + s("addClausesAtMostOne(BoolVar[])", m -> m.addClausesAtMostOne(b(m, 3))); + s("addClausesBoolAndArrayEqVar(BoolVar[],BoolVar)", m -> m.addClausesBoolAndArrayEqVar(b(m, 2), m.boolVar("r"))); + s("addClausesBoolAndArrayEqualFalse(BoolVar[])", m -> m.addClausesBoolAndArrayEqualFalse(b(m, 3))); + s("addClausesBoolAndEqVar(BoolVar,BoolVar,BoolVar)", m -> { + BoolVar[] b = b(m, 3); + m.addClausesBoolAndEqVar(b[0], b[1], b[2]); + }); + s("addClausesBoolEq(BoolVar,BoolVar)", m -> { + BoolVar[] b = b(m, 2); + m.addClausesBoolEq(b[0], b[1]); + }); + s("addClausesBoolIsEqVar(BoolVar,BoolVar,BoolVar)", m -> { + BoolVar[] b = b(m, 3); + m.addClausesBoolIsEqVar(b[0], b[1], b[2]); + }); + s("addClausesBoolIsLeVar(BoolVar,BoolVar,BoolVar)", m -> { + BoolVar[] b = b(m, 3); + m.addClausesBoolIsLeVar(b[0], b[1], b[2]); + }); + s("addClausesBoolIsLtVar(BoolVar,BoolVar,BoolVar)", m -> { + BoolVar[] b = b(m, 3); + m.addClausesBoolIsLtVar(b[0], b[1], b[2]); + }); + s("addClausesBoolIsNeqVar(BoolVar,BoolVar,BoolVar)", m -> { + BoolVar[] b = b(m, 3); + m.addClausesBoolIsNeqVar(b[0], b[1], b[2]); + }); + s("addClausesBoolLe(BoolVar,BoolVar)", m -> { + BoolVar[] b = b(m, 2); + m.addClausesBoolLe(b[0], b[1]); + }); + s("addClausesBoolLt(BoolVar,BoolVar)", m -> { + BoolVar[] b = b(m, 2); + m.addClausesBoolLt(b[0], b[1]); + }); + s("addClausesBoolNot(BoolVar,BoolVar)", m -> { + BoolVar[] b = b(m, 2); + m.addClausesBoolNot(b[0], b[1]); + }); + s("addClausesBoolOrArrayEqVar(BoolVar[],BoolVar)", m -> m.addClausesBoolOrArrayEqVar(b(m, 2), m.boolVar("r"))); + s("addClausesBoolOrArrayEqualTrue(BoolVar[])", m -> m.addClausesBoolOrArrayEqualTrue(b(m, 3))); + s("addClausesBoolOrEqVar(BoolVar,BoolVar,BoolVar)", m -> { + BoolVar[] b = b(m, 3); + m.addClausesBoolOrEqVar(b[0], b[1], b[2]); + }); + s("addClausesBoolXorEqVar(BoolVar,BoolVar,BoolVar)", m -> { + BoolVar[] b = b(m, 3); + m.addClausesBoolXorEqVar(b[0], b[1], b[2]); + }); + s("addClausesMaxBoolArrayLessEqVar(BoolVar[],BoolVar)", m -> m.addClausesMaxBoolArrayLessEqVar(b(m, 2), m.boolVar("r"))); + s("addClausesSumBoolArrayGreaterEqVar(BoolVar[],BoolVar)", m -> m.addClausesSumBoolArrayGreaterEqVar(b(m, 2), m.boolVar("r"))); + s("addClausesSumBoolArrayLessEqKVar(BoolVar[],BoolVar)", m -> m.addClausesSumBoolArrayLessEqKVar(b(m, 3), m.boolVar("r"))); + s("addConstructiveDisjunction(Constraint[])", m -> { + IntVar[] x = x(m, 2, 0, 3); + m.addConstructiveDisjunction(m.arithm(x[0], "<", x[1]), m.arithm(x[0], "=", 3)); + }); + s("addElement(IntVar,int[],IntVar,int)", m -> m.addElement(v(m, "v", 0, 5), ints(3, 1, 4), v(m, "i", 0, 2), 0)); + s("addTable(IntVar[],HybridTuples)", m -> { + HybridTuples h = new HybridTuples(); + h.add(HybridTuples.eq(1), HybridTuples.any()); + h.add(HybridTuples.ne(1), HybridTuples.col(0)); + m.addTable(x(m, 2, 0, 2), h); + }); + s("addTable(IntVar[],Tuples)", m -> m.addTable(x(m, 2, 0, 2), tuples(true, ints(0, 1), ints(2, 2)))); + + // ---------------------------------------------------------------- scheduling + s("cumulative(IntVar[],int[],int[],int)", m -> m.cumulative(x(m, 3, 0, 3), ints(1, 2, 1), ints(1, 1, 2), 2).post()); + s("cumulative(List,List,IntVar)", m -> m.cumulative(Arrays.asList(tasks(m, 2)), Arrays.asList(m.intVar(1), m.intVar(1)), v(m, "c", 1, 2)).post()); + s("cumulative(Task[],IntVar[],IntVar)", m -> m.cumulative(tasks(m, 3), m.intVarArray("h", 3, 1, 2), v(m, "c", 2, 3)).post()); + s("cumulative(Task[],IntVar[],IntVar,boolean,boolean)", m -> m.cumulative(tasks(m, 3), m.intVarArray("h", 3, 1, 2), v(m, "c", 2, 3), true, false).post()); + s("setTimes(Task[])", m -> { + Task[] t = tasks(m, 2); + cum(m, t); + m.setTimes(t); + }); + s("smallest(Task[]), smallest(Task[],ArbitrationRule), before(Task,Task,ArbitrationRule)", m -> { + Task[] t = tasks(m, 2); + cum(m, t); + m.smallest(t); + m.smallest(t, ISchedulingFactory.ArbitrationRule.MIN_ECT); + m.before(t[0], t[1], ISchedulingFactory.ArbitrationRule.MIN_EST); + }); + } + + //*********************************************************************************** + // CHECKS + //*********************************************************************************** + + /** + * The modes in which the cases are checked: some factories build different constraints (or variables) with + * the SAT solver or with LCG. + */ + private enum Mode { + DEFAULT, SAT, LCG; + + Settings settings() { + switch (this) { + case SAT: + return SettingsBuilder.init().setEnableSAT(true).build(); + case LCG: + return SettingsBuilder.init().setLCG(true).build(); + default: + return SettingsBuilder.init().build(); + } + } + } + + @DataProvider + public Object[][] cases() { + List res = new ArrayList<>(); + for (Mode mode : Mode.values()) { + for (Case c : CASES) { + res.add(new Object[]{mode, c}); + } + } + return res.toArray(new Object[0][]); + } + + @Test(groups = "1s", timeOut = 60000, dataProvider = "cases") + public void testCase(Mode mode, Case c) { + Settings settings = mode.settings(); + // what the solver itself does not support (in this mode) is out of the scope of the duplication + Model direct = new Model("m", settings); + String stats; + try { + c.body().accept(direct); + stats = SpecTestUtils.solve(direct, 50_000); + } catch (Throwable e) { + throw new SkipException("not supported by the solver in mode " + mode + ": " + e); + } + direct = new Model("m", settings); + c.body().accept(direct); + RecordingModel recorded = new RecordingModel("m", settings); + c.body().accept(recorded); + if (c.name().contains("[unsupported by design]")) { + SpecException e = Assert.expectThrows(SpecException.class, recorded::snapshot); + Assert.assertTrue(e.getMessage().contains("lambda"), e.getMessage()); + return; + } + ModelSpec spec = recorded.snapshot(); + Model replayed = spec.instantiate().model(); + String expected = SpecTestUtils.structure(direct); + Assert.assertEquals(SpecTestUtils.structure(recorded), expected, "recording changes the model"); + Assert.assertEquals(SpecTestUtils.structure(replayed), expected, "the replayed model differs"); + Assert.assertEquals(SpecTestUtils.solve(recorded, 50_000), stats, "the recorded model searches differently"); + Assert.assertEquals(SpecTestUtils.solve(replayed, 50_000), stats, "the replayed model searches differently"); + } + + /** + * @return the factory methods dealing only with integer and boolean variables + */ + private static Set scope() { + Set scope = new TreeSet<>(); + for (Method m : IModel.class.getMethods()) { + if (!m.isDefault() || Modifier.isStatic(m.getModifiers()) || m.isSynthetic() || RecordingModel.EXCLUDED.contains(m.getName())) { + continue; + } + String types = Arrays.stream(m.getGenericParameterTypes()).map(Type::getTypeName) + .collect(Collectors.joining(" ")) + " " + m.getGenericReturnType().getTypeName(); + boolean real = Arrays.stream(m.getParameterTypes()).anyMatch(p -> p == double.class || p == double[].class) + && !m.getName().equals("multiCostRegular"); + String sig = signature(m.getName(), m.getParameterTypes()); + if (OUT_OF_SCOPE.stream().noneMatch(types::contains) && !real && !NOT_IMPLEMENTED.contains(sig)) { + scope.add(sig); + } + } + return scope; + } + + private static String signature(String name, Class[] params) { + return name + Arrays.stream(params).map(Class::getSimpleName).collect(Collectors.joining(",", "(", ")")); + } + + @Test(groups = "1s", timeOut = 60000) + public void testEveryIntFactoryMethodIsCovered() { + Set called = new TreeSet<>(); + for (Mode mode : Mode.values()) { + for (Case c : CASES) { + record(mode, c, called); + } + } + List missing = scope().stream().filter(s -> !called.contains(s)).collect(Collectors.toList()); + Assert.assertTrue(missing.isEmpty(), missing.size() + " factory methods are not covered: " + missing); + } + + /** + * Run a case on a recording model and collect the factory methods called at top level. + */ + private static void record(Mode mode, Case c, Set called) { + { + RecordingModel m = new RecordingModel("m", mode.settings()); + try { + c.body().accept(m); + } catch (Throwable ignored) { + // not supported by the solver in this mode + return; + } + // the calls are recorded, whether journalizable or not + m.getRecorder().unsupported().forEach(u -> called.add(u.operation().replace(" ", ""))); + try { + for (Step s : m.snapshot().steps()) { + if (s instanceof Step.Call) { + called.add(((Step.Call) s).method().toString().replace(" ", "")); + } + } + } catch (SpecException ignored) { + // unsupported by design + } + } + } +} diff --git a/solver/src/test/java/org/chocosolver/solver/spec/ModelSpecTest.java b/solver/src/test/java/org/chocosolver/solver/spec/ModelSpecTest.java new file mode 100644 index 0000000000..01634f1a15 --- /dev/null +++ b/solver/src/test/java/org/chocosolver/solver/spec/ModelSpecTest.java @@ -0,0 +1,399 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.Solver; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.constraints.extension.Tuples; +import org.chocosolver.solver.constraints.nary.automata.FA.FiniteAutomaton; +import org.chocosolver.solver.constraints.nary.cnf.LogOp; +import org.chocosolver.solver.exception.SolverException; +import org.chocosolver.solver.search.strategy.Search; +import org.chocosolver.solver.variables.BoolVar; +import org.chocosolver.solver.variables.IntVar; +import org.chocosolver.solver.variables.Task; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +/** + * Tests of {@link ModelSpec} built by hand, without recorder. + * + * @author Charles Prud'homme + */ +public class ModelSpecTest { + + /** + * Enumerate all solutions with a deterministic search. + * + * @return solutions, nodes and fails + */ + private static String solveAll(Model model, IntVar... decisions) { + Solver s = model.getSolver(); + s.setSearch(Search.inputOrderLBSearch(decisions)); + while (s.solve()) ; + return s.getSolutionCount() + " sols, " + s.getNodeCount() + " nodes, " + s.getFailCount() + " fails"; + } + + private record Queens(ModelSpec spec, int[] q) { + } + + private static Queens queensSpec(int n) { + SpecBuilder b = new SpecBuilder(Header.of("queens")); + int[] q = b.callArray(n, "intVarArray", "q", n, 1, n); + for (int i = 0; i < n - 1; i++) { + for (int j = i + 1; j < n; j++) { + b.post(b.call("arithm", b.ref(q[i]), "!=", b.ref(q[j]))); + b.post(b.call("arithm", b.ref(q[i]), "!=", b.ref(q[j]), "+", j - i)); + b.post(b.call("arithm", b.ref(q[i]), "!=", b.ref(q[j]), "-", j - i)); + } + } + return new Queens(b.build(), q); + } + + private static String queensDirect(int n) { + Model m = new Model("queens"); + IntVar[] q = m.intVarArray("q", n, 1, n); + for (int i = 0; i < n - 1; i++) { + for (int j = i + 1; j < n; j++) { + m.arithm(q[i], "!=", q[j]).post(); + m.arithm(q[i], "!=", q[j], "+", j - i).post(); + m.arithm(q[i], "!=", q[j], "-", j - i).post(); + } + } + return solveAll(m, q); + } + + @Test(groups = "1s", timeOut = 60000) + public void testQueensSameSearchAsDirectModel() { + Queens qs = queensSpec(8); + Resolver i1 = qs.spec().instantiate(); + Resolver i2 = qs.spec().instantiate(); + Assert.assertNotSame(i1.model(), i2.model()); + Assert.assertEquals(i1.model().getNbVars(), 8); + Assert.assertEquals(i1.model().getNbCstrs(), 3 * 28); + String direct = queensDirect(8); + Assert.assertEquals(solveAll(i1.model(), i1.intVars(qs.q())), direct); + Assert.assertEquals(solveAll(i2.model(), i2.intVars(qs.q())), direct); + Assert.assertTrue(direct.startsWith("92 sols")); + } + + @Test(groups = "1s", timeOut = 60000) + public void testInstancesAreIndependent() { + Queens qs = queensSpec(4); + Resolver i1 = qs.spec().instantiate(); + Resolver i2 = qs.spec().instantiate(); + for (int id : qs.q()) { + IntVar v1 = i1.intVar(id); + IntVar v2 = i2.intVar(id); + Assert.assertNotSame(v1, v2); + Assert.assertSame(v1.getModel(), i1.model()); + Assert.assertSame(v2.getModel(), i2.model()); + Assert.assertEquals(i1.idOf(v1), id); + Assert.assertEquals(i1.idOf(v2), -1); + } + } + + @Test(groups = "10s", timeOut = 120000) + public void testConcurrentInstantiations() throws Exception { + Queens qs = queensSpec(8); + String direct = queensDirect(8); + ExecutorService pool = Executors.newFixedThreadPool(8); + try { + List> futures = new ArrayList<>(); + for (int k = 0; k < 16; k++) { + futures.add(pool.submit(() -> { + Resolver inst = qs.spec().instantiate(); + return solveAll(inst.model(), inst.intVars(qs.q())); + })); + } + for (Future f : futures) { + Assert.assertEquals(f.get(), direct); + } + } finally { + pool.shutdownNow(); + } + } + + @Test(groups = "1s", timeOut = 60000) + public void testHeader() { + Header h = Header.of("h").withSeed(42); + Model m = new ModelSpec(h, List.of()).instantiate().model(); + Assert.assertEquals(m.getName(), "h"); + Assert.assertEquals(m.getSeed(), 42L); + Assert.assertSame(m.getSettings(), h.settings()); + } + + @Test(groups = "1s", timeOut = 60000) + public void testVarargsAndAllDifferent() { + SpecBuilder b = new SpecBuilder(Header.of("ad")); + int[] x = b.callArray(3, "intVarArray", "x", 3, 1, 3); + b.post(b.call("allDifferent", b.refs(IntVar.class, x))); + Resolver inst = b.build().instantiate(); + Assert.assertEquals(solveAll(inst.model(), inst.intVars(x)).split(",")[0], "6 sols"); + } + + @Test(groups = "1s", timeOut = 60000) + public void testReificationSteps() { + // direct + Model m = new Model(); + IntVar x = m.intVar("x", 0, 5); + Constraint c1 = m.arithm(x, ">", 2); + BoolVar b1 = c1.reify(); + BoolVar b2 = m.boolVar("b2"); + m.arithm(x, "<", 4).reifyWith(b2); + BoolVar b3 = m.boolVar("b3"); + m.arithm(x, "=", 0).implies(b3); + BoolVar b4 = m.boolVar("b4"); + m.arithm(x, "=", 5).impliedBy(b4); + m.sum(new BoolVar[]{b1, b2}, "=", 1).post(); + String direct = solveAll(m, x, b1, b2, b3, b4); + // spec + SpecBuilder b = new SpecBuilder(Header.of("reif")); + int ix = b.call("intVar", "x", 0, 5); + int ib1 = b.reify(b.call("arithm", b.ref(ix), ">", 2)); + int ib2 = b.call("boolVar", "b2"); + b.reifyWith(b.call("arithm", b.ref(ix), "<", 4), ib2); + int ib3 = b.call("boolVar", "b3"); + b.implies(b.call("arithm", b.ref(ix), "=", 0), ib3); + int ib4 = b.call("boolVar", "b4"); + b.impliedBy(b.call("arithm", b.ref(ix), "=", 5), ib4); + b.post(b.call("sum", b.refs(BoolVar.class, ib1, ib2), "=", 1)); + Resolver inst = b.build().instantiate(); + Assert.assertEquals(inst.model().getNbCstrs(), m.getNbCstrs()); + Assert.assertEquals(solveAll(inst.model(), inst.intVars(ix, ib1, ib2, ib3, ib4)), direct); + } + + @Test(groups = "1s", timeOut = 60000) + public void testUnpost() { + SpecBuilder b = new SpecBuilder(Header.of("unpost")); + int x = b.call("intVar", "x", 0, 3); + int c = b.call("arithm", b.ref(x), ">", 1); + b.post(c).unpost(c); + Resolver inst = b.build().instantiate(); + Assert.assertEquals(inst.model().getNbCstrs(), 0); + Assert.assertEquals(solveAll(inst.model(), inst.intVar(x)).split(",")[0], "4 sols"); + } + + @Test(groups = "1s", timeOut = 60000) + public void testTableSharesFrozenTuples() { + Tuples tuples = new Tuples(true); + tuples.add(1, 2); + tuples.add(2, 1); + tuples.add(3, 3); + SpecBuilder b = new SpecBuilder(Header.of("table")); + int[] x = b.callArray(2, "intVarArray", "x", 2, 1, 3); + b.post(b.call("table", b.refs(IntVar.class, x), tuples)); + ModelSpec spec = b.build(); + Assert.assertTrue(tuples.isFrozen()); + Assert.assertThrows(SolverException.class, () -> tuples.add(1, 1)); + Assert.assertThrows(SolverException.class, tuples::sort); + for (int k = 0; k < 2; k++) { + Resolver inst = spec.instantiate(); + Assert.assertEquals(solveAll(inst.model(), inst.intVars(x)).split(",")[0], "3 sols"); + } + } + + @Test(groups = "1s", timeOut = 60000) + public void testArraysAreCopied() { + int[] coeffs = {1, 2, 3}; + SpecBuilder b = new SpecBuilder(Header.of("scalar")); + int[] x = b.callArray(3, "intVarArray", "x", 3, 0, 3); + b.post(b.call("scalar", b.refs(IntVar.class, x), coeffs, "=", 6)); + ModelSpec spec = b.build(); + coeffs[0] = 100; + Model m = new Model(); + IntVar[] y = m.intVarArray("x", 3, 0, 3); + m.scalar(y, new int[]{1, 2, 3}, "=", 6).post(); + Resolver inst = spec.instantiate(); + Assert.assertEquals(solveAll(inst.model(), inst.intVars(x)), solveAll(m, y)); + } + + @Test(groups = "1s", timeOut = 60000) + public void testAutomatonIsCopied() { + FiniteAutomaton fa = new FiniteAutomaton("(1|2)3*1"); + SpecBuilder b = new SpecBuilder(Header.of("regular")); + int[] x = b.callArray(4, "intVarArray", "x", 4, 0, 3); + b.post(b.call("regular", b.refs(IntVar.class, x), fa)); + ModelSpec spec = b.build(); + Step.Call call = (Step.Call) spec.steps().get(1); + Object captured = ((Arg.Const) call.args()[1]).value(); + Assert.assertNotSame(captured, fa); + Assert.assertNotSame(((Arg.Const) call.args()[1]).value(), captured); + Model m = new Model(); + IntVar[] y = m.intVarArray("x", 4, 0, 3); + m.regular(y, fa).post(); + String direct = solveAll(m, y); + Assert.assertTrue(direct.startsWith("2 sols")); + Resolver inst = spec.instantiate(); + Assert.assertEquals(solveAll(inst.model(), inst.intVars(x)), direct); + } + + @Test(groups = "1s", timeOut = 60000) + public void testAutomatonCloneDoesNotShareBuffer() throws Exception { + FiniteAutomaton fa = new FiniteAutomaton("(1|2)3*1"); + FiniteAutomaton copy = fa.clone(); + Field nexts = FiniteAutomaton.class.getDeclaredField("nexts"); + nexts.setAccessible(true); + Assert.assertNotSame(nexts.get(copy), nexts.get(fa)); + Assert.assertEquals(copy.isDeterministic(), fa.isDeterministic()); + Assert.assertEquals(copy.getTransitions().size(), fa.getTransitions().size()); + } + + @Test(groups = "1s", timeOut = 60000) + public void testMatrixArgumentAndOutput() { + Model m = new Model(); + IntVar[][] y = m.intVarMatrix("m", 2, 3, 0, 2); + m.lexChainLess(y).post(); + String direct = solveAll(m, Arrays.stream(y).flatMap(Arrays::stream).toArray(IntVar[]::new)); + SpecBuilder b = new SpecBuilder(Header.of("lex")); + int[] ids = b.callArray(6, "intVarMatrix", "m", 2, 3, 0, 2); + int[][] rows = {Arrays.copyOfRange(ids, 0, 3), Arrays.copyOfRange(ids, 3, 6)}; + b.post(b.call("lexChainLess", b.refs(IntVar.class, rows))); + Resolver inst = b.build().instantiate(); + Assert.assertEquals(solveAll(inst.model(), inst.intVars(ids)), direct); + } + + @Test(groups = "1s", timeOut = 60000) + public void testTasksAndCumulative() { + int[] durations = {2, 3, 1}; + int[] heights = {1, 2, 2}; + Model m = new Model(); + IntVar[] s = new IntVar[3]; + Task[] tasks = new Task[3]; + IntVar[] h = new IntVar[3]; + for (int i = 0; i < 3; i++) { + s[i] = m.intVar("s" + i, 0, 4); + IntVar e = m.intVar("e" + i, 0, 6); + tasks[i] = new Task(s[i], m.intVar(durations[i]), e); + h[i] = m.intVar(heights[i]); + } + m.cumulative(tasks, h, m.intVar(3)).post(); + String direct = solveAll(m, s); + + SpecBuilder b = new SpecBuilder(Header.of("cumulative")); + int[] is = new int[3]; + int[] it = new int[3]; + int[] ih = new int[3]; + for (int i = 0; i < 3; i++) { + is[i] = b.call("intVar", "s" + i, 0, 4); + int ie = b.call("intVar", "e" + i, 0, 6); + it[i] = b.newTask(is[i], b.call("intVar", durations[i]), ie); + ih[i] = b.call("intVar", heights[i]); + } + b.post(b.call("cumulative", b.refs(Task.class, it), b.refs(IntVar.class, ih), b.ref(b.call("intVar", 3)))); + Resolver inst = b.build().instantiate(); + Assert.assertEquals(inst.model().getNbVars(), m.getNbVars()); + Assert.assertEquals(solveAll(inst.model(), inst.intVars(is)), direct); + } + + @Test(groups = "1s", timeOut = 60000) + public void testClauses() { + Model m = new Model(); + BoolVar[] x = m.boolVarArray("b", 3); + m.addClauses(LogOp.or(LogOp.and(x[0], x[1]), LogOp.nor(x[2]))); + String direct = solveAll(m, x); + SpecBuilder b = new SpecBuilder(Header.of("clauses")); + int[] ix = b.callArray(3, "boolVarArray", "b", 3); + b.effect("addClauses", Arg.or(Arg.and(b.ref(ix[0]), b.ref(ix[1])), new Arg.Logic(false, true, new Arg[]{b.ref(ix[2])}))); + Resolver inst = b.build().instantiate(); + Assert.assertEquals(solveAll(inst.model(), inst.intVars(ix)), direct); + } + + @Test(groups = "1s", timeOut = 60000) + public void testCustom() { + SpecBuilder b = new SpecBuilder(Header.of("custom")); + int x = b.call("intVar", "x", 0, 3); + b.post(b.custom("gt1", r -> r.model().arithm(r.intVar(x), ">", 1))); + Resolver inst = b.build().instantiate(); + Assert.assertEquals(solveAll(inst.model(), inst.intVar(x)).split(",")[0], "2 sols"); + } + + @Test(groups = "1s", timeOut = 60000) + public void testMostSpecificOverload() { + SpecBuilder b = new SpecBuilder(Header.of("overload")); + int[] x = b.callArray(3, "boolVarArray", "b", 3); + // sum(IntVar[], ...) and sum(BoolVar[], ...) both apply: the latter is chosen + b.post(b.call("sum", b.refs(BoolVar.class, x), "=", 2)); + ModelSpec spec = b.build(); + Step.Call call = (Step.Call) spec.steps().get(1); + Assert.assertEquals(call.method().parameterTypes()[0], BoolVar[].class); + Resolver inst = spec.instantiate(); + Assert.assertEquals(solveAll(inst.model(), inst.intVars(x)).split(",")[0], "3 sols"); + } + + @Test(groups = "1s", timeOut = 60000) + public void testCachedConstantsAreAliased() { + SpecBuilder b = new SpecBuilder(Header.of("alias")); + int c1 = b.call("intVar", 5); + int c2 = b.call("intVar", 5); + Resolver inst = b.build().instantiate(); + Assert.assertSame(inst.get(c1), inst.get(c2)); + Assert.assertEquals(inst.idOf(inst.get(c2)), c1); + } + + @Test(groups = "1s", timeOut = 60000) + public void testDivergenceIsDetected() { + FactoryMethod intVar = FactoryMethod.of("intVar", String.class, int.class, int.class); + Arg[] args = {new Arg.Const("x"), new Arg.Const(0), new Arg.Const(1)}; + ModelSpec spec = new ModelSpec(Header.of("div"), List.of( + new Step.Call(new int[]{0}, intVar, args), + new Step.Call(new int[]{0}, intVar, args))); + SpecException e = Assert.expectThrows(SpecException.class, spec::instantiate); + Assert.assertTrue(e.getMessage().contains("Divergence"), e.getMessage()); + Assert.assertTrue(e.getMessage().startsWith("Step #1"), e.getMessage()); + } + + @Test(groups = "1s", timeOut = 60000) + public void testIllFormedSpecs() { + // undefined identifier + Assert.assertThrows(SpecException.class, () -> new ModelSpec(Header.of("e"), List.of(new Step.Post(new int[]{3})))); + // wrong number of arguments + FactoryMethod intVar = FactoryMethod.of("intVar", String.class, int.class, int.class); + Assert.assertThrows(SpecException.class, () -> new Step.Call(new int[]{0}, intVar, new Arg[]{new Arg.Const("x")})); + // not a factory method + Assert.assertThrows(SpecException.class, () -> FactoryMethod.of("getSolver")); + // unsupported (mutable) constant; an object without field is immutable, hence supported + Assert.assertThrows(SpecException.class, () -> new Arg.Const(new StringBuilder())); + new Arg.Const(new Object()); + // a variable where a constraint is expected + SpecBuilder b = new SpecBuilder(Header.of("e")); + int x = b.call("intVar", "x", 0, 1); + ModelSpec spec = b.post(x).build(); + Assert.assertThrows(SpecException.class, spec::instantiate); + // a constraint where a variable is expected + FactoryMethod arithm = FactoryMethod.of("arithm", org.chocosolver.solver.variables.IntVar.class, String.class, int.class); + SpecBuilder b2 = new SpecBuilder(Header.of("e")); + int y = b2.call("intVar", "y", 0, 1); + int c = b2.call("arithm", b2.ref(y), ">", 0); + b2.call(arithm, b2.ref(c), ">", 0); + Assert.assertThrows(SpecException.class, () -> b2.build().instantiate()); + } + + @Test(groups = "1s", timeOut = 60000) + public void testNoSharedMutableObject() { + Queens qs = queensSpec(5); + Resolver i1 = qs.spec().instantiate(); + Resolver i2 = qs.spec().instantiate(); + Set seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + seen.addAll(Arrays.asList(i1.model().getVars())); + seen.addAll(Arrays.asList(i1.model().getCstrs())); + for (Object o : i2.model().getVars()) Assert.assertFalse(seen.contains(o)); + for (Object o : i2.model().getCstrs()) Assert.assertFalse(seen.contains(o)); + Assert.assertEquals(new HashSet<>(Arrays.asList(i1.model().getVars())).size(), 5); + } +} diff --git a/solver/src/test/java/org/chocosolver/solver/spec/RecorderTest.java b/solver/src/test/java/org/chocosolver/solver/spec/RecorderTest.java new file mode 100644 index 0000000000..385835d0e0 --- /dev/null +++ b/solver/src/test/java/org/chocosolver/solver/spec/RecorderTest.java @@ -0,0 +1,358 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.constraints.binary.PropGreaterOrEqualX_Y; +import org.chocosolver.solver.constraints.extension.Tuples; +import org.chocosolver.solver.constraints.nary.automata.FA.CostAutomaton; +import org.chocosolver.solver.constraints.nary.automata.FA.FiniteAutomaton; +import org.chocosolver.solver.constraints.nary.cnf.LogOp; +import org.chocosolver.solver.exception.SolverException; +import org.chocosolver.solver.variables.BoolVar; +import org.chocosolver.solver.variables.IntVar; +import org.chocosolver.solver.variables.OptionalTask; +import org.chocosolver.solver.variables.Task; +import org.testng.Assert; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Consumer; + +/** + * Tests of {@link RecordingModel} and {@link Recorder}: a model built directly and the same model recorded, + * snapshot and instantiated must be identical. + * + * @author Charles Prud'homme + */ +public class RecorderTest { + + /** + * Build the model directly and through a snapshot, check they are identical and return the spec. + */ + private static ModelSpec roundTrip(Consumer builder) { + Model direct = new Model("m"); + builder.accept(direct); + RecordingModel recorded = new RecordingModel("m"); + builder.accept(recorded); + ModelSpec spec = recorded.snapshot(); + Model replayed = spec.instantiate().model(); + String expected = SpecTestUtils.structure(direct); + Assert.assertEquals(SpecTestUtils.structure(recorded), expected, "recording must not change the model"); + Assert.assertEquals(SpecTestUtils.structure(replayed), expected, "replay differs"); + Assert.assertEquals(replayed.getSeed(), direct.getSeed()); + String stats = SpecTestUtils.solve(direct, 100_000); + Assert.assertEquals(SpecTestUtils.solve(recorded, 100_000), stats); + Assert.assertEquals(SpecTestUtils.solve(replayed, 100_000), stats); + return spec; + } + + //*********************************************************************************** + // SCENARIOS + //*********************************************************************************** + + private static void queens(Model m) { + int n = 6; + IntVar[] q = m.intVarArray("q", n, 1, n); + IntVar[] d1 = new IntVar[n]; + IntVar[] d2 = new IntVar[n]; + for (int i = 0; i < n; i++) { + d1[i] = m.offset(q[i], i); + d2[i] = m.offset(q[i], -i); + } + m.allDifferent(q, "AC").post(); + m.allDifferent(d1, "BC").post(); + m.allDifferent(d2).post(); + } + + private static void sideEffects(Model m) { + IntVar[] x = m.intVarArray("x", 5, 0, 4); + IntVar v = m.intVar("v", 0, 10); + IntVar i = m.intVar("i", 0, 6); + m.element(v, new int[]{3, 1, 4, 1, 5}, i, 2).post(); + m.among(m.intVar("n", 0, 5), x, new int[]{1, 3}).post(); + m.globalCardinality(x, new int[]{0, 1}, m.intVarArray("o", 2, 0, 2), false).post(); + m.nValues(x, m.intVar("nv", 2, 3)).post(); + m.sum(x, "<=", m.intVar("s", 0, 12)).post(); + m.increasing(Arrays.copyOf(x, 3), 0).post(); + m.count(2, x, m.intVar("c", 1, 2)).post(); + m.mod(x[3], 3, x[4]).post(); + m.scalar(x, new int[]{1, 2, 3, 4, 5}, ">=", 10).post(); + } + + private static void reification(Model m) { + IntVar x = m.intVar("x", 0, 5); + IntVar y = m.intVar("y", 0, 5); + BoolVar b1 = m.arithm(x, ">", y).reify(); + BoolVar b2 = m.boolVar("b2"); + m.arithm(x, "=", 2).reifyWith(b2); + BoolVar b3 = m.boolVar("b3"); + m.arithm(y, "=", 0).implies(b3); + BoolVar b4 = m.boolVar("b4"); + m.arithm(y, "=", 5).impliedBy(b4); + m.or(m.arithm(x, "=", y), m.arithm(x, "=", 0)).post(); + m.and(b1.not(), b3).post(); + m.ifThen(m.arithm(x, "=", 1), m.arithm(y, "=", 1)); + m.not(m.arithm(x, "=", 4)).post(); + Constraint c = m.arithm(x, "<", 5); + BoolVar again1 = c.reify(); + BoolVar again2 = c.reify(); + Assert.assertSame(again1, again2); + m.sum(new BoolVar[]{b1, b2, b4, again1}, ">=", 1).post(); + } + + private static void expressions(Model m) { + IntVar x = m.intVar("x", 0, 5); + IntVar y = m.intVar("y", 0, 5); + IntVar z = m.intVar("z", 0, 12); + x.add(y).mul(2).eq(z).post(); + x.gt(y).or(z.lt(3)).post(); + x.sub(y).abs().ge(2).extension().post(); + IntVar xy = x.add(y).intVar(); + m.arithm(xy, "<=", 8).post(); + x.mul(y).ne(4).decompose().post(); + } + + private static void views(Model m) { + IntVar x = m.intVar("x", -3, 3); + BoolVar b = m.boolVar("b"); + m.arithm(m.intMinusView(x), "<=", 2).post(); + m.arithm(m.intScaleView(x, 2), "!=", 2).post(); + m.arithm(m.intAbsView(x), ">", 0).post(); + BoolVar eq = m.isEq(x, 1); + m.arithm(eq, "!=", b).post(); + m.arithm(b.not(), "=", m.isLeq(x, 0)).post(); + m.arithm(m.intVar(5), ">", x).post(); + m.arithm(m.intVar(5), "!=", m.intVar("w", 4, 6)).post(); + } + + private static void scheduling(Model m) { + IntVar[] s = m.intVarArray("s", 3, 0, 5); + Task t0 = new Task(s[0], 2); + Task t1 = m.taskVar(s[1], 3); + Task t2 = new Task(s[2], m.intVar("d2", 1, 2), m.intVar("e2", 0, 8)); + OptionalTask t3 = new OptionalTask(m.intVar("s3", 0, 5), 2); + m.cumulative(new Task[]{t0, t1, t2, t3}, m.intVarArray("h", 4, 1, 1), m.intVar(2)).post(); + m.cumulative(s, new int[]{1, 1, 2}, new int[]{1, 1, 1}, 2).post(); + } + + private static void sat(Model m) { + BoolVar[] b = m.boolVarArray("b", 4); + m.addClauses(LogOp.or(LogOp.and(b[0], b[1]), LogOp.nor(b[2]))); + m.addClauseTrue(b[3]); + m.addClausesBoolOrArrayEqVar(new BoolVar[]{b[0], b[2]}, b[1]); + m.addClauses(new BoolVar[]{b[0]}, new BoolVar[]{b[2]}); + } + + private static void data(Model m) { + IntVar[] x = m.intVarArray("x", 3, 0, 3); + Tuples t = new Tuples(true); + t.add(0, 1, 2); + t.add(1, 2, 3); + t.add(3, 3, 3); + m.table(x, t, "CT+").post(); + m.table(new IntVar[]{x[0], x[1]}, new Tuples(new int[][]{{0, 1}, {1, 2}, {3, 3}}, false, java.util.OptionalInt.empty())).post(); + IntVar[] y = m.intVarArray("y", 4, 0, 3); + m.regular(y, new FiniteAutomaton("(1|2)3*1")).post(); + FiniteAutomaton fa = new FiniteAutomaton("(0|1)*"); + int[][] costs = new int[4][4]; + for (int[] c : costs) Arrays.fill(c, 1); + costs[1][1] = 0; + m.costRegular(m.intVarArray("z", 4, 0, 1), m.intVar("cost", 0, 3), + CostAutomaton.makeSingleResource(fa, costs, 0, 3)).post(); + m.lexChainLess(m.intVarMatrix("mat", 2, 2, 0, 1)).post(); + } + + private static void objectiveHooksSeed(Model m) { + m.addHook("CUMULATIVE", "GLB"); + IntVar[] x = m.intVarArray("x", 4, 0, 5); + m.setSeed(42); + m.allDifferent(x).post(); + IntVar obj = m.intVar("obj", 0, 20); + m.sum(x, "=", obj).post(); + m.setObjective(Model.MINIMIZE, obj); + Constraint tmp = m.arithm(x[0], ">", 3); + tmp.post(); + m.unpost(tmp); + } + + private static void custom(Model m) { + IntVar[] x = m.intVarArray("x", 2, 0, 3); + BoolVar[] b = m.boolVarArray("b", 2); + m.custom("geq", x, PropGreaterOrEqualX_Y::new).post(); + // the propagator is built from an array of the recorded type + m.custom("geqBool", b, PropGreaterOrEqualX_Y::new).post(); + m.custom("geqLambda", new IntVar[]{x[1], b[0]}, vs -> new PropGreaterOrEqualX_Y(vs)).post(); + } + + private static void nameAndHooks(Model m) { + m.setName("renamed"); + m.addHook("CUMULATIVE", "GLB"); + m.removeAllHooks(); + m.addHook("other", 1); + m.allDifferent(m.intVarArray("x", 3, 0, 2)).post(); + } + + private static void groups(Model m) { + IntVar[] x = m.intVarArray("x", 4, 0, 3); + IntVar[] y = m.intVarArray("y", 3, 0, 3); + m.addAsGroup("x", x); + m.addAsGroup("y", y); + m.allDifferent(x).post(); + m.sum(y, "=", x[0]).post(); + } + + @DataProvider + public Object[][] scenarios() { + return new Object[][]{ + {"queens", (Consumer) RecorderTest::queens}, + {"sideEffects", (Consumer) RecorderTest::sideEffects}, + {"reification", (Consumer) RecorderTest::reification}, + {"expressions", (Consumer) RecorderTest::expressions}, + {"views", (Consumer) RecorderTest::views}, + {"scheduling", (Consumer) RecorderTest::scheduling}, + {"sat", (Consumer) RecorderTest::sat}, + {"data", (Consumer) RecorderTest::data}, + {"objectiveHooksSeed", (Consumer) RecorderTest::objectiveHooksSeed}, + {"groups", (Consumer) RecorderTest::groups}, + {"custom", (Consumer) RecorderTest::custom}, + {"nameAndHooks", (Consumer) RecorderTest::nameAndHooks}, + }; + } + + @Test(groups = "1s", timeOut = 60000, dataProvider = "scenarios") + public void testRoundTrip(String name, Consumer builder) { + roundTrip(builder); + } + + @Test(groups = "1s", timeOut = 60000) + public void testHooksSeedAndObjectiveAreReplayed() { + ModelSpec spec = roundTrip(RecorderTest::objectiveHooksSeed); + Model m = spec.instantiate().model(); + Assert.assertEquals(m.getHook("CUMULATIVE"), "GLB"); + Assert.assertEquals(m.getSeed(), 42L); + Assert.assertNotNull(m.getObjective()); + Assert.assertEquals(m.getObjective().getName(), "obj"); + } + + @Test(groups = "1s", timeOut = 60000) + public void testNameAndHooksAreReplayed() { + ModelSpec spec = roundTrip(RecorderTest::nameAndHooks); + Assert.assertEquals(spec.header().name(), "renamed"); + Model m = spec.instantiate().model(); + Assert.assertEquals(m.getName(), "renamed"); + Assert.assertNull(m.getHook("CUMULATIVE")); + Assert.assertEquals(m.getHook("other"), 1); + } + + @Test(groups = "1s", timeOut = 60000) + public void testCapturingCustomConstraintIsReported() { + RecordingModel m = new RecordingModel("m"); + IntVar[] x = m.intVarArray("x", 2, 0, 3); + m.custom("captures", x, vs -> new PropGreaterOrEqualX_Y(x)).post(); + SpecException e = Assert.expectThrows(SpecException.class, m::snapshot); + Assert.assertTrue(e.getMessage().contains("capturing lambda"), e.getMessage()); + } + + @Test(groups = "1s", timeOut = 60000) + public void testOnlyTopLevelCallsAreRecorded() { + RecordingModel m = new RecordingModel("m"); + IntVar[] x = m.intVarArray("x", 50, 0, 5); // 1 call + m.sum(x, "=", 30, 5).post(); // 1 call + 1 post, despite the decomposition + m.globalCardinality(x, new int[]{0, 1}, m.intVarArray("o", 2, 0, 50), true).post(); // 2 calls + 1 post + List steps = m.snapshot().steps(); + Assert.assertEquals(steps.size(), 6, steps.toString()); + Assert.assertTrue(m.getNbVars() > 52, "decomposition should create intermediate variables"); + Assert.assertTrue(steps.get(0) instanceof Step.Call); + Assert.assertEquals(((Step.Call) steps.get(0)).out().length, 50); + Assert.assertTrue(steps.get(2) instanceof Step.Post); + } + + @Test(groups = "1s", timeOut = 60000) + public void testCustomConstraintIsReported() { + RecordingModel m = new RecordingModel("m"); + IntVar[] x = m.intVarArray("x", 2, 0, 3); + new Constraint("MyCustom", new PropGreaterOrEqualX_Y(x)).post(); + Assert.assertEquals(m.getRecorder().unsupported().size(), 1); + SpecException e = Assert.expectThrows(SpecException.class, m::snapshot); + Assert.assertTrue(e.getMessage().contains("MyCustom"), e.getMessage()); + Assert.assertTrue(e.getMessage().contains("Model.custom"), e.getMessage()); + } + + @Test(groups = "1s", timeOut = 60000) + public void testUnsupportedArgumentsAreReported() { + RecordingModel m = new RecordingModel("m"); + IntVar[] x = m.intVarArray("x", 3, 0, 3); + // a capturing lambda cannot be journalized (it may capture objects of the recorded model) + int threshold = x.length - 2; + m.allDifferentUnderCondition(x, v -> v.getUB() > threshold, true).post(); + // a variable of another model + Model other = new Model(); + try { + m.arithm(x[0], "=", other.intVar("o", 0, 1)).post(); + } catch (SolverException | AssertionError ignored) { + // the model itself rejects it (assertion of Model.post) + } + List u = m.getRecorder().unsupported(); + Assert.assertTrue(u.size() >= 2, u.toString()); + Assert.assertTrue(u.get(0).operation().startsWith("allDifferentUnderCondition"), u.toString()); + Assert.assertThrows(SpecException.class, m::snapshot); + } + + @Test(groups = "1s", timeOut = 60000) + public void testSnapshotAfterSolveFails() { + RecordingModel m = new RecordingModel("m"); + m.arithm(m.intVar("x", 0, 3), ">", 1).post(); + m.getSolver().solve(); + Assert.assertThrows(SpecException.class, m::snapshot); + } + + @Test(groups = "1s", timeOut = 60000) + public void testTuplesAreFrozenByRecording() { + RecordingModel m = new RecordingModel("m"); + Tuples t = new Tuples(true); + t.add(0, 1); + m.table(m.intVarArray("x", 2, 0, 1), t).post(); + Assert.assertTrue(t.isFrozen()); + } + + @Test(groups = "1s", timeOut = 60000) + public void testSnapshotIsReusableConcurrently() throws Exception { + RecordingModel master = new RecordingModel("m"); + sideEffects(master); + reification(master); + ModelSpec spec = master.snapshot(); + String expected = SpecTestUtils.solve(spec.instantiate().model(), 100_000); + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + List> futures = new ArrayList<>(); + for (int k = 0; k < 8; k++) { + futures.add(pool.submit(() -> SpecTestUtils.solve(spec.instantiate().model(), 100_000))); + } + for (Future f : futures) { + Assert.assertEquals(f.get(), expected); + } + } finally { + pool.shutdownNow(); + } + } + + @Test(groups = "1s", timeOut = 60000) + public void testPlainModelHasNoRecorder() { + Model m = new Model(); + Assert.assertNull(m.getRecorder()); + BoolVar b = m.arithm(m.intVar("x", 0, 3), ">", 1).reify(); + Assert.assertNotNull(b); + } +} diff --git a/solver/src/test/java/org/chocosolver/solver/spec/SearchDeclTest.java b/solver/src/test/java/org/chocosolver/solver/spec/SearchDeclTest.java new file mode 100644 index 0000000000..fcfacd5b1c --- /dev/null +++ b/solver/src/test/java/org/chocosolver/solver/spec/SearchDeclTest.java @@ -0,0 +1,188 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.Solution; +import org.chocosolver.solver.Solver; +import org.chocosolver.solver.search.strategy.BlackBoxConfigurator; +import org.chocosolver.solver.search.strategy.Search; +import org.chocosolver.solver.search.strategy.SearchParams; +import org.chocosolver.solver.variables.IntVar; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Consumer; + +/** + * Tests of the search, objective and solutions of a {@link ModelSpec}. + * + * @author Charles Prud'homme + */ +public class SearchDeclTest { + + private static final SearchParams.VarSelConf DOMWDEG = + new SearchParams.VarSelConf(SearchParams.VariableSelection.DOMWDEG, Integer.MAX_VALUE); + private static final SearchParams.ValSelConf MIN = + new SearchParams.ValSelConf(SearchParams.ValueSelection.MIN, false, 1, false); + private static final SearchParams.ResConf LUBY = + new SearchParams.ResConf(SearchParams.Restart.LUBY, 50, 0, true); + + /** + * A small optimization problem: returns its decision variables. + */ + private static IntVar[] build(Model m) { + int n = 7; + IntVar[] x = m.intVarArray("x", n, 0, n - 1); + m.allDifferent(x).post(); + for (int i = 0; i < n - 1; i++) { + m.arithm(x[i], "!=", x[i + 1], "+", 1).post(); + } + IntVar obj = m.intVar("obj", 0, 100); + m.scalar(x, new int[]{1, 2, 3, 4, 5, 6, 7}, "=", obj).post(); + m.setObjective(Model.MINIMIZE, obj); + return x; + } + + /** + * @return solutions, nodes, fails and best objective + */ + private static String solve(Model m) { + Solver s = m.getSolver(); + s.limitNode(20_000); + while (s.solve()) ; + return s.getSolutionCount() + " sols, " + s.getNodeCount() + " nodes, " + s.getFailCount() + " fails, best=" + + s.getBestSolutionValue(); + } + + /** + * Configure the direct model with direct, and the instances with decl: they must search alike. + */ + private static void assertSameSearch(Consumer direct, SearchDecl decl) throws Exception { + Model d = new Model("m"); + IntVar[] x = build(d); + direct.accept(x); + String expected = solve(d); + + RecordingModel r = new RecordingModel("m"); + build(r); + ModelSpec spec = r.snapshot().withSearch(decl); + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + List> futures = new ArrayList<>(); + for (int k = 0; k < 4; k++) { + futures.add(pool.submit(() -> solve(spec.instantiate().model()))); + } + for (Future f : futures) { + Assert.assertEquals(f.get(), expected); + } + } finally { + pool.shutdownNow(); + } + } + + @Test(groups = "1s", timeOut = 60000) + public void testModelBasedSearch() throws Exception { + assertSameSearch(x -> Search.defaultSearch(x[0].getModel()), SearchDecl.of(Search::defaultSearch)); + } + + @Test(groups = "1s", timeOut = 60000) + public void testBlackBoxWithNogoods() throws Exception { + // nogoods on restarts create the SAT constraint on each instance + assertSameSearch(x -> BlackBoxConfigurator.forCOP().make(x[0].getModel()), + SearchDecl.of(m -> BlackBoxConfigurator.forCOP().make(m))); + } + + @Test(groups = "1s", timeOut = 60000) + public void testCatalogSearch() throws Exception { + RecordingModel r = new RecordingModel("m"); + IntVar[] x = build(r); + int[] ids = r.getRecorder().idsOf(x); + assertSameSearch(v -> { + Solver s = v[0].getModel().getSolver(); + s.setSearch(DOMWDEG.make().apply(v, MIN.make().apply(v[0].getModel()))); + s.addRestarter(LUBY.make().apply(s)); + s.setSearch(Search.lastConflict(s.getSearch(), 2)); + }, + SearchDecl.intVarSearch(DOMWDEG, MIN, ids) + .andThen(SearchDecl.restarts(LUBY)) + .andThen(SearchDecl.lastConflict(2))); + } + + @Test(groups = "1s", timeOut = 60000) + public void testSolutionsAreSharedByIdentifiers() throws Exception { + RecordingModel master = new RecordingModel("m"); + IntVar[] mx = build(master); + int[] ids = master.getRecorder().idsOf(mx); + ModelSpec spec = master.snapshot().withSearch(SearchDecl.intVarSearch(DOMWDEG, MIN, ids)); + + // a worker finds the optimum + Resolver worker = spec.instantiate(); + SpecSolution best = null; + while (worker.model().getSolver().solve()) { + best = SpecSolution.record(worker); + } + Assert.assertNotNull(best); + int objId = master.getRecorder().idOf(master.getObjective()); + int optimum = best.valueOf(objId); + Assert.assertEquals(optimum, worker.model().getSolver().getBestSolutionValue().intValue()); + + // read by the master + Solution inMaster = best.toSolution(master.getRecorder()); + for (int i = 0; i < mx.length; i++) { + Assert.assertEquals(inMaster.getIntVal(mx[i]), best.valueOf(ids[i])); + } + Assert.assertEquals(inMaster.getIntVal((IntVar) master.getObjective()), optimum); + + // and it is a solution of another instance + Resolver other = spec.instantiate(); + for (int id : ids) { + other.model().arithm(other.intVar(id), "=", best.valueOf(id)).post(); + } + Assert.assertTrue(other.model().getSolver().solve()); + Assert.assertEquals(SpecSolution.record(other).valueOf(objId), optimum); + } + + @Test(groups = "1s", timeOut = 60000) + public void testCustomConstraintCapturingAnotherModelIsDetected() { + Model master = new Model(); + IntVar foreign = master.intVar("x", 0, 3); + SpecBuilder b = new SpecBuilder(Header.of("guard")); + b.call("intVar", "y", 0, 3); + b.post(b.custom("bad", r -> r.model().arithm(foreign, ">", 1))); + ModelSpec spec = b.build(); + SpecException e = Assert.expectThrows(SpecException.class, spec::instantiate); + Assert.assertTrue(e.getCause() instanceof AssertionError, String.valueOf(e.getCause())); + } + + @Test(groups = "1s", timeOut = 60000) + public void testSearchCapturingAnotherModelIsDetected() { + Model master = new Model(); + IntVar foreign = master.intVar("x", 0, 3); + SpecBuilder b = new SpecBuilder(Header.of("guard")); + b.call("intVar", "y", 0, 3); + ModelSpec spec = b.build().withSearch(r -> r.model().getSolver().setSearch(Search.inputOrderLBSearch(foreign))); + Assert.expectThrows(AssertionError.class, spec::instantiate); + } + + @Test(groups = "1s", timeOut = 60000) + public void testMasterIsAResolver() { + RecordingModel master = new RecordingModel("m"); + IntVar[] x = build(master); + Recorder r = master.getRecorder(); + Assert.assertSame(r.model(), master); + for (IntVar v : x) { + Assert.assertSame(r.get(r.idOf(v)), v); + } + Assert.assertThrows(SpecException.class, () -> r.idsOf(new Model().intVar("z", 0, 1))); + } +} diff --git a/solver/src/test/java/org/chocosolver/solver/spec/SpecTestUtils.java b/solver/src/test/java/org/chocosolver/solver/spec/SpecTestUtils.java new file mode 100644 index 0000000000..9bba375a23 --- /dev/null +++ b/solver/src/test/java/org/chocosolver/solver/spec/SpecTestUtils.java @@ -0,0 +1,73 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.Solver; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.search.strategy.Search; +import org.chocosolver.solver.variables.IntVar; +import org.chocosolver.solver.variables.Variable; + +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * What the tests of this package compare between models built directly, recorded and replayed. + * + * @author Charles Prud'homme + */ +final class SpecTestUtils { + + private SpecTestUtils() { + } + + /** + * @return a description of the structure of the model: variables (with their domains), groups and constraints + * (with their propagators), in order + */ + static String structure(Model m) { + StringBuilder sb = new StringBuilder(); + for (Variable v : m.getVars()) { + sb.append(v.getClass().getSimpleName()).append(' ').append(v.getName()); + if (v instanceof IntVar iv) { + sb.append(' ').append(iv.getLB()).append("..").append(iv.getUB()).append(" #").append(iv.getDomainSize()); + } + sb.append('\n'); + } + m.getGroups().forEach(g -> sb.append("group ").append(g.getName()).append(Arrays.stream(g.getVariables()) + .map(Variable::getName).collect(Collectors.joining(",", "[", "]"))).append('\n')); + for (Constraint c : m.getCstrs()) { + sb.append(c.getName()).append(Arrays.stream(c.getPropagators()) + .map(p -> p.getClass().getSimpleName() + "/" + p.getNbVars()) + .collect(Collectors.joining(",", "[", "]"))).append('\n'); + } + return sb.toString(); + } + + /** + * Solve with a deterministic search over all integer variables, limited in nodes. + * + * @return solutions, nodes, fails and best objective + */ + static String solve(Model m, long nodes) { + Solver s = m.getSolver(); + IntVar[] vars = m.retrieveIntVars(true); + if (vars.length > 0) { + s.setSearch(Search.inputOrderLBSearch(vars)); + } + s.limitNode(nodes); + Number best = null; + while (s.solve()) { + if (m.getObjective() != null) { + best = s.getBestSolutionValue(); + } + } + return s.getSolutionCount() + " sols, " + s.getNodeCount() + " nodes, " + s.getFailCount() + + " fails" + (best == null ? "" : ", best=" + best); + } +} diff --git a/solver/src/test/java/org/chocosolver/solver/spec/VariantTest.java b/solver/src/test/java/org/chocosolver/solver/spec/VariantTest.java new file mode 100644 index 0000000000..8405f497f0 --- /dev/null +++ b/solver/src/test/java/org/chocosolver/solver/spec/VariantTest.java @@ -0,0 +1,210 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.Solver; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.constraints.extension.Tuples; +import org.chocosolver.solver.search.strategy.Search; +import org.chocosolver.solver.search.strategy.SearchParams; +import org.chocosolver.solver.variables.IntVar; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.Collectors; + +/** + * Tests of {@link Variant}. + * + * @author Charles Prud'homme + */ +public class VariantTest { + + private static final int N = 7; + + /** + * Queens with three allDifferent, and a table on the first two queens. + */ + private static IntVar[] queens(Model m) { + IntVar[] q = m.intVarArray("q", N, 1, N); + IntVar[] d1 = new IntVar[N]; + IntVar[] d2 = new IntVar[N]; + for (int i = 0; i < N; i++) { + d1[i] = m.offset(q[i], i); + d2[i] = m.offset(q[i], -i); + } + m.allDifferent(q).post(); + m.allDifferent(d1, "AC").post(); + m.allDifferent(d2).post(); + Tuples t = new Tuples(false); + t.add(1, 3); + t.add(3, 1); + m.table(new IntVar[]{q[0], q[1], q[2]}, tuples3()).post(); + m.table(q[0], q[1], t).post(); + return q; + } + + private static Tuples tuples3() { + Tuples t = new Tuples(true); + for (int a = 1; a <= N; a++) { + for (int b = 1; b <= N; b++) { + for (int c = 1; c <= N; c++) { + if (a != 2 || b != 4) { + t.add(a, b, c); + } + } + } + } + return t; + } + + private static ModelSpec spec() { + return ModelSpec.record("queens", org.chocosolver.solver.SettingsBuilder.init().build(), VariantTest::queens); + } + + private static List propagators(Model m, String constraintName) { + return Arrays.stream(m.getCstrs()).filter(c -> c.getName().equals(constraintName)) + .flatMap(c -> Arrays.stream(c.getPropagators())).map(p -> p.getClass().getSimpleName()) + .collect(Collectors.toList()); + } + + private static long solutions(Model m) { + Solver s = m.getSolver(); + s.setSearch(Search.inputOrderLBSearch(m.retrieveIntVars(true))); + return s.findAllSolutions().size(); + } + + @Test(groups = "1s", timeOut = 60000) + public void testIdentity() { + ModelSpec spec = spec(); + Assert.assertSame(spec.with(Variant.IDENTITY), spec); + Assert.assertEquals(solutions(spec.instantiate(Variant.IDENTITY).model()), solutions(spec.instantiate().model())); + } + + @Test(groups = "1s", timeOut = 60000) + public void testAllDifferentConsistency() { + ModelSpec spec = spec(); + long expected = solutions(spec.instantiate().model()); + Model bc = spec.instantiate(Variant.allDifferent("BC")).model(); + List props = propagators(bc, "ALLDIFFERENT"); + Assert.assertEquals(props.stream().filter(p -> p.contains("BC")).count(), 3, props.toString()); + Assert.assertTrue(props.stream().noneMatch(p -> p.contains("AC")), props.toString()); + Assert.assertEquals(solutions(bc), expected); + Model neqs = spec.instantiate(Variant.allDifferent("NEQS")).model(); + Assert.assertTrue(propagators(neqs, "ALLDIFFERENT").isEmpty() || !propagators(neqs, "ALLDIFFERENT").contains("PropAllDiffBC")); + Assert.assertEquals(solutions(neqs), expected); + } + + @Test(groups = "1s", timeOut = 60000) + public void testTableAlgorithms() { + ModelSpec spec = spec(); + long expected = solutions(spec.instantiate().model()); + Model str2 = spec.instantiate(Variant.tableAlgorithm("STR2+")).model(); + Assert.assertTrue(propagators(str2, "TABLE").contains("PropTableStr2"), propagators(str2, "TABLE").toString()); + Assert.assertEquals(solutions(str2), expected); + Model ac3 = spec.instantiate(Variant.binaryTableAlgorithm("AC3rm")).model(); + Assert.assertTrue(propagators(ac3, "TABLE").stream().anyMatch(p -> p.contains("AC3rm")), propagators(ac3, "TABLE").toString()); + Assert.assertEquals(solutions(ac3), expected); + } + + @Test(groups = "1s", timeOut = 60000) + public void testSeedOverridesRecordedSeeds() { + Model recorded = Model.record("seeded"); + recorded.setSeed(42); + recorded.allDifferent(recorded.intVarArray("x", 3, 0, 2)).post(); + ModelSpec spec = recorded.getRecorder().snapshot(); + Assert.assertEquals(spec.instantiate().model().getSeed(), 42L); + Assert.assertEquals(spec.instantiate(Variant.seed(7)).model().getSeed(), 7L); + } + + @Test(groups = "1s", timeOut = 60000) + public void testSeedAndSearch() { + ModelSpec spec = spec(); + long expected = solutions(spec.instantiate().model()); + Model m = spec.instantiate(Variant.seed(123)).model(); + Assert.assertEquals(m.getSeed(), 123L); + Assert.assertEquals(solutions(m), expected); + SearchParams.VarSelConf dom = new SearchParams.VarSelConf(SearchParams.VariableSelection.DOM, Integer.MAX_VALUE); + SearchParams.ValSelConf max = new SearchParams.ValSelConf(SearchParams.ValueSelection.MAX, false, 1, false); + int[] q = new int[N]; + for (int i = 0; i < N; i++) { + q[i] = i; // intVarArray is the first call: its outputs are 0..N-1 + } + Model searched = spec.instantiate(Variant.search(SearchDecl.intVarSearch(dom, max, q))).model(); + Assert.assertTrue(searched.getSolver().solve()); + Assert.assertEquals(searched.getSolver().getSearch().getClass().getSimpleName(), "IntStrategy"); + } + + @Test(groups = "1s", timeOut = 60000) + public void testExtendAndUnpost() { + RecordingModel master = new RecordingModel("queens"); + IntVar[] q = queens(master); + int[] ids = master.getRecorder().idsOf(q); + ModelSpec spec = master.snapshot(); + long expected = solutions(spec.instantiate().model()); + int nbCstrs = spec.instantiate().model().getNbCstrs(); + + // redundant constraint: the sum of the queens + Variant redundant = Variant.extend(b -> b.post(b.call("sum", b.refs(IntVar.class, ids), "=", N * (N + 1) / 2))); + Model r = spec.instantiate(redundant).model(); + Assert.assertEquals(r.getNbCstrs(), nbCstrs + 1); + Assert.assertEquals(solutions(r), expected); + + // symmetry breaking: q[0] < q[N-1] + Variant symmetry = Variant.extend(b -> b.post(b.call("arithm", b.ref(ids[0]), "<", b.ref(ids[N - 1])))); + Assert.assertTrue(solutions(spec.instantiate(symmetry).model()) < expected); + + // removal of the tables + Model relaxed = spec.instantiate(Variant.unpostIf(c -> c.method().name().equals("table"))).model(); + Assert.assertEquals(relaxed.getNbCstrs(), nbCstrs - 2); + Assert.assertTrue(solutions(relaxed) > expected); + } + + @Test(groups = "1s", timeOut = 60000) + public void testComposedVariantsInParallel() throws Exception { + ModelSpec spec = spec(); + long expected = solutions(spec.instantiate().model()); + List variants = List.of( + Variant.IDENTITY, + Variant.allDifferent("BC"), + Variant.allDifferent("AC").andThen(Variant.tableAlgorithm("STR2+")).andThen(Variant.seed(7)), + Variant.binaryTableAlgorithm("FC").andThen(Variant.search(SearchDecl.of(Search::defaultSearch)))); + List specs = variants.stream().map(spec::with).collect(Collectors.toList()); + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + List> futures = new ArrayList<>(); + for (ModelSpec s : specs) { + futures.add(pool.submit(() -> solutions(s.instantiate().model()))); + } + for (Future f : futures) { + Assert.assertEquals(f.get().longValue(), expected); + } + } finally { + pool.shutdownNow(); + } + } + + @Test(groups = "1s", timeOut = 60000) + public void testVariantKeepsIdentifiers() { + ModelSpec spec = spec(); + ModelSpec v = spec.with(Variant.allDifferent("BC")); + Assert.assertEquals(v.steps().size(), spec.steps().size()); + Resolver i = v.instantiate(); + for (int id = 0; id < N; id++) { + Assert.assertEquals(i.intVar(id).getName(), "q[" + id + "]"); + } + Constraint[] cs = i.model().getCstrs(); + Assert.assertEquals(cs.length, spec.instantiate().model().getNbCstrs()); + } +} From 3f9c345a0813c7c6ac0c6a809e4deb9932317670 Mon Sep 17 00:00:00 2001 From: Charles Prud'homme Date: Thu, 1 Oct 2026 15:51:03 +0200 Subject: [PATCH 4/6] Build a ParallelPortfolio from a recorded model or a spec - ParallelPortfolio.of(model, n): the recorded model is the first worker, plus n-1 copies built concurrently; of(model, variants) adds one copy per variant. - ParallelPortfolio.of(spec, n) and of(spec, variants) build all the workers from a ModelSpec. - getBestSolution() expresses the solution with the variables of the recorded model; getBestSpecSolution() with the identifiers of the spec. Co-Authored-By: Claude Opus 5.5 --- .../chocosolver/solver/ParallelPortfolio.java | 186 ++++++++++++++++++ .../solver/spec/RecordedModelTest.java | 128 ++++++++++++ .../solver/spec/SpecPortfolioTest.java | 136 +++++++++++++ 3 files changed, 450 insertions(+) create mode 100644 solver/src/test/java/org/chocosolver/solver/spec/RecordedModelTest.java create mode 100644 solver/src/test/java/org/chocosolver/solver/spec/SpecPortfolioTest.java diff --git a/solver/src/main/java/org/chocosolver/solver/ParallelPortfolio.java b/solver/src/main/java/org/chocosolver/solver/ParallelPortfolio.java index 956fbd551a..3f07945ee5 100644 --- a/solver/src/main/java/org/chocosolver/solver/ParallelPortfolio.java +++ b/solver/src/main/java/org/chocosolver/solver/ParallelPortfolio.java @@ -18,21 +18,31 @@ import org.chocosolver.solver.search.strategy.SearchParams; import org.chocosolver.solver.search.strategy.selectors.values.IntValueSelector; import org.chocosolver.solver.search.strategy.strategy.AbstractStrategy; +import org.chocosolver.solver.spec.ModelSpec; +import org.chocosolver.solver.spec.Recorder; +import org.chocosolver.solver.spec.Resolver; +import org.chocosolver.solver.spec.SpecSolution; +import org.chocosolver.solver.spec.Variant; import org.chocosolver.solver.variables.IntVar; import org.chocosolver.solver.variables.Variable; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; import java.util.Spliterator; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.stream.StreamSupport; @@ -140,6 +150,17 @@ public class ParallelPortfolio { */ private Model finder; + /** + * For a portfolio built from a {@link ModelSpec}: the resolver of each model, which maps the identifiers of the + * spec to its objects. + */ + private final Map resolvers = new IdentityHashMap<>(); + + /** + * For a portfolio built from a recorded model: that model, in whose terms the solutions are expressed. + */ + private Model origin; + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////// CONSTRUCTOR ////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -169,10 +190,175 @@ public ParallelPortfolio() { this(true); } + /** + * Creates a portfolio of n workers: model itself (the first worker) and n-1 copies of it, + * built concurrently. The search heuristics of the copies are diversified, as well as the one of model + * unless a search strategy was declared on it. {@link #getBestSolution()} expresses the solutions found with the + * variables of model. + *

+ * Once its resolution has started, model cannot be duplicated anymore: duplicate it beforehand if needed. + * Note that a worker may not even start if another one completes the search first. + *

{@code
+     * Model model = Model.record("pb");
+     * // ... variables and constraints ...
+     * ParallelPortfolio portfolio = ParallelPortfolio.of(model, 4);
+     * if (portfolio.solve()) {
+     *     Solution s = portfolio.getBestSolution();
+     * }
+     * }
+ * + * @param model a model created with {@link Model#record(String)} + * @param n number of workers, model included + * @return a portfolio of n workers + * @throws SolverException if model cannot be duplicated + */ + public static ParallelPortfolio of(Model model, int n) { + return of(model, Collections.nCopies(Math.max(0, n - 1), Variant.IDENTITY)); + } + + /** + * Creates a portfolio whose workers are model itself (the first worker) and one copy of it per variant, + * built concurrently, see {@link #of(Model, int)} and {@link #of(ModelSpec, List)}. + * + * @param model a model created with {@link Model#record(String)} + * @param variants one variant per copy + * @return a portfolio of variants.size()+1 workers + * @throws SolverException if model cannot be duplicated + */ + public static ParallelPortfolio of(Model model, List variants) { + Recorder recorder = model.getRecorder(); + if (recorder == null) { + throw new SolverException(Model.NOT_RECORDED); + } + List specs = specs(recorder.snapshot(), variants); + ParallelPortfolio portfolio = new ParallelPortfolio(); + portfolio.origin = model; + portfolio.addModel(model, model.getSolver().getSearch() != null, true); + portfolio.resolvers.put(model, recorder); + portfolio.add(specs, instantiate(specs), 2); + return portfolio; + } + + /** + * Creates a portfolio of n identical models built from spec, whose search heuristics are then + * diversified by the portfolio (see {@link #addModel(Model)}). This is equivalent to adding n models built + * the same way, but the problem is described once and the models are built concurrently. + * + * @param spec the spec of the problem + * @param n number of models + * @return a portfolio of n models + */ + public static ParallelPortfolio of(ModelSpec spec, int n) { + return of(spec, Collections.nCopies(n, Variant.IDENTITY)); + } + + /** + * Creates a portfolio with one model per variant of spec, built concurrently. + * A model whose variant declares a search ({@link ModelSpec#search()}) keeps it; the search heuristics of the + * other models are diversified by the portfolio. + *

+ * The solutions found can be read with the identifiers of the spec: see {@link #getBestSpecSolution()} and + * {@link #resolverOf(Model)}. + * + * @param spec the spec of the problem + * @param variants one variant per model (see {@link Variant#IDENTITY}) + * @return a portfolio with one model per variant + */ + public static ParallelPortfolio of(ModelSpec spec, List variants) { + List specs = specs(spec, variants); + ParallelPortfolio portfolio = new ParallelPortfolio(); + portfolio.add(specs, instantiate(specs), 1); + return portfolio; + } + + private static List specs(ModelSpec spec, List variants) { + return variants.stream().map(spec::with).collect(Collectors.toList()); + } + + /** + * Add the instances of specs, numbered from rank. + */ + private void add(List specs, List instances, int rank) { + for (int i = 0; i < instances.size(); i++) { + Model m = instances.get(i).model(); + m.setName(m.getName() + "_" + (rank + i)); + addModel(m, specs.get(i).search() != null, true); + resolvers.put(m, instances.get(i)); + } + } + + /** + * @return the instances of specs, built concurrently + */ + private static List instantiate(List specs) { + if (specs.isEmpty()) { + return List.of(); + } + ExecutorService pool = Executors.newFixedThreadPool( + Math.min(specs.size(), Runtime.getRuntime().availableProcessors())); + try { + List> futures = new ArrayList<>(specs.size()); + for (ModelSpec s : specs) { + futures.add(pool.submit(() -> s.instantiate())); + } + List instances = new ArrayList<>(specs.size()); + for (Future f : futures) { + instances.add(f.get()); + } + return instances; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SolverException("Interrupted while building the models of the portfolio"); + } catch (ExecutionException e) { + throw e.getCause() instanceof RuntimeException re ? re : new SolverException(e.getCause().getMessage()); + } finally { + pool.shutdownNow(); + } + } + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////// API ////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + /** + * @param model a model of this portfolio + * @return the resolver of model if this portfolio was built from a {@link ModelSpec}, null otherwise + */ + public Resolver resolverOf(Model model) { + return resolvers.get(model); + } + + /** + * To be called once {@link #solve()} returned true. + * + * @return the (best) solution found, expressed with the variables of the recorded model for a portfolio built + * with {@link #of(Model, int)}, with the variables of the model which found it otherwise + * @throws SolverException if no solution was found + */ + public Solution getBestSolution() { + if (finder == null) { + throw new SolverException("No solution found"); + } + return origin == null || finder == origin + ? new Solution(finder).record() + : getBestSpecSolution().toSolution(origin.getRecorder()); + } + + /** + * To be called once {@link #solve()} returned true, on a portfolio built from a {@link ModelSpec} (or a + * recorded model). + * + * @return the (best) solution found, with the identifiers of the spec: it can be read from any model of the spec + * @throws SolverException if this portfolio was not built from a spec, or if no solution was found + */ + public SpecSolution getBestSpecSolution() { + Resolver r = finder == null ? null : resolvers.get(finder); + if (r == null) { + throw new SolverException(finder == null ? "No solution found" : "The portfolio was not built from a ModelSpec"); + } + return SpecSolution.record(r); + } + /** * Calling this method will ensure that workers equipped with a restart policy not only * record nogoods from themselves (based on {@link NogoodFromRestarts}) but also based on diff --git a/solver/src/test/java/org/chocosolver/solver/spec/RecordedModelTest.java b/solver/src/test/java/org/chocosolver/solver/spec/RecordedModelTest.java new file mode 100644 index 0000000000..3d46874c6c --- /dev/null +++ b/solver/src/test/java/org/chocosolver/solver/spec/RecordedModelTest.java @@ -0,0 +1,128 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.ParallelPortfolio; +import org.chocosolver.solver.Solution; +import org.chocosolver.solver.exception.SolverException; +import org.chocosolver.solver.search.strategy.Search; +import org.chocosolver.solver.search.strategy.strategy.AbstractStrategy; +import org.chocosolver.solver.variables.IntVar; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.List; + +/** + * The simple usage: {@link Model#record(String)}, {@link Model#duplicate()} and + * {@link ParallelPortfolio#of(Model, int)}, without knowing anything about specs. + * + * @author Charles Prud'homme + */ +public class RecordedModelTest { + + private static IntVar[] queens(Model model, int n) { + IntVar[] q = model.intVarArray("q", n, 1, n); + for (int i = 0; i < n - 1; i++) { + for (int j = i + 1; j < n; j++) { + model.arithm(q[i], "!=", q[j]).post(); + model.arithm(q[i], "!=", q[j], "+", j - i).post(); + model.arithm(q[i], "!=", q[j], "-", j - i).post(); + } + } + return q; + } + + private static void assertQueens(Solution s, IntVar[] q) { + for (int i = 0; i < q.length - 1; i++) { + for (int j = i + 1; j < q.length; j++) { + int a = s.getIntVal(q[i]); + int b = s.getIntVal(q[j]); + Assert.assertTrue(a != b && a != b + j - i && a != b - j + i); + } + } + } + + @Test(groups = "1s", timeOut = 60000) + public void testDuplicate() { + Model model = Model.record("queens"); + queens(model, 8); + + Model copy = model.duplicate(); + Assert.assertNotSame(copy, model); + Assert.assertEquals(copy.getNbVars(), model.getNbVars()); + Assert.assertEquals(copy.getNbCstrs(), model.getNbCstrs()); + Assert.assertEquals(copy.getSolver().findAllSolutions().size(), 92); + // the original is untouched, and can be duplicated again + Assert.assertEquals(model.duplicate().getSolver().findAllSolutions().size(), 92); + Assert.assertEquals(model.getSolver().findAllSolutions().size(), 92); + } + + @Test(groups = "1s", timeOut = 60000) + public void testPortfolio() { + Model model = Model.record("queens"); + IntVar[] q = queens(model, 10); + + ParallelPortfolio portfolio = ParallelPortfolio.of(model, 4); + Assert.assertEquals(portfolio.getModels().size(), 4); + Assert.assertSame(portfolio.getModels().get(0), model, "the original model is the first worker"); + Assert.assertTrue(portfolio.solve()); + // the solution is expressed with the variables of the original model + assertQueens(portfolio.getBestSolution(), q); + } + + @Test(groups = "1s", timeOut = 60000) + public void testOptimizationPortfolio() { + Model model = Model.record("knapsack"); + IntVar[] occ = model.intVarArray("o", 3, 0, 5); + IntVar value = model.intVar("v", 0, 100); + model.knapsack(occ, model.intVar("w", 0, 10), value, new int[]{2, 3, 4}, new int[]{3, 5, 7}).post(); + model.setObjective(Model.MAXIMIZE, value); + + ParallelPortfolio portfolio = ParallelPortfolio.of(model, List.of(Variant.IDENTITY, Variant.seed(3))); + Assert.assertEquals(portfolio.getModels().size(), 3, "the original model and one copy per variant"); + int best = -1; + while (portfolio.solve()) { + Solution s = portfolio.getBestSolution(); + best = s.getIntVal(value); + Assert.assertEquals(best, 3 * s.getIntVal(occ[0]) + 5 * s.getIntVal(occ[1]) + 7 * s.getIntVal(occ[2])); + } + Assert.assertEquals(best, 17); + } + + @Test(groups = "1s", timeOut = 60000) + public void testOriginalSearchIsKept() { + Model model = Model.record("queens"); + IntVar[] q = queens(model, 8); + AbstractStrategy search = Search.inputOrderUBSearch(q); + model.getSolver().setSearch(search); + ParallelPortfolio portfolio = ParallelPortfolio.of(model, 2); + Assert.assertTrue(portfolio.solve()); + Assert.assertSame(model.getSolver().getSearch(), search, "the search declared on the original model is kept"); + assertQueens(portfolio.getBestSolution(), q); + } + + @Test(groups = "1s", timeOut = 60000) + public void testSolvedModelCannotBeDuplicated() { + Model model = Model.record("queens"); + queens(model, 4); + Assert.assertNotNull(model.duplicate()); + Assert.assertTrue(model.getSolver().solve()); + // once its resolution has started, the model cannot be duplicated anymore + Assert.assertThrows(SolverException.class, model::duplicate); + } + + @Test(groups = "1s", timeOut = 60000) + public void testPlainModelCannotBeDuplicated() { + Model model = Model.create("plain"); + queens(model, 4); + SolverException e = Assert.expectThrows(SolverException.class, model::duplicate); + Assert.assertTrue(e.getMessage().contains("Model.record"), e.getMessage()); + Assert.assertThrows(SolverException.class, () -> ParallelPortfolio.of(model, 2)); + } +} diff --git a/solver/src/test/java/org/chocosolver/solver/spec/SpecPortfolioTest.java b/solver/src/test/java/org/chocosolver/solver/spec/SpecPortfolioTest.java new file mode 100644 index 0000000000..e0a109f825 --- /dev/null +++ b/solver/src/test/java/org/chocosolver/solver/spec/SpecPortfolioTest.java @@ -0,0 +1,136 @@ +/* + * This file is part of choco-solver, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.solver.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.ParallelPortfolio; +import org.chocosolver.solver.Solution; +import org.chocosolver.solver.SettingsBuilder; +import org.chocosolver.solver.exception.SolverException; +import org.chocosolver.solver.search.strategy.SearchParams; +import org.chocosolver.solver.variables.IntVar; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Tests of {@link ParallelPortfolio#of(ModelSpec, List)}. + * + * @author Charles Prud'homme + */ +public class SpecPortfolioTest { + + private static final int[] WEIGHTS = {3, 4, 5, 8, 9, 11, 13}; + private static final int[] VALUES = {4, 5, 7, 10, 11, 15, 17}; + private static final int CAPACITY = 30; + + /** + * A small knapsack, to maximize. + * + * @return the occurrences + */ + private static IntVar[] knapsack(Model m) { + IntVar[] occ = m.intVarArray("o", WEIGHTS.length, 0, 3); + IntVar weight = m.intVar("w", 0, CAPACITY); + IntVar value = m.intVar("v", 0, 200); + m.knapsack(occ, weight, value, WEIGHTS, VALUES).post(); + m.setObjective(Model.MAXIMIZE, value); + return occ; + } + + /** + * @return the optimum, computed sequentially + */ + private static int optimum() { + Model m = new Model(); + knapsack(m); + Solution best = m.getSolver().findOptimalSolution((IntVar) m.getObjective(), Model.MAXIMIZE); + return best.getIntVal((IntVar) m.getObjective()); + } + + private static void assertOptimal(ParallelPortfolio portfolio, RecordingModel master, IntVar[] occ) { + int best = -1; + while (portfolio.solve()) { + best = portfolio.getBestModel().getSolver().getBestSolutionValue().intValue(); + // each solution can be read from the master + SpecSolution sol = portfolio.getBestSpecSolution(); + Solution inMaster = sol.toSolution(master.getRecorder()); + int weight = 0; + int value = 0; + for (int i = 0; i < occ.length; i++) { + weight += WEIGHTS[i] * inMaster.getIntVal(occ[i]); + value += VALUES[i] * inMaster.getIntVal(occ[i]); + } + Assert.assertTrue(weight <= CAPACITY); + Assert.assertEquals(value, best); + Assert.assertEquals(inMaster.getIntVal((IntVar) master.getObjective()), best); + } + Assert.assertEquals(best, optimum()); + } + + @Test(groups = "1s", timeOut = 60000) + public void testCarbonCopies() { + RecordingModel master = new RecordingModel("knapsack"); + IntVar[] occ = knapsack(master); + ParallelPortfolio portfolio = ParallelPortfolio.of(master.snapshot(), 4); + List models = portfolio.getModels(); + Assert.assertEquals(models.size(), 4); + Set names = new HashSet<>(); + for (Model m : models) { + Assert.assertNotSame(m, master); + Assert.assertNotNull(portfolio.resolverOf(m)); + names.add(m.getName()); + } + Assert.assertEquals(names.size(), 4, "the models are named after their rank"); + assertOptimal(portfolio, master, occ); + } + + @Test(groups = "1s", timeOut = 60000) + public void testVariants() { + RecordingModel master = new RecordingModel("knapsack"); + IntVar[] occ = knapsack(master); + int[] ids = master.getRecorder().idsOf(occ); + SearchDecl search = SearchDecl.intVarSearch( + new SearchParams.VarSelConf(SearchParams.VariableSelection.DOM, Integer.MAX_VALUE), + new SearchParams.ValSelConf(SearchParams.ValueSelection.MAX, false, 1, false), ids); + ParallelPortfolio portfolio = ParallelPortfolio.of(master.snapshot(), List.of( + Variant.IDENTITY, + Variant.seed(17), + Variant.search(search), + Variant.settings(SettingsBuilder.init().setEnableSAT(true).build()))); + Assert.assertEquals(portfolio.getModels().size(), 4); + Assert.assertEquals(portfolio.getModels().get(1).getSeed(), 17L); + assertOptimal(portfolio, master, occ); + } + + @Test(groups = "1s", timeOut = 60000) + public void testManualPortfolioHasNoResolver() { + ParallelPortfolio portfolio = new ParallelPortfolio(); + Model m = new Model(); + knapsack(m); + portfolio.addModel(m); + Assert.assertNull(portfolio.resolverOf(m)); + Assert.assertTrue(portfolio.solve()); + Assert.assertThrows(SolverException.class, portfolio::getBestSpecSolution); + Assert.assertNotNull(portfolio.getBestSolution()); + } + + @Test(groups = "1s", timeOut = 60000) + public void testInvalidVariantIsReported() { + RecordingModel master = new RecordingModel("knapsack"); + knapsack(master); + ModelSpec spec = master.snapshot(); + // a variant which breaks the replay: the error of the instantiation is reported + Variant broken = Variant.mapCalls(c -> c.method().name().equals("knapsack") + ? new Step.Call(c.out(), FactoryMethod.of("allDifferent", IntVar[].class, String.class), + new Arg[]{c.args()[0], new Arg.Const("UNKNOWN")}) : c); + Assert.assertThrows(SolverException.class, () -> ParallelPortfolio.of(spec, List.of(Variant.IDENTITY, broken))); + } +} From 39a25ef6dd0bd549e5dc5c17dc8e0987d1070bcb Mon Sep 17 00:00:00 2001 From: Charles Prud'homme Date: Thu, 1 Oct 2026 15:51:04 +0200 Subject: [PATCH 5/6] Parsers: recordable models, custom constraints and spec benches - RegParser.newModel(name, settings) can be overridden to build recorded models; RegParser.getModels() returns all the models built. - The FlatZinc parser builds BoolSumEq0Reif/BoolSumLeq0Reif with Model.custom, so that these models can be duplicated. - Spec tests and benches (group "spec", added to the CI): equivalence of plain, recorded and replayed models on the instances of the repository, portfolio and recording overhead benches (etc/spec-bench.sh, one JVM per instance). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/maven-test.yml | 2 +- etc/spec-bench.sh | 28 ++ .../org/chocosolver/parser/RegParser.java | 20 ++ .../org/chocosolver/parser/dimacs/DIMACS.java | 2 +- .../chocosolver/parser/flatzinc/Flatzinc.java | 2 +- .../parser/flatzinc/ast/FConstraint.java | 4 +- .../java/org/chocosolver/parser/mps/MPS.java | 2 +- .../org/chocosolver/parser/xcsp/XCSP.java | 2 +- .../parser/spec/SpecEquivalenceMain.java | 79 +++++ .../parser/spec/SpecEquivalenceTest.java | 321 ++++++++++++++++++ .../parser/spec/SpecOverheadBench.java | 156 +++++++++ .../chocosolver/parser/spec/SpecParsers.java | 65 ++++ .../parser/spec/SpecPortfolioBench.java | 194 +++++++++++ 13 files changed, 870 insertions(+), 7 deletions(-) create mode 100755 etc/spec-bench.sh create mode 100644 parsers/src/test/java/org/chocosolver/parser/spec/SpecEquivalenceMain.java create mode 100644 parsers/src/test/java/org/chocosolver/parser/spec/SpecEquivalenceTest.java create mode 100644 parsers/src/test/java/org/chocosolver/parser/spec/SpecOverheadBench.java create mode 100644 parsers/src/test/java/org/chocosolver/parser/spec/SpecParsers.java create mode 100644 parsers/src/test/java/org/chocosolver/parser/spec/SpecPortfolioBench.java diff --git a/.github/workflows/maven-test.yml b/.github/workflows/maven-test.yml index acf9a8fb93..af74ccc25f 100644 --- a/.github/workflows/maven-test.yml +++ b/.github/workflows/maven-test.yml @@ -62,7 +62,7 @@ jobs: strategy: #fail-fast: false matrix: - group: [ "xcsp", "dimacs", "mps" ] + group: [ "xcsp", "dimacs", "mps", "spec" ] # The different steps steps: diff --git a/etc/spec-bench.sh b/etc/spec-bench.sh new file mode 100755 index 0000000000..03753f73b8 --- /dev/null +++ b/etc/spec-bench.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# +# Equivalence bench of ModelSpec on a set of instances, each one in its own JVM. +# +# Usage: spec-bench.sh

+ * Prints one line: {@code RESULTstatusinstancesecondsdetail}, with status among OK, FAIL, + * SKIP (not journalizable), ERROR, OOM and TIMEOUT. + *

+ * Usage: {@code SpecEquivalenceMain [time limit in seconds]}; the system properties of + * {@link SpecEquivalenceTest} apply. + * + * @author Charles Prud'homme + */ +public class SpecEquivalenceMain { + + public static void main(String[] args) { + String file = args[0]; + long limit = args.length > 1 ? Long.parseLong(args[1]) : 600; + String name = Paths.get(file).getFileName().toString(); + System.setProperty("spec.progress", "false"); + PrintStream out = System.out; + // the parsers and the solver may print: keep stdout for the result only + System.setOut(new PrintStream(java.io.OutputStream.nullOutputStream())); + long start = System.currentTimeMillis(); + Thread watchdog = new Thread(() -> { + try { + Thread.sleep(limit * 1000); + result(out, "TIMEOUT", name, start, "exceeds " + limit + " s"); + Runtime.getRuntime().halt(3); + } catch (InterruptedException ignored) { + // done in time + } + }); + watchdog.setDaemon(true); + watchdog.start(); + SpecEquivalenceTest test = new SpecEquivalenceTest(); + int status = 0; + try { + test.testEquivalence(name, file); + result(out, "OK", name, start, test.usageSummary()); + } catch (SkipException e) { + result(out, "SKIP", name, start, e.getMessage()); + } catch (AssertionError e) { + result(out, "FAIL", name, start, e.getMessage()); + status = 1; + } catch (OutOfMemoryError e) { + result(out, "OOM", name, start, ""); + status = 2; + } catch (Throwable e) { + result(out, "ERROR", name, start, e.toString()); + status = 1; + } + watchdog.interrupt(); + out.flush(); + Runtime.getRuntime().halt(status); + } + + private static void result(PrintStream out, String status, String name, long start, String detail) { + String d = detail == null ? "" : detail.replace('\t', ' ').replace('\n', ' '); + if (d.length() > 500) { + d = d.substring(0, 500) + "..."; + } + out.printf("RESULT\t%s\t%s\t%.1f\t%s%n", status, name, (System.currentTimeMillis() - start) / 1000.0, d); + out.flush(); + } +} diff --git a/parsers/src/test/java/org/chocosolver/parser/spec/SpecEquivalenceTest.java b/parsers/src/test/java/org/chocosolver/parser/spec/SpecEquivalenceTest.java new file mode 100644 index 0000000000..eada65d765 --- /dev/null +++ b/parsers/src/test/java/org/chocosolver/parser/spec/SpecEquivalenceTest.java @@ -0,0 +1,321 @@ +/* + * This file is part of choco-parsers, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.parser.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.Solver; +import org.chocosolver.solver.constraints.Constraint; +import org.chocosolver.solver.search.strategy.Search; +import org.chocosolver.solver.spec.ModelSpec; +import org.chocosolver.solver.spec.Recorder; +import org.chocosolver.solver.spec.SharedObjects; +import org.chocosolver.solver.spec.SpecException; +import org.chocosolver.solver.spec.Step; +import org.chocosolver.solver.variables.IntVar; +import org.chocosolver.solver.variables.Variable; +import org.testng.Assert; +import org.testng.SkipException; +import org.testng.annotations.AfterClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Equivalence bench of {@link ModelSpec}: each instance is parsed into a plain model and into a model recorded + * with {@link Model#record}, whose spec is instantiated twice. All the models must have the same structure and the + * same search (solutions, nodes, fails), and the instances must not share mutable objects with each other or with + * the recorded model. + *

+ * Instances which cannot be journalized are skipped and reported in {@code target/spec-coverage.md}. + *

+ * System properties: + *

    + *
  • {@code spec.dir}: a directory of instances (.fzn, .xml, .xml.lzma) to use instead of the test + * resources;
  • + *
  • {@code spec.nodes}: node limit of each resolution (default: 1000);
  • + *
  • {@code spec.sharing}: whether shared objects are looked for (default: true);
  • + *
  • {@code spec.sharing.maxsize}: maximum number of variables and constraints of a model in which shared + * objects are looked for (default: 20000);
  • + *
  • {@code spec.filter}: regular expression selecting the instances by file name (default: all).
  • + *
+ * + * @author Charles Prud'homme + */ +public class SpecEquivalenceTest { + + private static final long NODES = Long.getLong("spec.nodes", 1000); + + /** + * Whether shared objects are looked for (memory consuming on large instances). + */ + private static final boolean SHARING = Boolean.parseBoolean(System.getProperty("spec.sharing", "true")); + + /** + * Shared objects are not looked for in models with more variables and constraints (memory consuming). + */ + private static final int SHARING_MAX_SIZE = Integer.getInteger("spec.sharing.maxsize", 20_000); + + /** + * Regular expression selecting the instances by name. + */ + private static final String FILTER = System.getProperty("spec.filter", ".*"); + + /** + * Two models parsed independently own their tuples and diagrams: not worth traversing to find the (global) + * objects they already share. + */ + private static final Set> BASELINE_ALLOWED = Set.of(PrintStream.class, + org.chocosolver.solver.constraints.extension.Tuples.class, + org.chocosolver.util.objects.graphs.MultivaluedDecisionDiagram.class); + + /** + * Types which may be shared by models: immutable, or shared by design. + */ + private static final Set> ALLOWED = Set.of(PrintStream.class); + + // coverage, aggregated over instances + private final Map unsupported = new TreeMap<>(); + private final List skipped = new ArrayList<>(); + private final Map usage = new TreeMap<>(); + private final Map baseline = new TreeMap<>(); + private final List sharingSkipped = new ArrayList<>(); + private int passed; + + @DataProvider + public Object[][] instances() throws IOException { + List roots = new ArrayList<>(); + String dir = System.getProperty("spec.dir"); + if (dir != null) { + roots.add(Paths.get(dir)); + } else { + roots.add(Paths.get(Objects.requireNonNull(getClass().getResource("/flatzinc")).getPath())); + roots.add(Paths.get(Objects.requireNonNull(getClass().getResource("/xcsp")).getPath())); + } + List res = new ArrayList<>(); + for (Path root : roots) { + try (Stream files = Files.walk(root)) { + files.map(Path::toString) + .filter(f -> f.endsWith(".fzn") || f.endsWith(".xml") || f.endsWith(".xml.lzma")) + .filter(f -> Paths.get(f).getFileName().toString().matches(FILTER)) + .sorted() + .forEach(f -> res.add(new Object[]{root.relativize(Paths.get(f)).toString(), f})); + } + } + return res.toArray(new Object[0][]); + } + + @Test(groups = "spec", dataProvider = "instances", timeOut = 300_000) + public void testEquivalence(String name, String file) throws Exception { + progress(name); + // the models are kept alive only when needed, to save memory on large instances + Model direct = build(file, false); + String expected = structure(direct); + Set baseline = Set.of(); + boolean sharing = SHARING && direct.getNbVars() + direct.getNbCstrs() <= SHARING_MAX_SIZE; + if (!sharing) { + reportSharingSkipped(name); + } else { + // objects already shared by two models parsed independently (global singletons): excused by identity + baseline = SharedObjects.common(direct, build(file, false), BASELINE_ALLOWED, List.of()); + reportBaseline(baseline); + } + String stats = solve(direct); + direct = null; + + Model recorded = build(file, true); + ModelSpec spec; + try { + spec = recorded.getRecorder().snapshot(); + } catch (SpecException e) { + reportUnsupported(name, recorded.getRecorder()); + throw new SkipException("cannot be journalized: " + recorded.getRecorder().unsupported().get(0)); + } + assertSame(structure(recorded), expected, "recording changes the model"); + List excused = new ArrayList<>(SharedObjects.constantsOf(spec)); + excused.addAll(baseline); + List shared = new ArrayList<>(); + Model i1 = spec.instantiate().model(); + assertSame(structure(i1), expected, "the instantiated model differs"); + if (sharing) { + shared.addAll(SharedObjects.between(recorded, i1, ALLOWED, excused)); + } + recorded = null; + Model i2 = spec.instantiate().model(); + if (sharing) { + shared.addAll(SharedObjects.between(i1, i2, ALLOWED, excused)); + Assert.assertTrue(shared.isEmpty(), "shared mutable objects: " + shared); + } + + // same search, the instances being solved concurrently + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future f1 = pool.submit(() -> solve(i1)); + Future f2 = pool.submit(() -> solve(i2)); + Assert.assertEquals(f1.get(), stats, "the instantiated model searches differently"); + Assert.assertEquals(f2.get(), stats, "the instantiated model searches differently"); + } finally { + pool.shutdownNow(); + } + reportUsage(spec); + } + + /** + * Log the instance being tested, to identify it if the JVM crashes. + */ + private static void progress(String name) throws IOException { + if (!Boolean.parseBoolean(System.getProperty("spec.progress", "true"))) { + return; + } + Path log = Paths.get(System.getProperty("user.dir"), "target", "spec-progress.log"); + Files.createDirectories(log.getParent()); + Files.writeString(log, name + "\n", java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); + } + + /** + * Compare two structures, reporting the first different line. + */ + private static void assertSame(String actual, String expected, String message) { + if (!actual.equals(expected)) { + String[] a = actual.split("\n"); + String[] e = expected.split("\n"); + int i = 0; + while (i < a.length && i < e.length && a[i].equals(e[i])) { + i++; + } + Assert.fail(message + ", line " + i + ": expected <" + (i < e.length ? e[i] : "EOF") + + "> but was <" + (i < a.length ? a[i] : "EOF") + ">"); + } + } + + /** + * Parse file into a plain model, or into a recorded model. + */ + private static Model build(String file, boolean record) { + try { + return SpecParsers.parse(file, 1, record).getModel(); + } catch (IllegalStateException e) { + throw new SkipException(e.getMessage()); + } + } + + /** + * @return a description of the structure of the model: variables and constraints, in order + */ + private static String structure(Model m) { + StringBuilder sb = new StringBuilder(); + sb.append(m.getNbVars()).append(" vars, ").append(m.getNbCstrs()).append(" cstrs, objective: ") + .append(m.getObjective() == null ? "none" : m.getObjective().getName()).append('\n'); + for (Variable v : m.getVars()) { + sb.append(v.getClass().getSimpleName()).append(' ').append(v.getName()); + if (v instanceof IntVar) { + sb.append(' ').append(((IntVar) v).getLB()).append("..").append(((IntVar) v).getUB()) + .append(" #").append(((IntVar) v).getDomainSize()); + } + sb.append('\n'); + } + m.getGroups().forEach(g -> sb.append("group ").append(g.getName()).append(Arrays.stream(g.getVariables()) + .map(Variable::getName).collect(Collectors.joining(",", "[", "]"))).append('\n')); + for (Constraint c : m.getCstrs()) { + sb.append(c.getName()).append(Arrays.stream(c.getPropagators()) + .map(p -> p.getClass().getSimpleName() + "/" + p.getNbVars()) + .collect(Collectors.joining(",", "[", "]"))).append('\n'); + } + return sb.toString(); + } + + /** + * Solve with the default (deterministic) search, limited in nodes. + */ + private static String solve(Model m) { + Solver s = m.getSolver(); + Search.defaultSearch(m); + s.limitNode(NODES); + Number best = null; + while (s.solve()) { + if (m.getObjective() != null) { + best = s.getBestSolutionValue(); + } + } + return s.getSolutionCount() + " sols, " + s.getNodeCount() + " nodes, " + s.getFailCount() + " fails" + + (best == null ? "" : ", best=" + best); + } + + private synchronized void reportUnsupported(String name, Recorder recorder) { + skipped.add(name + " (" + recorder.unsupported().size() + ")"); + Set reasons = new HashSet<>(); + for (Recorder.Unsupported u : recorder.unsupported()) { + reasons.add(u.operation() + ": " + u.reason().replaceAll("variable \\S+", "variable _")); + } + reasons.forEach(r -> unsupported.merge(r, 1, Integer::sum)); + } + + private synchronized void reportSharingSkipped(String name) { + sharingSkipped.add(name); + } + + private synchronized void reportBaseline(Set objects) { + objects.stream().map(o -> o.getClass().getName()).distinct().forEach(t -> baseline.merge(t, 1, Integer::sum)); + } + + private synchronized void reportUsage(ModelSpec spec) { + passed++; + Set used = new HashSet<>(); + for (Step s : spec.steps()) { + used.add(s instanceof Step.Call ? ((Step.Call) s).method().name() : s.getClass().getSimpleName()); + } + used.forEach(u -> usage.merge(u, 1, Integer::sum)); + } + + /** + * @return the kinds of steps used by the journalized instances, and the instances whose sharing was not + * checked + */ + synchronized String usageSummary() { + return String.join(",", usage.keySet()) + (sharingSkipped.isEmpty() ? "" : " [sharing not checked]"); + } + + @AfterClass(alwaysRun = true) + public void writeReport() throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append("# ModelSpec coverage\n\n") + .append("- journalized and equivalent: ").append(passed).append('\n') + .append("- not journalizable: ").append(skipped.size()).append("\n\n") + .append("## Unsupported operations (number of instances)\n\n"); + unsupported.entrySet().stream().sorted(Map.Entry.comparingByValue().reversed()) + .forEach(e -> sb.append("- ").append(e.getValue()).append(" × ").append(e.getKey()).append('\n')); + sb.append("\n## Not journalizable instances\n\n"); + skipped.forEach(s -> sb.append("- ").append(s).append('\n')); + sb.append("\n## Shared objects not looked for (large models): ").append(sharingSkipped.size()).append("\n\n"); + sharingSkipped.forEach(n -> sb.append("- ").append(n).append('\n')); + sb.append("\n## Objects shared by two models parsed independently (number of instances)\n\n"); + baseline.forEach((k, v) -> sb.append("- ").append(k).append(": ").append(v).append('\n')); + sb.append("\n## Steps used by the journalized instances (number of instances)\n\n"); + usage.forEach((k, v) -> sb.append("- ").append(k).append(": ").append(v).append('\n')); + Path out = Paths.get(System.getProperty("user.dir"), "target", "spec-coverage.md"); + Files.createDirectories(out.getParent()); + Files.writeString(out, sb.toString()); + System.out.println(sb); + } +} diff --git a/parsers/src/test/java/org/chocosolver/parser/spec/SpecOverheadBench.java b/parsers/src/test/java/org/chocosolver/parser/spec/SpecOverheadBench.java new file mode 100644 index 0000000000..1593d2dbff --- /dev/null +++ b/parsers/src/test/java/org/chocosolver/parser/spec/SpecOverheadBench.java @@ -0,0 +1,156 @@ +/* + * This file is part of choco-parsers, http://choco-solver.org/ + * Copyright (c) 1999, IMT Atlantique. + * SPDX-License-Identifier: BSD-3-Clause. + * See LICENSE file in the project root for full license information. + */ +package org.chocosolver.parser.spec; + +import org.chocosolver.solver.Model; +import org.chocosolver.solver.spec.ModelSpec; + +import java.io.PrintStream; +import java.lang.ref.Reference; +import java.lang.management.ManagementFactory; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Locale; + +/** + * Measures, on one instance, the overhead of recording the construction of a model: + *
    + *
  • building time: parsing into a plain model ({@code create}) versus into a recorded model + * ({@code record});
  • + *
  • cost of {@code snapshot} and of {@code instantiate} (replay of the spec), compared to parsing;
  • + *
  • memory: the plain model, the recorded model (model and journal), and the spec alone.
  • + *
+ * Times are medians over {@code spec.reps} repetitions (default 5), after a warm-up. + *

+ * To be launched in its own JVM (see {@code etc/spec-bench.sh}, with {@code SPEC_MAIN} set to this class). Prints one + * line: {@code RESULTstatusinstancesecondsmetrics}, metrics being {@code key=value} pairs + * separated by {@code ;}. + *

+ * Usage: {@code SpecOverheadBench

    + *
  • (a) as the parsers currently do: n parsings, one after the other;
  • + *
  • (a') n parsings run concurrently;
  • + *
  • (b) with a {@link ModelSpec}: one recorded parsing (the master, which also solves), then n-1 models + * instantiated concurrently.
  • + *
+ * For each: the building time and the heap used by the n models. Optionally, (a) and (b) are solved with a + * {@link ParallelPortfolio} under the same time limit. + *

+ * To be launched in its own JVM (see {@code etc/spec-bench.sh}, with {@code SPEC_MAIN} set to this class). Prints one + * line: {@code RESULTstatusinstancesecondsmetrics}, metrics being + * {@code key=value} pairs separated by {@code ;}. + *

+ * Usage: {@code SpecPortfolioBench

still runs a whole directory. - The node limit of each resolution defaults to 100 (-Dspec.nodes). - Time-out per instance: 120 s. Locally: 49 s instead of 614 s. --- .../parser/spec/SpecEquivalenceTest.java | 36 ++-- parsers/src/test/resources/spec-instances.txt | 177 ++++++++++++++++++ 2 files changed, 200 insertions(+), 13 deletions(-) create mode 100644 parsers/src/test/resources/spec-instances.txt diff --git a/parsers/src/test/java/org/chocosolver/parser/spec/SpecEquivalenceTest.java b/parsers/src/test/java/org/chocosolver/parser/spec/SpecEquivalenceTest.java index eada65d765..fa35def394 100644 --- a/parsers/src/test/java/org/chocosolver/parser/spec/SpecEquivalenceTest.java +++ b/parsers/src/test/java/org/chocosolver/parser/spec/SpecEquivalenceTest.java @@ -52,9 +52,9 @@ *

* System properties: *

    - *
  • {@code spec.dir}: a directory of instances (.fzn, .xml, .xml.lzma) to use instead of the test - * resources;
  • - *
  • {@code spec.nodes}: node limit of each resolution (default: 1000);
  • + *
  • {@code spec.dir}: a directory of instances (.fzn, .xml, .xml.lzma) to use instead of the instances of the + * test resources listed in {@code spec-instances.txt} (one per family of problems);
  • + *
  • {@code spec.nodes}: node limit of each resolution (default: 100);
  • *
  • {@code spec.sharing}: whether shared objects are looked for (default: true);
  • *
  • {@code spec.sharing.maxsize}: maximum number of variables and constraints of a model in which shared * objects are looked for (default: 20000);
  • @@ -65,7 +65,13 @@ */ public class SpecEquivalenceTest { - private static final long NODES = Long.getLong("spec.nodes", 1000); + private static final long NODES = Long.getLong("spec.nodes", 100); + + /** + * The instances of the test resources to use, one per family of problems: similar instances add time, not + * coverage. + */ + private static final String INSTANCES = "spec-instances.txt"; /** * Whether shared objects are looked for (memory consuming on large instances). @@ -105,16 +111,10 @@ public class SpecEquivalenceTest { @DataProvider public Object[][] instances() throws IOException { - List roots = new ArrayList<>(); String dir = System.getProperty("spec.dir"); - if (dir != null) { - roots.add(Paths.get(dir)); - } else { - roots.add(Paths.get(Objects.requireNonNull(getClass().getResource("/flatzinc")).getPath())); - roots.add(Paths.get(Objects.requireNonNull(getClass().getResource("/xcsp")).getPath())); - } List res = new ArrayList<>(); - for (Path root : roots) { + if (dir != null) { + Path root = Paths.get(dir); try (Stream files = Files.walk(root)) { files.map(Path::toString) .filter(f -> f.endsWith(".fzn") || f.endsWith(".xml") || f.endsWith(".xml.lzma")) @@ -122,11 +122,21 @@ public Object[][] instances() throws IOException { .sorted() .forEach(f -> res.add(new Object[]{root.relativize(Paths.get(f)).toString(), f})); } + } else { + Path list = Paths.get(Objects.requireNonNull(getClass().getResource("/" + INSTANCES)).getPath()); + Path root = list.getParent(); + for (String line : Files.readAllLines(list)) { + String name = line.strip(); + if (!name.isEmpty() && !name.startsWith("#") + && Paths.get(name).getFileName().toString().matches(FILTER)) { + res.add(new Object[]{name, root.resolve(name).toString()}); + } + } } return res.toArray(new Object[0][]); } - @Test(groups = "spec", dataProvider = "instances", timeOut = 300_000) + @Test(groups = "spec", dataProvider = "instances", timeOut = 120_000) public void testEquivalence(String name, String file) throws Exception { progress(name); // the models are kept alive only when needed, to save memory on large instances diff --git a/parsers/src/test/resources/spec-instances.txt b/parsers/src/test/resources/spec-instances.txt new file mode 100644 index 0000000000..6cf8e814a4 --- /dev/null +++ b/parsers/src/test/resources/spec-instances.txt @@ -0,0 +1,177 @@ +# Instances of SpecEquivalenceTest: one per family of problems (the fastest one), to keep the test short. +# Paths relative to the test resources. Run the whole directories with -Dspec.dir=. +flatzinc/2012/amaze+amaze+2012-07-04.fzn +flatzinc/2012/carpet-cutting+cc_base+mzn_rnd_test.16.fzn +flatzinc/2012/fast-food+fastfood+ff59.fzn +flatzinc/2012/filters+filter+fir_1_1.fzn +flatzinc/2012/league+league+model20-3-5.fzn +flatzinc/2012/mspsp+mspsp+medium_02.fzn +flatzinc/2012/nonogram+non+non_fast_4.fzn +flatzinc/2012/parity-learning+parity-learning+44_22_5.3.fzn +flatzinc/2012/pattern-set-mining-k2+pattern_set_mining_k2+audiology.fzn +flatzinc/2012/project-planning+ProjectPlannertest_12_8.fzn +flatzinc/2012/radiation+radiation+m07_07_20.fzn +flatzinc/2012/ship-schedule+ship-schedule.cp+7ShipsMixed.fzn +flatzinc/2012/solbat+sb+sb_14_14_6_0.fzn +flatzinc/2012/still-life-wastage+still-life+09.fzn +flatzinc/2012/tpp+tpp+tpp_5_3_20_1.fzn +flatzinc/2012/train+train+instance.12.fzn +flatzinc/2012/vrp+vrp+P-n20-k2.vrp.fzn +flatzinc/2015/gfd-schedule+n120f5d50m50k20.fzn +flatzinc/2016/nfc_12_2_10.fzn +flatzinc/2017/tcgc2+k10_34.fzn +flatzinc/2018/oocsp_racks+050_r1n.fzn +flatzinc/2018/rotating-workforce+ex1479.fzn +flatzinc/2018/steiner-tree+es10fst03.stp.fzn +flatzinc/2018/test-scheduling+t30m10r3-15.fzn +flatzinc/2019/ccmcp+3_20_0_25_5.fzn +flatzinc/2019/fm3_3.fzn +flatzinc/2019/foxgeesecorn+54.fzn +flatzinc/2019/group+u6g1pref1.fzn +flatzinc/2019/median_string_dp+p2_10_8-0.fzn +flatzinc/2019/mknapsack_global+mknap2-1.fzn +flatzinc/2019/zephyrus+12_6_6_3.fzn +flatzinc/2020/bnn+inventory_4_8s.fzn +flatzinc/2020/is+v1HjuSBQMb_new.fzn +flatzinc/2020/lot_sizing_cp+pigment15b.psp.fzn +flatzinc/2020/p1f-pjs+10_new.fzn +flatzinc/2020/pentominoes+02.fzn +flatzinc/2020/skill_allocation+mzn_1m_1.fzn +flatzinc/2020/soccer+xIGData_22_12_22_5.fzn +flatzinc/2020/stable-goods-solution+s-d6.fzn +flatzinc/2021/atsp+instance5_0p15.fzn +flatzinc/2021/connect__0018.fzn +flatzinc/2021/feistel.fzn +flatzinc/2021/mapping_mesh2x2_2.fzn +flatzinc/2021/perfect_square+205.fzn +flatzinc/2021/physician-scheduling.fzn +flatzinc/2021/yumi-dynamic+p_10.fzn +xcsp/2024/AztecDiamondSym-12_c24.xml.lzma +xcsp/2024/BinPacking-n1c1w1a_c24.xml.lzma +xcsp/2024/Cargo-01-0s-1913_m17.xml.lzma +xcsp/2024/Charlotte-18-2_c24.xml.lzma +xcsp/2024/Drinking-010000_c24.xml.lzma +xcsp/2024/FoolSolitaire-dec2-0-2.xml.lzma +xcsp/2024/SameQueensKnights-b-15_c24.xml.lzma +xcsp/2024/StillLife-12-12_c24.xml.lzma +xcsp/2024/TestScheduling-t020m10r03-1_c24.xml.lzma +xcsp/2024/TravelingTournament-galaxy06_c24.xml.lzma +flatzinc/2025/hitori_h5-1.fzn +flatzinc/alpha/alpha.fzn +xcsp/basics/AllInterval-005.xml.lzma +xcsp/basics/Allergy.xml.lzma +xcsp/basics/Auction-cnt-example_c18.xml.lzma +xcsp/basics/Auction-sum-example_c18.xml.lzma +xcsp/basics/Bacp-m1-06_c18.xml.lzma +xcsp/basics/Bibd-sc-06-050-25-03-10.xml.lzma +xcsp/basics/Bibd-sum-06-050-25-03-10.xml.lzma +xcsp/basics/BinPacking-mdd-n1c1w4a.xml.lzma +xcsp/basics/BinPacking-sum-n1c1w4a.xml.lzma +xcsp/basics/BinPacking-tab-n1c1w4a.xml.lzma +xcsp/basics/Blackhole-04-3-00.xml.lzma +xcsp/basics/BusScheduling-cnt-t1.xml.lzma +xcsp/basics/CarSequencing-dingbas.xml.lzma +xcsp/basics/ChessboardColoration-07-07.xml.lzma +xcsp/basics/ColouredQueens-07.xml.lzma +xcsp/basics/CostasArray-12.xml.lzma +xcsp/basics/Crossword-lex-vg-5-6.xml.lzma +xcsp/basics/CrosswordDesign-03-4-rom_c18.xml.lzma +xcsp/basics/CryptoPuzzle-cross-roads-danger.xml.lzma +xcsp/basics/Cutstock-small.xml.lzma +xcsp/basics/DistinctVectors-30-050-02.xml.lzma +xcsp/basics/Domino-300-300.xml.lzma +xcsp/basics/Fapp-m2s-ex2_c18.xml.lzma +xcsp/basics/Fastfood-ff10.xml.lzma +xcsp/basics/Filters-dct_2_3.xml.lzma +xcsp/basics/FlexibleJobshop-easy01.xml.lzma +xcsp/basics/Furniture.xml.lzma +xcsp/basics/GolombRuler-09-a3.xml.lzma +xcsp/basics/GracefulGraph-K02-P04.xml.lzma +xcsp/basics/GraphColoring-3-fullins-4.xml.lzma +xcsp/basics/GraphColoring-qwhdec-o5-h10-1.xml.lzma +xcsp/basics/Hanoi-05.xml.lzma +xcsp/basics/Kakuro-easy-000-ext.xml.lzma +xcsp/basics/Kakuro-easy-000-sumdiff.xml.lzma +xcsp/basics/Knapsack-30-100-00.xml.lzma +xcsp/basics/KnightTour-06-ext03.xml.lzma +xcsp/basics/KnightTour-06-int.xml.lzma +xcsp/basics/Langford-3-10.xml.lzma +xcsp/basics/LangfordBin-08.xml.lzma +xcsp/basics/LowAutocorrelation-015.xml.lzma +xcsp/basics/MSPSP-easy_01.xml.lzma +xcsp/basics/MSPSP-hard_01.xml.lzma +xcsp/basics/MagicSequence-008-ca.xml.lzma +xcsp/basics/MagicSequence-008-co.xml.lzma +xcsp/basics/MagicSquare-4-table.xml.lzma +xcsp/basics/MagicSquare-6-mdd.xml.lzma +xcsp/basics/MagicSquare-6-sum.xml.lzma +xcsp/basics/MagicSquare-9-f10-01.xml.lzma +xcsp/basics/Mario-easy-4.xml.lzma +xcsp/basics/MarketSplit-01.xml.lzma +xcsp/basics/MultiKnapsack-1-01.xml.lzma +xcsp/basics/MultiKnapsack-1-0_X2.xml.lzma +xcsp/basics/NFC-12_2_10.xml.lzma +xcsp/basics/Nonogram-001-regular.xml.lzma +xcsp/basics/Nonogram-001-table.xml.lzma +xcsp/basics/NurseRostering-00_c18.xml.lzma +xcsp/basics/Opd-07-007-003.xml.lzma +xcsp/basics/Ortholatin-005.xml.lzma +xcsp/basics/Pb-gr-05.xml.lzma +xcsp/basics/Pb-robin08.xml.lzma +xcsp/basics/PeacableArmies-m2-05_c18.xml.lzma +xcsp/basics/PizzaVoucher-10a_c18.xml.lzma +xcsp/basics/Primes-15-20-2-1.xml.lzma +xcsp/basics/PrizeCollecting-15-3-5-0.xml.lzma +xcsp/basics/QuadraticAssignment-bur26a.xml.lzma +xcsp/basics/QuadraticAssignment-qap.xml.lzma +xcsp/basics/QuadraticAssignment-qap_c18.xml.lzma +xcsp/basics/QuasiGroup-3-04.xml.lzma +xcsp/basics/QueenAttacking-06.xml.lzma +xcsp/basics/Queens-0008-m1.xml.lzma +xcsp/basics/RadarSurveillance-8-24-3-2-00.xml.lzma +xcsp/basics/Ramsey-12.xml.lzma +xcsp/basics/Rcpsp-j30-01-01_c18.xml.lzma +xcsp/basics/Rlfap-graph-04-opt_c18.xml.lzma +xcsp/basics/RoomMate-sr0050-int.xml.lzma +xcsp/basics/Sat-flat200-00-clause.xml.lzma +xcsp/basics/SocialGolfers-4-3-4-cp.xml.lzma +xcsp/basics/Sonet-s2ring02.xml.lzma +xcsp/basics/SportsScheduling-08.xml.lzma +xcsp/basics/SteelMillSlab-m1-simple_c18.xml.lzma +xcsp/basics/SteelMillSlab-m2s-mini-simple_c18.xml.lzma +xcsp/basics/Steiner3-08.xml.lzma +xcsp/basics/StillLife-03-06.xml.lzma +xcsp/basics/StillLife-wastage-03.xml.lzma +xcsp/basics/StripPacking-C1P1.xml.lzma +xcsp/basics/Subisomorphism-A-10.xml.lzma +xcsp/basics/Sudoku-s01a-alldiff.xml.lzma +xcsp/basics/SumColoring-myciel4_c18.xml.lzma +xcsp/basics/Taillard-js-015-15-0.xml.lzma +xcsp/basics/Taillard-os-04-04-0.xml.lzma +xcsp/basics/Tal-01_c18.xml.lzma +xcsp/basics/TeamAssignment-data1_4_6.xml.lzma +xcsp/basics/TemplateDesign-m1s-1_c18.xml.lzma +xcsp/basics/TemplateDesign-m2-1_c18.xml.lzma +xcsp/basics/TestSchedulingM18-t30m10r3-15.xml.lzma +xcsp/basics/Tpp-3-3-20-1.xml.lzma +xcsp/basics/TravelingTournament-a3-galaxy04_c18.xml.lzma +xcsp/basics/TravellingSalesman-13-13-0.xml.lzma +xcsp/basics/Vrp-A-n32-k5.xml.lzma +xcsp/basics/Vrp-P-n16-k8.xml.lzma +xcsp/basics/Warehouse-opl.xml.lzma +xcsp/basics/Zebra.xml.lzma +flatzinc/basics/all_different+test_globals.fzn +flatzinc/basics/basic+test_basic.fzn +flatzinc/basics/basic2+test_basic2+large.fzn +flatzinc/basics/bool_indomain_max+input_order.fzn +flatzinc/basics/circuit+test_globals.fzn +flatzinc/basics/cumulative+test_globals.fzn +xcsp/basics/driverlogw-09.xml.lzma +flatzinc/basics/int_indomain_median+anti_first_fail.fzn +xcsp/basics/qcp-15-120-00_X2.xml.lzma +xcsp/basics/qwh-o30-h374-01.xml.lzma +flatzinc/basics/seq_search_1.fzn +xcsp/basics/testExtension2.xml.lzma +xcsp/basics/testObjective1.xml.lzma +xcsp/basics/testPrimitive.xml.lzma +flatzinc/basics/test_set_lt_2.fzn