Enum, Int and Intervals#
Typed finite-domain variables on top of Boolean literals:
hermax.model.EnumVarhermax.model.IntVar
They emit domain constraints and decode back to typed values.
Enum Variables#
Create an enum:
color = model.enum("color", choices=["red", "green", "blue"])
Enum variables expose equality to a choice label:
model &= (color == "red")
Enum subset membership#
EnumVar.is_in(...) is a fast CNF helper for common subset-membership tests:
model &= shift_type.is_in(["morning", "day"])
This returns a Clause over the underlying choice literals
without introducing auxiliary variables.
Non-nullable enum -> Exactly one choice must be selected
Nullable enum -> At most one choice, with
Nonemeaning no choice
Nullable enums are useful for optional assignment modelling.
Enum-Enum Comparisons#
Enum variables support:
enum1 == enum2(returnsClauseGroup)enum1 != enum2(returnsClauseGroup)
The choices must match.
Integer Variables#
Create a bounded integer in range [lb, ub]:
x = model.int("x", lb=0, ub=10)
IntVar uses a ladder / order encoding internally. This is part of the
model contract.
Examples:
lb=0, ub=10-> values0..10lb=3, ub=7-> values3..7
x = model.int("x", lb=3, ub=9)
assert x.lower_bound() == 3
assert x.upper_bound() == 9
These return declared bounds. upper_bound() returns ub.
Bounded Int comparisons#
Scalar comparisons return Boolean literals:
model &= (x <= 4)
model &= (x > 1)
model &= (x == 3)
These literals are exact and tied to the ladder encoding.
The model implements exact integer relations across int variables:
x == yx != yx <= yx < yx >= yx > y
which return ClauseGroup.
Boolean indicators can be tied to native integer relations:
b = model.bool("b")
model &= (b == (x <= y))
This encodes full equivalence:
Similarly for <, >=, and >.
Equality is also supported:
model &= (b == (x == y))
This uses ladder relation clauses in both directions, with no PB/Card encoder dispatch being used for this reification shape.
Performance notes#
These relations are implemented to respect the ladder encoding:
x == yis linear in the domain cut spanx <= y/x >= yare linear in the domain cut spanx != yis linear in overlap size and introduces no new variables
This avoids naive quadratic approaches.
Ladder constraints#
The ladder encoding enables constraints that compile to small CNF without PB/Card encoders.
Distance bound: |x - y| <= D#
Use hermax.model.IntVar.distance_at_most():
model &= x.distance_at_most(y, 3)
This compiles to ladder-threshold implications and introduces no auxiliary variables. It is linear in the relevant threshold cuts and mostly binary clauses.
Edge cases:
D = 0behaves like equality (x == y)D < 0raisesValueError
Skipping values in domains: forbid_value()#
Use hermax.model.IntVar.forbid_value() to remove a single value from a
domain:
for v in [4, 7, 9]:
model &= x.forbid_value(v)
This exploits the ladder “cliff” pattern for an exact value and compiles to a tiny clause, where:
Interior values usually produce a binary clause
Boundary values often collapse to a unit clause
Values outside the declared domain return a tautological no-op clause
Skipping large value ranges: forbid_interval()#
Use hermax.model.IntVar.forbid_interval() to remove an entire closed
interval from the domain:
model &= x.forbid_interval(200, 800)
This uses the ladder jump implication:
and compiles to a single small clause after domain clipping.
Use this instead of many forbid_value() calls for large gaps.
Range membership indicator: in_range()#
Use hermax.model.IntVar.in_range() to build a Boolean literal
representing inclusive range membership:
afternoon = task_end.in_range(13, 17)
model &= afternoon
The returned object is a Literal, so it composes with
PB/cardinality constraints (for example, histogram/binning counts):
bins = [t.in_range(0, 12) for t in end_times]
model &= (sum(bins) <= 10)
x.in_range(start, end) represents:
Internally, this is the ladder predicate:
and the returned indicator literal is defined lazily (via deferred helper clauses), so constructing it does not immediately mutate the model.
Using IntVar in PB expressions#
IntVar can be lifted into PB expressions:
model &= (a + x <= 7)
model &= (a + 2 * x <= 7)
model &= (a - x <= 0)
Piecewise Step Functions as PB#
IntVar can also be mapped to a step function as a lazy
PBExpr:
cost = x.piecewise(
base_value=10,
steps={
10: 25,
50: 100,
},
)
model &= (cost <= budget)
steps is a mapping {threshold: new_value} interpreted in sorted
threshold order. For every threshold t:
If
x >= t, the piecewise value becomessteps[t]Otherwise, the previous value remains active
Formally, for a sorted threshold sequence t_1 < t_2 < \dots < t_m and
piecewise values v_0 (the base value), v_1, \dots, v_m, the returned
expression is represented as:
where [x >= t_i] is the ladder-threshold literal for the integer variable.
This:
Burns no new variables
Emits no clauses
Reuses the existing ladder literals of
xComposes into later PB constraints
The result is just a PBExpr.
Non-monotonic step functions#
The function values do not need to be monotone. If a later step decreases the value, the corresponding delta is negative and is handled by the existing PB normalization pipeline when the expression is compiled [1].
For example:
# 50 -> 80 at x>=2, then back down to 30 at x>=4
penalty = x.piecewise(base_value=50, steps={2: 80, 4: 30})
Boundary handling#
Thresholds are clipped against the integer domain:
thresholds
<= lbare folded into the effective base valuethresholds
>= ubare ignored (never active)zero-delta steps are elided
IntVar in objective#
You can add an integer variable to the weighted objective:
x = model.int("x", lb=0, ub=10)
model.obj[3] += x
This lowers to O(n) soft clauses using ladder bits (one weighted soft unit
per threshold bit) and is equivalent to minimizing the integer value.
obj[w] += int_var requires int_var.lb >= 0.
IntVar scaling (multiplication)#
The model also provides an eager way to scale an integer by a positive constant into a new integer variable:
y = model.scale(x, 3)
model &= (y <= 12)
This uses direct ladder-threshold ties (no PB/Card encoder).
x * 3 remains the algebraic/PB syntax and produces a PBExpr. The
compiler may still optimize PB comparisons containing scaled integers (for
example 2*x + 3*y <= 17) using internal ladder fast paths.
Constant division via //#
IntVar supports floor division by a positive integer constant:
q = x // 10
model &= (q <= 3)
x // d returns a lazy derived integer expression (DivExpr). It does
not mutate the model immediately.
If you want the explicit eager API, use:
q = model.floor_div(x, 10)
Both forms are equivalent. The lazy form is realized automatically when used in
model &= ..., model.obj[...] +=, or inside a PB expression compiled in
Stage 2.
Why this is fast#
For positive d, if q = x // d, then for every quotient threshold m:
The model compiles constant division using direct threshold equivalences, without PB/cardinality encoders. Divisors must be strictly positive integers.
Lazy array indexing via @#
IntVar also supports lazy array-indexing for arrays of integer constants:
costs = [10, 100, 1000]
w = model.int("w", lb=0, ub=3)
model &= (costs @ w <= 50)
The expression costs @ w creates a lazy descriptor. On comparison, the
model unrolls the index domain and compiles a ClauseGroup.
Supported forms:
integer constant
another
IntVar
Variable index on IntVector#
IntVector also supports variable indexing:
vals = model.int_vector("vals", length=3, lb=0, ub=10)
idx = model.int("idx", 0, 3)
a = model.int("a", 0, 10)
model &= (vals[idx] == a)
Equivalence rule:
for OP in ==, !=, <=, <, >=, >.
Current constraints:
idx.lb >= 0vector length covers
[idx.lb, idx.ub]
For element equality/ordering shapes, this compiles to conditional clauses and avoids generic PB/Card dispatch.
Intervals#
The modelling layer also provides a lightweight scheduling object:
hermax.model.IntervalVar
Create intervals with a fixed duration and a bounded horizon:
task_a = model.interval("A", start=0, duration=5, end=24)
task_b = model.interval("B", start=0, duration=3, end=24)
model &= task_a.ends_before(task_b)
model &= task_a.no_overlap(task_b)
The interval constructor uses:
start= earliest start time (inclusive)end= latest end time (inclusive)duration= positive fixed duration
Internally, an interval owns two hermax.model.IntVar objects:
interval.startinterval.end
and the model enforces:
interval.end == interval.start + duration
Methods#
Currently implemented:
ends_before(other)-> enforcesself.end <= other.startstarts_after(other)-> enforcesself.start >= other.endno_overlap(other)-> disjunctive non-overlap (either orientation)
no_overlap allows touching intervals (i.e. end == other.start is valid).
Performance#
The interval identity end == start + duration is not compiled through
the generic PB/Cardinality encoder pipeline.
Instead, because both endpoints use the same ladder width and the model welds the endpoint ladders with threshold-bit equivalences:
start_t[i] <-> end_t[i]for each ladder positioni
This yields:
O(n) binary clauses (where
nis the ladder width)zero auxiliary variables