GOLDEN REFERENCE - DO NOT MODIFY WITHOUT EXPLICIT APPROVAL
This document serves as the canonical, unbreakable reference for Dantzig DSL syntax. All implementations must support the syntax patterns documented here exactly as specified.
This reference is based on:
test/dantzig/dsl/experimental/simple_generator_test.exs- Marked as golden syntaxexamples/nqueens_dsl.exs- Marked as golden syntax
# Simple problem with variables and constraints
problem = Problem.define do
new(name: "My Problem", description: "A simple optimization problem")
# Add variables
variables("x", [i <- 1..3], :continuous, "Decision variables")
# Add constraints
constraints([i <- 1..3], x[i] >= 0, "Non-negative constraint #{i}")
# Set objective
objective(sum(x[:_]), :maximize)
endProblem.define: Create new problems from scratch and add variables/constraints to existing problemsProblem.modify: Amend existing problems with new or replacement variables/constraints/objectivesProblem.add_variable: Add a single variable outside blocksProblem.add_constraint: Add a single constraint outside blocksProblem.set_objective: Set or replace the optimization objective outside blocks
problem = Problem.define do
new(name: "Problem Name", description: "Problem description")
# ... variables, constraints, objectives
endOnce a problem has been defined, it can be amended with new variables, constraints, and objectives.
problem = Problem.modify(problem) do
# list of variables, constraints, objective
endA problem can also be modified with new variables, constraints, and objectives outside of a Problem.define block with identical syntax. Problem.modify() can also modify existing variables, existing constraints (identified by their generated names). In this case, there is no creation of additional variables or constraints. A warning is issued if a variable or constraint with the same name already exists.
A Problem.modify() cannot contain a new() statement.
problem = Problem.modify(problem) do
new(name: "Problem Name", description: "Problem description") # Error: new() not allowed in Problem.modify()
endProblem.modify() returns a new problem with the modifications applied.
Problem.modify() can also be called with parameters provided as a dictionary map with identical syntax:
problem = Problem.modify(problem, model_parameters: model_parameters) do
# list of variables, constraints, objective
endvariables/3 is used to define individual variables one by one within a Problem.define or a Problem.modify block. variables/3 cannot accept iterators.
(Note: variables/3 is equivalent to variables/4, which can accept generators (see below) with an empty generator list: variables("name", [], :type, "description"). The empty generator list is equivalent to no generators provided. variables/3 and variables/4, having different arities, are different functions.)
Variable deifinitions can include a minimum bound and/or a maximum bound provided as parameters for integer or continuous variables. The type of the variable dictates the type of the bounds. Infinity is denoted as:
- positive infinity:
:infinity,:infty,:inf,:pos_infinity,:pos_infty,:pos_inf - negative infinity:
:neg_infinity,:neg_infty,:neg_inf
problem = Problem.define do
new(name: "Problem Name", description: "Problem description")
# variables/3 takes a variable name, a type and a description
variables("var_int_1", :integer, "Description")
variables("var_int_2", :integer, "Desciption", min_bound: 0, max_bound: 100)
variables("var_int_3", :integer, "Desciption", min_bound: 0, max_bound: :infinity)
variables("var_int_4", :integer, "Desciption", min_bound: :neg_infty, max_bound: 13)
variables("var_bin_1", :binary, "Description")
variables("var_float_1", :continuous, "Description")
variables("var_float_2", :continuous, "Desciption", min_bound: 0, max_bound: 100)
variables("var_float_3", :continuous, "Desciption", min_bound: 0, max_bound: :infinity)
variables("var_float_4", :continuous, "Desciption", min_bound: :neg_infty, max_bound: 13)
variables("var_float_2", :continuous, "Desciption", min_bound: 0.889, max_bound: 100.864)
variables("var_float_3", :continuous, "Desciption", min_bound: -235.12, max_bound: :infinity)
variables("var_float_4", :continuous, "Desciption", min_bound: :neg_infty, max_bound: 13.29345)
endThe following statements generate errors.
problem = Problem.define do
new(name: "Problem Name", description: "Problem description")
variables("var_int_1", :integer, "Description", min_bound: 0.0, max_bound: 100e5) # No floating point values allowed for integers
variables("var_bin_1", :binary, "Description", min_bound: 0) # Binary variables have no bounds
variables("var_bin_2", :binary, "Description", max_bound: :inf) # Binary variables have no bounds
endproblem = Problem.define do
new(name: "Problem Name", description: "Problem description")
end
problem = Problem.modify(problem) do
variables("var_name_1", :binary, "Description")
variables("var_name_2", :continuous, "Description")
endvariables/4 is used to define multiple variables at once within a Problem.define or a Problem.modify block. variables/4 can accept iterators.
Generators are always in the form of [variable <- list] or [variable <- generator], or a combination of both provided as a list: [variable_1 <- list, variable_2 <- generator, {var3, var4} <- generator_map].
For example:
[food <- ["bread", "milk"]][i <- 1..4][i <- 1..4, j <- 1..4][i <- 1..4, j <- 1..4 where i != j](In a future extension)food_names = ["bread", "milk"]and[food <- food_names][{k, v} <- [{"bread", 1}, {"milk", 2}]]
Internally those are converted to quoted expressions like quote(do: [food <- ["bread", "milk"]]) or quote(do: [i <- 1..4]) or [quote(do: i) <- 1..4, quote(do: j) <- 1..4]. But that syntax with quote(do: ...) is not allowed in the DSL.
An empty generator list such as variables("var_name", [], :binary, "Description") is equivalent to variables("var_name", :binary, "Description"). This is a special case equivalent to variables/3.
problem = Problem.define do
new(name: "Problem Name", description: "Problem description")
# Using literal lists
# variables/4 takes a variable name, a list of generators, a type and a description
# It would generate a list of variables/3 for each combination of the generators
# For example, if food_names = ["bread", "milk"], it would generate:
# variables("qty_bread", :continuous, "Amount of bread")
# variables("qty_milk", :continuous, "Amount of milk")
variables("qty", [food <- ["bread", "milk"]], :continuous, "Amount of food")
endproblem = Problem.define do
new(name: "Problem Name", description: "Problem description")
end
problem = Problem.modify(problem) do
# variables/4 takes a variable name, a list of generators, a type and a description
variables("qty", [food <- ["bread", "milk"]], :continuous, "Amount of food")
endmodelParameters = %{food_names: ["bread", "milk"]}
problem = Problem.define(model_parameters: model_parameters) do
new(name: "Problem Name", description: "Problem description")
variables("qty", [food <- food_names], :continuous, "Amount of food")
endor, equivalently in multiple blocks:
problem = Problem.define do
new(name: "Problem Name", description: "Problem description")
end
modelParameters = %{food_names: ["bread", "milk"]}
problem = Problem.modify(problem, model_parameters: model_parameters) do
variables("qty", [food <- food_names], :continuous, "Amount of food")
end# 2D variables
problem = Problem.define do
new(name: "2D Example", description: "2D variables example")
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
# It would generate 16 variables/3 like:
# variables("queen2d_1_1", :binary, "Queen position 1,1")
# variables("queen2d_1_2", :binary, "Queen position 1,2")
# ...
# variables("queen2d_4_4", :binary, "Queen position 4,4")
end# 3D variables
problem = Problem.define do
new(name: "3D Example", description: "3D variables example")
variables("queen3d", [i <- 1..4, j <- 1..4, k <- 1..4], :binary, "Queen position")
endmodelParameters = %{max_i: 4, max_j: 4, max_k: 4}
problem = Problem.define(model_parameters: model_parameters) do
new(name: "3D Example", description: "3D variables example")
variables("queen3d", [i <- 1..max_i, j <- 1..max_j, k <- 1..max_k], :binary, "Queen position")
# It would generate max_i x max_j x max_k variables/3 statements like:
# variables("queen3d_1_1_1", :binary, "Queen position 1,1,1")
# variables("queen3d_1_1_2", :binary, "Queen position 1,1,2")
# ...
# variables("queen3d_1_1_#{max_k}", :binary, "Queen position 1,1,#{max_k}")
# variables("queen3d_1_2_1", :binary, "Queen position 1,2,1")
# ...
# variables("queen3d_1_2_#{max_k}", :binary, "Queen position 1,2,#{max_k}")
# variables("queen3d_1_3_1", :binary, "Queen position 1,3,1")
# ...
# variables("queen3d_1_#{max_j}_#{max_k}", :binary, "Queen position 1,#{max_j},#{max_k}")
# ...
# variables("queen3d_#{max_i}_#{max_j}_#{max_k}", :binary, "Queen position #{max_i},#{max_j},#{max_k}")
endWhen adding one or several variables outside a Problem.define block to an existing problem, we use 2 approaches:
- use a
Problem.modifyblock withvariables()as above. - use
Problem.add_variable()one by one outside of a block. In this case, there cannot be any generators
The following 2 examples should behave the same way and should produce the same result.
# Defining variables within the problem definition
problem = Problem.define do
new(name: "Adding variables to a problem", description: "Adding variables to a problem")
end
problem = Problem.modify(problem) do
variables("x", [i <- 1..4], :binary, "X variables")
variables("y", [i <- 1..4], :binary, "Y variables")
end# 2D variables
problem = Problem.define do
new(name: "Adding variables to a problem", description: "Adding variables to a problem")
variables("x", [i <- 1..4], :binary, "X variables")
end
problem = Problem.add_variable(problem, "y_1", :binary, "Y variables 1")
problem = Problem.add_variable(problem, "y_2", :binary, "Y variables 2")
problem = Problem.add_variable(problem, "y_3", :binary, "Y variables 3")
problem = Problem.add_variable(problem, "y_4", :binary, "Y variables 4")Note that we internally transform the former syntax to the latter syntax when adding variables to a problem.
constraints/2 is used to define individual constraints one by one within a Problem.define or a Problem.modify block. constraints/2 cannot accept iterators.
(Note: constraints/2 is equivalent to constraints/3, which can accept generators (see below) with an empty generator list: constraints([], expression, "description"). The empty generator list is equivalent to no generators provided.)
constraints/2 is used to define individual constraints one by one within a Problem.define or a Problem.modify block. constraints/2 cannot accept iterators.
problem = Problem.define do
new(name: "Adding variables to a problem", description: "Adding variables to a problem")
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
# constraints/2 takes a constraint and a description
constraints(queen2d_1_1 + queen2d_1_2 + queen2d_1_3 + queen2d_1_4 == 1, "One queen per row 1")
constraints(queen2d_2_1 + queen2d_2_2 + queen2d_2_3 + queen2d_2_4 == 1, "One queen per row 2")
constraints(queen2d_3_1 + queen2d_3_2 + queen2d_3_3 + queen2d_3_4 == 1, "One queen per row 3")
constraints(queen2d_4_1 + queen2d_4_2 + queen2d_4_3 + queen2d_4_4 == 1, "One queen per row 4")
endWith numerical ranges:
problem = Problem.define do
new(name: "Adding variables to a problem", description: "Adding variables to a problem")
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
# constraints/2 takes a constraint and a description
# :_ is a wildcard for any value of anindex
# In this case,
# sum(queen2d[:_][:_]) means something like:
# 1) create a list of all possible indices:
# listIndices = list of tuples (i, j) where i and j are integers between 1 and 4
# 2) create a list of all possible queen2d variables:
# listVariables = list of queen2d[i][j] where i and j are integers between 1 and 4
# 3) sum all the queen2d variables.
# The constraints/2 has no iterators like constraints/3 (see below). This statement
# creates a SINGLE constraint summing ALL the queen2d variables.
constraints(sum(queen2d[:_][:_]) == 4, "4 queens in total")
# It would generate a single constraint/2 statement like:
# constraints(
# queen2d_1_1 + queen2d_1_2 + queen2d_1_3 + queen2d_1_4 +
# queen2d_2_1 + queen2d_2_2 + queen2d_2_3 + queen2d_2_4 +
# queen2d_3_1 + queen2d_3_2 + queen2d_3_3 + queen2d_3_4 +
# queen2d_4_1 + queen2d_4_2 + queen2d_4_3 + queen2d_4_4 == 4, "4 queens in total")
endWith user supplied iterators:
iterators = %{var_1: iterator_1, var_2: iterator_2}
problem = Problem.define(modelParameters: iterators) do
new(name: "Adding variables to a problem", description: "Adding variables to a problem")
variables("val", [var_1 <- iterator_1, var_2 <- iterator_2], :binary, "Variable description")
constraints(sum(val[:_][:_]) == 4, "sum of all vals across all var_1 and var_2 possibilities")
endproblem = Problem.define do
new(name: "Adding variables to a problem", description: "Adding variables to a problem")
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
# constraints/3 takes a list of generators, a constraint and a description
# Here the generator will create one constraints/2 for each combination of the generators
constraints([i <- 1..4], sum(queen2d[i][:_]) == 1, "One queen per row")
# This will generate 4 constraints/2 like:
# constraints(sum(queen2d[1][:_]) == 1, "One queen per row 1")
# constraints(sum(queen2d[2][:_]) == 1, "One queen per row 2")
# constraints(sum(queen2d[3][:_]) == 1, "One queen per row 3")
# constraints(sum(queen2d[4][:_]) == 1, "One queen per row 4")
# In turn, the constraints/2 will generate:
# constraints(queen2d_1_1 + queen2d_1_2 + queen2d_1_3 + queen2d_1_4 == 1, "One queen per row 1")
# constraints(queen2d_2_1 + queen2d_2_2 + queen2d_2_3 + queen2d_2_4 == 1, "One queen per row 2")
# constraints(queen2d_3_1 + queen2d_3_2 + queen2d_3_3 + queen2d_3_4 == 1, "One queen per row 3")
# constraints(queen2d_4_1 + queen2d_4_2 + queen2d_4_3 + queen2d_4_4 == 1, "One queen per row 4")
endproblem = Problem.define do
new(name: "Adding variables to a problem", description: "Adding variables to a problem")
variables("queen3d", [i <- 1..2, j <- 1..2, k <- 1..3], :binary, "Queen position")
# Multiple generators
# In the following statement, we have 2 generators: i <- 1..2 and k <- 1..3.
constraints([i <- 1..2, k <- 1..3], sum(queen3d[i][:_][k]) == 1, "One queen on first axis #{i} and 3rd axis #{k}")
# The statement will create 6 constraints/2 like:
# constraints(sum(queen3d[1][:_][1]) == 1, "One queen on first axis 1 and 3rd axis 1")
# constraints(sum(queen3d[1][:_][2]) == 1, "One queen on first axis 1 and 3rd axis 2")
# constraints(sum(queen3d[1][:_][3]) == 1, "One queen on first axis 1 and 3rd axis 3")
# constraints(sum(queen3d[2][:_][1]) == 1, "One queen on first axis 2 and 3rd axis 1")
# constraints(sum(queen3d[2][:_][2]) == 1, "One queen on first axis 2 and 3rd axis 2")
# constraints(sum(queen3d[2][:_][3]) == 1, "One queen on first axis 2 and 3rd axis 3")
#
# In turn, the 6 constraints/2 will respectively generate:
# constraints(queen3d_1_1_1 + queen3d_1_2_1 == 1, "One queen on first axis 1 and 3rd axis 1")
# constraints(queen3d_1_1_2 + queen3d_1_2_2 == 1, "One queen on first axis 1 and 3rd axis 2")
# constraints(queen3d_1_1_3 + queen3d_1_2_3 == 1, "One queen on first axis 1 and 3rd axis 3")
# constraints(queen3d_2_1_1 + queen3d_2_2_1 == 1, "One queen on first axis 2 and 3rd axis 1")
# constraints(queen3d_2_1_2 + queen3d_2_2_2 == 1, "One queen on first axis 2 and 3rd axis 2")
# constraints(queen3d_2_1_3 + queen3d_2_2_3 == 1, "One queen on first axis 2 and 3rd axis 3")
endThe following 2 examples should behave the same way and should produce the same result.
# Defining constraints within the problem definition
problem = Problem.define do
# new(...)
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
# Single generator
constraints([i <- 1..4], sum(queen2d[i][:_]) == 1, "One queen per row")
constraints([j <- 1..4], sum(queen2d[:_][j]) == 1, "One queen per column")
endproblem = Problem.define do
# new(...)
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
# Single generator
constraints([i <- 1..4], sum(queen2d[i][:_]) == 1, "One queen per row")
end
problem = Problem.modify(problem) do
constraints([j <- 1..4], sum(queen2d[:_][j]) == 1, "One queen per column")
endSimilarly to Problem.add_variable(), it is possible to add constraints one at a time, outside of a Problem.define or a Problem.modify block. As with Problem.add_variable(), Problem.add_constraint() cannot contain generators.
# Create problem with variables
problem = Problem.define do
new(name: "Constraint Example", description: "Adding constraints one by one")
variables("queen2d", [i <- 1..2, j <- 1..2], :binary, "Queen position")
end
# Add constraints one by one (no generators allowed)
problem = Problem.add_constraint(problem, queen2d_1_1 + queen2d_1_2 == 1, "Row 1 constraint")
problem = Problem.add_constraint(problem, queen2d_2_1 + queen2d_2_2 == 1, "Row 2 constraint")
problem = Problem.add_constraint(problem, queen2d_1_1 + queen2d_2_1 == 1, "Column 1 constraint")
problem = Problem.add_constraint(problem, queen2d_1_2 + queen2d_2_2 == 1, "Column 2 constraint")Problem.add_constraint() returns a new problem with the constraint added.
Note: The expression queen2d_1_1 + queen2d_1_2 == 1 must use the actual names (e.g., queen2d_1_1) that were generated by the variables() call, not the pattern generator syntax like queen2d[1][:_].
problem = Problem.define do
# new(...)
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
objective(queen2d_1_1 + queen2d_1_2 + queen2d_2_1 + queen2d_2_2, :maximize)
endobjective() MUST specify the direction of the optimization (either :minimize or :maximize). No default is provided.
problem = Problem.define do
# new(...)
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
# Using sum with patterns
objective(sum(queen2d[:_][:_]), :maximize)
# It would generate a single objective/2 statement like:
objective(
queen2d_1_1 + queen2d_1_2 + queen2d_1_3 + queen2d_1_4 +
queen2d_2_1 + queen2d_2_2 + queen2d_2_3 + queen2d_2_4 +
queen2d_3_1 + queen2d_3_2 + queen2d_3_3 + queen2d_3_4 +
queen2d_4_1 + queen2d_4_2 + queen2d_4_3 + queen2d_4_4,
:maximize)
endNote that a define block can only contain one objective function.
problem = Problem.define do
# new(...)
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
# Using sum with patterns
objective(sum(queen2d[:_][:_]), :maximize)
# Add another objective
objective(sum(queen2d[:_][:_]), :maximize) # <-- This triggers an error even if the objective is identical.>
endBut an objective can always be set outside a Problem.define block with Problem.set_objective(). If there was a previously existing objective, only a warning is triggered, not an error.
problem = Problem.define do
new(name: "Adding variables to a problem", description: "Adding variables to a problem")
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
end
# First definition of an objective (none specified in the `Problem.define()` block)
problem = Problem.set_objective(
problem,
queen2d_1_1 + queen2d_1_2 + queen2d_1_3 + queen2d_1_4 +
queen2d_2_1 + queen2d_2_2 + queen2d_2_3 + queen2d_2_4 +
queen2d_3_1 + queen2d_3_2 + queen2d_3_3 + queen2d_3_4 +
queen2d_4_1 + queen2d_4_2 + queen2d_4_3 + queen2d_4_4,
:maximize
) # <-- This triggers a warning about redefining the problem objective
problem = Problem.set_objective(
problem,
sum(queen2d[:_][:_]), # <-- ERROR: Not allowed because iterators are not allowed outside of Problem.define or Problem.modify
:maximize
)Problem.set_objective() returns a new problem with the objective set. It requires :minimize or :maximize to be specified as an argument.
Problem.set_objective() cannot include any generator.
The following 3 examples should be supported and should produce the same result:
problem = Problem.define do
# new(...)
variables("qty", [food <- food_names], :continuous, "Amount of food")
# Using sum with patterns
objective(sum(qty[:_]), :minimize)
endproblem = Problem.define do
# new(...)
variables("qty", [food <- food_names], :continuous, "Amount of food")
# Using for comprehensions
objective(sum(for food <- food_names, do: qty[food]), :minimize)
endproblem = Problem.define do
# new(...)
variables("qty", [food <- food_names], :continuous, "Amount of food")
end
# Using for comprehensions
problem = Problem.set_objective(
problem,
sum(for food <- food_names, do: qty[food]),
:minimize
)In the following example, the second definition of "queen2d" should trigger an error and should not be allowed. An error message should be generated indicating that that redefining a variable is not allowed and the problem should not be created.
problem = Problem.define do
# new(...)
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position") # <--- THIS TRIGGERS AN ERROR>
endNote that the following would be OK as the final generated variable names have different indices (and actual name in the problem definition).
problem = Problem.define do
# new(...)
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
variables("queen2d", [i <- 5..8, j <- 5..8], :binary, "Queen position") # <--- THIS DOES NOT TRIGGER AN ERROR
variables("queen2d", [i <- 1..4, j <- 5..8], :binary, "Queen position") # <--- THIS DOES NOT TRIGGER AN ERROR
variables("queen2d", [i <- 5..8, j <- 1..4], :binary, "Queen position") # <--- THIS DOES NOT TRIGGER AN ERROR
defmodule Dantzig.DSL.SimpleGeneratorTest do
@moduledoc """
The syntax in this module is _golden_.
It should be considered the canonical way to write generator syntax.
"""
test "Simple generator syntax" do
params = %{food_names: ["bread", "milk"]}
problem =
Problem.define(model_parameters: params) do
new(name: "Simple Test", description: "Test generator syntax")
variables("qty", [food <- food_names], :continuous, "Amount of food")
end
assert length(problem.variable_defs) == 2
assert Map.has_key?(problem.variable_defs, "qty_bread")
assert Map.has_key?(problem.variable_defs, "qty_milk")
end
test "Generator with objective" do
params = %{food_names: ["bread", "milk"]}
problem =
Problem.define(model_parameters: params) do
new(name: "Simple Test", description: "Test generator with objective")
variables("qty", [food <- food_names], :continuous, "Amount of food")
objective(sum(for food <- food_names, do: qty[food]), :minimize)
end
assert problem.direction == :minimize
assert problem.objective != nil
end
end# N-Queens problem using the new DSL
require Dantzig.Problem, as: Problem
require Dantzig.Problem.DSL, as: DSL
# Create the problem
problem2d =
Problem.define do
new(
name: "N-Queens",
description: "Place N queens on an N×N chessboard so that no two queens attack each other."
)
# Add binary variables for queen positions (4x4 board)
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
# Add constraints: one queen per row
constraints([i <- 1..4], sum(queen2d[i][:_]) == 1, "One queen per row")
# Add constraints: one queen per column
constraints([j <- 1..4], sum(queen2d[:_][j]) == 1, "One queen per column")
# Set objective (squeeze as many queens as possible)
objective(sum(queen2d[:_][:_]), :maximize)
endvariables("name", :type, "description")- single variable (variables/3)variables("name", [generators], :type, "description")- multiple variables (variables/4)constraints(expression, "description")- single constraint (constraints/2)constraints([generators], expression, "description")- multiple constraints (constraints/3)objective(expression, :direction)- objective function (objective/2)
Problem.add_variable(problem, "name", :type, "description")- single variableProblem.add_constraint(problem, expression, "description")- single constraintProblem.set_objective(problem, expression, :direction)- objective function
- Inside blocks: Can use generators
[i <- 1..4] - Outside blocks: Cannot use generators - must be singular
- Format:
[variable <- list]or[variable <- generator] - Multiple dimensions:
[i <- 1..4, j <- 1..4]or[var_1 <- generator_1, var_2 <- generator_2] - Provided model parameters: Must be supported (e.g.,
[food <- food_names]) - food_names should be identified as an unknown symbol and looked up in the model parameters - Literal lists: Must be supported (e.g.,
[food <- ["bread", "milk"]])
:_ is a special symbol that means "iterate through all values".
If a symbol is used instead of :_, it means "for each value of the symbol generate a statement".
Bracket notation (preferred for indexed access):
x[i]— access variablexat indexi(same key as used in the generator)assign[worker][task]— access 2D variable at keysworker,taskx[i][:_]— wildcard: all values of the second dimension, for fixedix[:_]— wildcard: all values of the (1D) variable
Bracket notation uses the generator value directly as the key — the same value used in variables("x", [i <- 1..n], ...) is the key for x[i]. This allows a unified syntax for both variables and constants.
Parenthesis notation (alternative, also supports wildcards):
queen2d[i][:_]— "for each value of i, all values of the second dimension"queen2d[:_][j]— "all values of the first dimension, for each j"queen2d[:_][:_]— "all values across both dimensions"
Both notations support wildcards. Bracket notation is preferred for its uniformity with constant access.
- Pattern sums:
sum(queen2d[i][:_])means "create a sum statement for each value of the first iterator (i) where all the values of the second iterator (j) are summed". - For comprehensions:
sum(for food <- food_names, do: qty[food])means "create a sum statement for each value of the food iterator (food) where the value of theqty's variables are summed". - All variables:
sum(queen2d[:_][:_])means "create a single sum statement where all the values of thequeen2d's variables (every cross product of the first iterator (i) and the second iterator (j)) are summed".
The DSL supports several pattern functions that expand expressions with wildcards:
- Pattern sums:
sum(queen2d[i][:_])expands toqueen2d_1_1 + queen2d_1_2 + queen2d_1_3 + queen2d_1_4for each value ofi - All variables:
sum(queen2d[:_][:_])expands to a single sum of all queen2d variables - Generator sums:
sum(for food <- food_names, do: qty[food])expands toqty_bread + qty_milk
- Pattern max:
max(queen2d[i][:_])expands tomax(queen2d_1_1, queen2d_1_2, queen2d_1_3, queen2d_1_4)for each value ofi - All variables:
max(queen2d[:_][:_])expands tomax(queen2d_1_1, queen2d_1_2, ..., queen2d_4_4)
- Pattern min:
min(queen2d[i][:_])expands tomin(queen2d_1_1, queen2d_1_2, queen2d_1_3, queen2d_1_4)for each value ofi - All variables:
min(queen2d[:_][:_])expands tomin(queen2d_1_1, queen2d_1_2, ..., queen2d_4_4)
- Pattern count:
count(queen2d[i][:_])expands toqueen2d_1_1 + queen2d_1_2 + queen2d_1_3 + queen2d_1_4for each value ofi(same as sum for binary variables) - All variables:
count(queen2d[:_][:_])expands to sum of all queen2d variables
Note: max() and min() functions require linearization techniques for linear optimization solvers.
- Static:
"One queen per row" - Dynamic with interpolation:
"One queen per row #{i}"expands to:"One queen per row 1"for i=1"One queen per row 2"for i=2"One queen per row 3"for i=3"One queen per row 4"for i=4
- Multiple variable interpolation:
"One queen on axis #{i} and #{k}"expands to:"One queen on axis 1 and 1"for i=1, k=1"One queen on axis 1 and 2"for i=1, k=2- etc.
Constraint definitionas and objectives can access named constants and named enumerated constants. The constants are passed via the model_parameters dictionary argument of the Problem.define() or Problem.modify() macros.
A Named constants is a single value (no index) passed as a single key in the model_parameters dictionary. Here are a list of valid examples.
problem = Problem.define(model_parameters: %{max_queens: 8}) do
new(name: "Problem Name", description: "Problem description")
variables("queen2d", [i <- 1..max_queens, j <- 1..max_queens], :binary, "Queen position")
end# Example 1
problem = Problem.define(model_parameters: %{multiplier: 7.0}) do
new(name: "Problem Name", description: "Problem description")
variables("x1", :continuous, "X1")
variables("x2", :continuous, "X2")
# The DSL identifies variables (here `x1` and `x2`) as variables, and constants (here `multiplier`)
constraints( x2 + multiplier * x1 <= 10, "Max constraint" )
end# Example 2
problem = Problem.define(model_parameters: %{multiplier_1: 7.0, multiplier_2: -5.0}) do
new(name: "Problem Name", description: "Problem description")
variables("x1", :continuous, "X1")
variables("x2", :continuous, "X2")
# The DSL identifies variables (here `x1` and `x2`) as variables, and constants (here `multiplier`)
constraints( multiplier_1 * x1 - x2 * multiplier_2 <= 10, "Max constraint" )
endA Named enumerated constant is an indexed set of values passed in the model_parameters dictionary. The index is either integer-like (like in the case of a map with integer keys), or more general (as in a map with string or atom keys). The index can be multidimensional.
Important: Generator variables (e.g.
iin[i <- 1..4]) produce the exact integer values from the range. Use a map with matching integer keys so thatmultiplier[i]resolves correctly for any range, including non-zero-based ranges like2..99or(-7)..7.
# Example 1: indexed constants - 1 dimension, range 1..4
# Use a map with integer keys matching the generator range.
# Bracket notation x[i] is used uniformly for both variables and constants.
problem = Problem.define(model_parameters: %{multiplier: %{1 => 4.0, 2 => 5.0, 3 => 6.0, 4 => 7.0}}) do
new(name: "Problem Name", description: "Problem description")
variables( "x", [i <- 1..4], :continuous, "Xs")
constraints( sum( for i <- 1..4, do: x[i] * multiplier[i]) <= 10, "Max constraint" )
end# Example 2: 2D indexed constants, ranges 1..2 and 1..3
# Use a nested map with integer keys for each dimension
problem = Problem.define(model_parameters: %{matrix: %{1 => %{1 => 4.0, 2 => 5.0, 3 => 6.0}, 2 => %{1 => 7.0, 2 => 8.0, 3 => 9.0}}}) do
new(name: "Problem Name", description: "Problem description")
variables( "x", [i <- 1..2, j <- 1..3], :continuous, "Xs")
constraints( sum( for i <- 1..2, j <- 1..3, do: x[i][j] * matrix[i][j]) <= 10, "Max constraint" )
end# For 0-based ranges, plain lists work because Elixir list access is 0-based
problem = Problem.define(model_parameters: %{multiplier: [4.0, 5.0, 6.0, 7.0]}) do
variables( "x", [i <- 0..3], :continuous, "Xs")
constraints( sum( for i <- 0..3, do: x[i] * multiplier[i]) <= 10, "Max constraint" )
end# Multidimensional indexed named constants
# Cost matrix: cost[worker][task] = cost of assigning worker to task
cost_matrix = %{
"Alice" => %{"Task1" => 2, "Task2" => 3, "Task3" => 1},
"Bob" => %{"Task1" => 4, "Task2" => 2, "Task3" => 3},
"Charlie" => %{"Task1" => 3, "Task2" => 1, "Task3" => 4}
}
workers = Enum.keys(cost_matrix)
# Tasks is the set of all possible tasks across all workers
tasks = Enum.flat_map(cost_matrix, fn {_, v} -> Map.keys(v) end) |> MapSet.new()
problem = Problem.define(model_parameters: %{cost: cost_matrix, workers: workers, tasks: tasks}) do
new(name: "Assignment Problem", description: "Assign workers to tasks at minimum cost")
variables("assign", [worker <- workers, task <- tasks], :binary, "Assignment variable")
constraints(
[task <- tasks],
sum(for worker <- workers, do: assign[worker][task]) == 1,
"Each task assigned to exactly one worker #{task}"
)
constraints(
[worker <- workers],
sum(for task <- tasks, do: assign[worker][task]) <= 1,
"Each worker assigned to at most one task #{worker}"
)
# Objective: minimize total cost — bracket notation for both assign (variable) and cost (constant)
objective(
sum(
for worker <- workers,
task <- tasks,
do: assign[worker][task] * cost[worker][task]
),
:minimize
)
end# Multidimensional indexed named constants in both constraints and objective
# Cost matrix: cost[worker][task] = cost of assigning worker to task
cost_matrix = %{
"Alice" => %{"Task1" => 2, "Task2" => 3, "Task3" => 1},
"Bob" => %{"Task1" => 4, "Task2" => 2, "Task3" => 3},
"Charlie" => %{"Task1" => 3, "Task2" => 1, "Task3" => 4}
}
workers = Enum.keys(cost_matrix)
# Tasks is the set of all possible tasks across all workers
tasks = Enum.flat_map(cost_matrix, fn {_, v} -> Map.keys(v) end) |> MapSet.new()
problem = Problem.define(model_parameters: %{cost: cost_matrix, workers: workers, tasks: tasks}) do
new(name: "Assignment Problem", description: "Assign workers to tasks at minimum cost")
variables("assign", [worker <- workers, task <- tasks], :binary, "Assignment variable")
constraints(
[task <- tasks],
sum(for worker <- workers, do: assign[worker][task]) == 1,
"Each task assigned to exactly one worker #{task}"
)
constraints(
[worker <- workers],
sum(for task <- tasks, do: assign[worker][task]) <= 1,
"Each worker assigned to at most one task #{worker}"
)
constraints(
sum(
for worker <- workers,
task <- tasks,
do: assign[worker][task] * cost[worker][task]) >= 0,
"The overall cost is positive"
)
# Objective: minimize total cost — bracket notation for both variables and constants
objective(
sum(
for worker <- workers,
task <- tasks,
do: assign[worker][task] * cost[worker][task]
),
:minimize
)
endThe DSL implementation MUST support:
- Model parameter lookup in generators:
[food <- food_names]- food_names should be identified as an unknown symbol and looked up in the model parameters. If a symbol is not found in the model parameters, an error should be raised. - Literal lists in generators:
[food <- ["bread", "milk"]] - Literal dictionaries in generators:
[k, v <- [{"bread", 1}, {"milk", 2}]] - Range syntax:
[i <- 1..4, j <- 1..4] - Pattern matching in variable access:
queen2d[i][:_] - Pattern functions with wildcards:
sum(queen2d[i][:_]),max(queen2d[i][:_]),min(queen2d[i][:_]),count(queen2d[i][:_]) - For comprehensions in objectives:
sum(for food <- food_names, do: qty[food]) - Variable interpolation in constraint descriptions:
"One queen per row #{i}" - Constraint deduplication and naming clash warnings
- Variable range validation for constraints
The following are NOT currently supported or should be clearly documented as limitations:
- Nested generators beyond the documented patterns
- Complex expressions in generator lists that cannot be evaluated at compile time
- Dynamic constraint names that cannot be resolved at compile time
Problem.add_variable()- no generators allowedProblem.add_constraint()- no generators allowedProblem.set_objective()- no generators allowed
Those limitations are due to the semantics of Elixir macros: it is not possible to provide generators as arguments to macros. Problem.add_variables(problem, [i <- 1..4], "x", :binary, "Description") cannot be implemented because it would require a macro to accept a list of generators as an argument.
If a constraint with the same name is added multiple times, an error is issued:
problem = Problem.define do
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
constraints([i <- 1..4], sum(queen2d[i][:_]) == 1, "One queen per row")
constraints([i <- 1..4], sum(queen2d[i][:_]) == 1, "One queen per row") # Error: duplicate constraint
endIf constraint ranges don't match variable ranges, an error is raised:
problem = Problem.define do
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
# ERROR: constraint range (1..5) doesn't match variable range (1..4)
constraints([i <- 1..5], sum(queen2d[i][:_]) == 1, "One queen per row")
endIf a variable referenced in a constraint hasn't been declared, an error is raised:
problem = Problem.define do
variables("queen2d", [i <- 1..4, j <- 1..4], :binary, "Queen position")
# ERROR: queen3d variable not declared
constraints([i <- 1..4], sum(queen3d[i][:_]) == 1, "One queen per row")
endAll syntax patterns in this reference must:
- Compile without errors
- Execute successfully
- Produce expected results
- Be covered by tests
Problem: error: undefined variable "i"
Cause: Using generator variables outside of Problem.define or Problem.modify blocks.
Solution: Move the code inside a Problem.define or Problem.modify block, or use imperative syntax with actual variable names. Generators are not available outside of Problem.define or Problem.modify blocks.
# ❌ Wrong - generator outside block
problem = Problem.add_constraint(problem, queen2d[i][:_] == 1, "Constraint")
# ✅ Correct - inside block
problem = Problem.define do
constraints([i <- 1..4], sum(queen2d[i][:_]) == 1, "Constraint")
end
# ✅ Correct - imperative with actual names
problem = Problem.add_constraint(problem, queen2d_1_1 + queen2d_1_2 == 1, "Constraint")Problem: ** (FunctionClauseError) no function clause matching in Problem.new/1
Cause: Passing wrong arguments to Problem.new.
Solution: Use keyword arguments.
# ❌ Wrong
problem = Problem.new("My Problem")
# ✅ Correct
problem = Problem.define do
new(name: "My Problem", description: "Description")
endProblem: Constraint names not interpolating correctly (e.g., "One queen per main diagonal" instead of "One queen per diagonal 1").
Cause: Missing variable placeholders in constraint descriptions.
Solution: Include variable placeholders in descriptions.
# ❌ Wrong - no placeholder
constraints([i <- 1..4], sum(queen2d[i][:_]) == 1, "One queen per diagonal")
# ✅ Correct - with placeholder
constraints([i <- 1..4], sum(queen2d[i][:_]) == 1, "One queen per diagonal #{i}")Use IO.inspect(problem.variables) to see actual variable names generated.
Use IO.inspect(problem.constraints) to see generated constraints.
Start with basic examples before adding complexity:
# Start simple
problem = Problem.define do
new(name: "Test", description: "Test")
variables("x", :continuous, "Variable")
constraints(x >= 0, "Non-negative")
endOld syntax:
problem = Problem.new(name: "Test")
problem = Problem.add_variables(problem, [i <- 1..3], "x", :continuous)
problem = Problem.add_constraints(problem, [i <- 1..3], x[i] >= 0, "Constraint")New syntax:
problem = Problem.define do
new(name: "Test", description: "Test")
variables("x", [i <- 1..3], :continuous, "Variable")
constraints([i <- 1..3], x[i] >= 0, "Constraint")
end# ✅ Good
variables("queen_position", [i <- 1..8, j <- 1..8], :binary, "Queen at position (i,j)")
# ❌ Poor
variables("q", [i <- 1..8, j <- 1..8], :binary, "q")# ✅ Good - descriptive and unique
constraints([i <- 1..8], sum(queen_position[i][:_]) == 1, "One queen per row #{i}")
# ❌ Poor - not unique
constraints([i <- 1..8], sum(queen_position[i][:_]) == 1, "Row constraint")# ✅ Good - logical grouping
problem = Problem.define do
new(name: "Production Planning", description: "Production planning problem")
# Production variables
variables("produce", [product <- products, month <- months], :continuous, "Production amount")
# Inventory variables
variables("inventory", [product <- products, month <- months], :continuous, "Inventory level")
# Constraints
constraints([product <- products], sum(produce[product][:_]) >= demand(product), "Demand constraint")
end# ✅ Good - parameterized
params = %{products: ["A", "B"], months: 1..12}
problem = Problem.define(model_parameters: params) do
variables("produce", [product <- products, month <- months], :continuous, "Production")
end
# ❌ Poor - hardcoded
problem = Problem.define do
variables("produce", [product <- ["A", "B"], month <- 1..12], :continuous, "Production")
endThe DSL is designed to generate efficient code at compile time:
- Variable generation: All variable combinations are generated at compile time
- Constraint generation: All constraint combinations are generated at compile time
- Pattern expansion:
sum(),max(),min()functions are expanded at compile time
- Variable storage: Each generated variable is stored as a separate entry
- Constraint storage: Each generated constraint is stored as a separate entry
- Large problems: For problems with many variables/constraints, consider using model parameters to control size
# ✅ Good - binary for yes/no decisions
variables("assign", [task <- tasks, worker <- workers], :binary, "Assignment")
# ❌ Poor - continuous for binary decisions
variables("assign", [task <- tasks, worker <- workers], :continuous, "Assignment")# ✅ Good - simple constraints
constraints([task <- tasks], sum(assign[task][:_]) == 1, "One worker per task")
# ❌ Poor - complex nested constraints (when possible)
constraints([task <- tasks], sum(for worker <- workers, do: assign[task][worker] * skill(worker)) >= 1, "Skilled worker")# ✅ Good - parameterized size
params = %{board_size: 8}
problem = Problem.define(model_parameters: params) do
variables("queen", [i <- 1..board_size, j <- 1..board_size], :binary, "Queen position")
end
# ❌ Poor - hardcoded size
problem = Problem.define do
variables("queen", [i <- 1..8, j <- 1..8], :binary, "Queen position")
end- v1.0 - Initial reference based on golden test file and nqueens example
- Future changes - Must be explicitly approved and documented
REMINDER: This is the GOLDEN REFERENCE. Do not modify without explicit approval.