Advanced Modelling Examples#

This page is a continuation of Modelling Examples. For the more basic modelling examples, start there first.

Conventions#

  • Hermax solves minimization problems.

  • model.obj[w] += expr minimizes the numeric value of w * expr.

  • IntVar values are bounded over closed domains [lb, ub].

  • x >= k in the implementation corresponds to a ladder threshold literal.

Example 13: Piecewise#

Use this pattern when a cost or penalty depends on an integer variable through tiers, and you want to use that cost in both constraints and the objective.

Efficiency#

The piecewise expression is compiled as a weighted sum over the existing ladder literals of the integer variable. It does not create a proxy integer variable for the mapped cost. In practice, this removes a large amount of unnecessary encoding work in budget models.

New primitives#

  • hermax.model.IntVar.piecewise()

  • direct objective lowering of linear expressions (model.obj[w] += expr)

Model#

The tariff is a step function of the load. The model constrains the tariff by budget and then minimizes the combination of load and tariff.

\[\begin{split}\begin{aligned} \text{Variable:}\quad & load \in \{0,\dots,11\} \\ \text{Tariff:}\quad & tariff(load)= \begin{cases} 10 & 0 \le load < 4 \\ 25 & 4 \le load < 8 \\ 60 & 8 \le load < 12 \end{cases} \\ \text{Hard budget cap:}\quad & tariff(load) \le 25 \\ \text{Objective:}\quad & \min \; load + tariff(load) \end{aligned}\end{split}\]

Code#

examples/model/13_piecewise_pricing_budget.py#
from hermax.model import Model

m = Model()

load = m.int("load", lb=0, ub=12)

# Piecewise tariff over load:
# [0,4) -> 10, [4,8) -> 25, [8,12) -> 60
tariff = load.piecewise(base_value=10, steps={4: 25, 8: 60})

m &= (tariff <= 25)

m.obj[1] += load
m.obj[1] += tariff

r = m.solve()
assert r.ok

print("load =", r[load])
print("cost =", r.cost)

Output#

The optimizer keeps the load in the cheapest pricing tier while satisfying the budget cap.

$ python examples/model/13_piecewise_pricing_budget.py
load = 0
cost = 10

Example 14: Histogram Binning#

Use this when you need counts over integer buckets and want those counts in constraints or the objective.

Efficiency#

in_range() returns a boolean indicator for an interval, which can be summed directly, which is very efficient.

New primitives#

  • hermax.model.IntVar.in_range()

  • Decoding Python list/tuple collections from the solution object

Model#

Each bin indicator represents inclusive interval membership, and histogram constraints are plain sums of booleans.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & t_i \in \{0,\dots,23\} \\ \text{Bins:}\quad & m_i = [0 \le t_i \le 11],\; a_i = [12 \le t_i \le 17],\; e_i = [18 \le t_i \le 23] \\ \text{Histogram constraints:}\quad & \sum_i m_i = 3,\quad \sum_i a_i = 2,\quad \sum_i e_i = 1 \end{aligned}\end{split}\]

Code#

examples/model/14_histogram_binning.py#
from hermax.model import Model

m = Model()

end_times = m.int_vector("end", length=6, lb=0, ub=24)

for i, v in enumerate([2, 5, 11, 13, 17, 20]):
    m &= (end_times[i] == v)

morning = [t.in_range(0, 11) for t in end_times]
afternoon = [t.in_range(12, 17) for t in end_times]
evening = [t.in_range(18, 23) for t in end_times]

m &= (sum(morning) == 3)
m &= (sum(afternoon) == 2)
m &= (sum(evening) == 1)

r = m.solve()
assert r.ok

print("end_times =", r[end_times])
print("morning_count =", sum(1 for b in r[morning] if b))
print("afternoon_count =", sum(1 for b in r[afternoon] if b))
print("evening_count =", sum(1 for b in r[evening] if b))

Output#

The output shows the chosen completion times, and the three bin counts match the target histogram exactly.

$ python examples/model/14_histogram_binning.py
end_times = [2, 5, 11, 13, 17, 20]
morning_count = 3
afternoon_count = 2
evening_count = 1

Example 15: Domain Holes + Distance Bound#

Use this when the domain itself carries structure and you want to encode that structure instead of routing through generic PB constraints.

Efficiency#

These constraints compile to very small sets of clauses. They are useful when the domain is large but the forbidden structure is simple.

New primitives#

  • hermax.model.IntVar.forbid_interval()

  • hermax.model.IntVar.forbid_value()

  • hermax.model.IntVar.distance_at_most()

Model#

This model is constraint heavy and uses ladder operations.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & x,y \in \{0,\dots,29\} \\ \text{Holes:}\quad & x \notin [10,20],\quad y \neq 11 \\ \text{Proximity:}\quad & |x-y| \le 2 \\ \text{Objective:}\quad & \min \; -score(x) - score(y) \end{aligned}\end{split}\]

where score is expressed with piecewise(...) and lowered as a target-preference expression in the objective.

Code#

examples/model/15_domain_holes_and_distance.py#
from hermax.model import Model

m = Model()

x = m.int("x", lb=0, ub=30)
y = m.int("y", lb=0, ub=30)

m &= x.forbid_interval(10, 20)
m &= y.forbid_value(11)

m &= x.distance_at_most(y, 2)

score_x = sum((20 - abs(k - 16)) * (x == k) for k in range(31))
score_y = sum((20 - abs(k - 11)) * (y == k) for k in range(31))
m.obj[1] += -(score_x + score_y)

r = m.solve()
assert r.ok

print("x =", r[x])
print("y =", r[y])
print("distance =", abs(r[x] - r[y]))

Output#

The solution satisfies the distance bound and avoids both domain holes.

$ python examples/model/15_domain_holes_and_distance.py
x = 9
y = 10
distance = 1

Solution#

_images/domain_holes_distance_solution.svg

Example 16: Division + Scaling#

Use this when your model has coarse units or derived quantities and you still want to write natural algebraic constraints.

Efficiency#

The compiler recognizes these arithmetic forms and compiles them with ladder fast paths instead of generic PB/Card encoders. This can remove a large amount of auxiliary-variable bloat in scheduling/resource models.

New primitives#

  • x // d (division expression)

  • x.scale(c) (scaling/multiplication expression)

Model#

The example combines quotient and scaled forms in ordinary algebraic syntax.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & x \in [0,30),\ y \in [0,50) \\ q &= \lfloor x/4 \rfloor \\ s &= 3x \\ \text{Hard constraints:}\quad & s + 2 \le y,\quad q = 3 \\ \text{Objective:}\quad & \min y \end{aligned}\end{split}\]

Code#

examples/model/16_lazy_div_scale_affine.py#
from hermax.model import Model

m = Model()

x = m.int("x", lb=0, ub=30)
y = m.int("y", lb=0, ub=50)

q = x // 4
s = x.scale(3)

m &= (s + 2 <= y)
m &= (q == 3)

m.obj[1] += y

r = m.solve()
assert r.ok

print("x =", r[x])
print("q = x // 4 =", r[q])
print("s = 3*x =", r[s])
print("y =", r[y])

Output#

$ python examples/model/16_lazy_div_scale_affine.py
x = 12
q = x // 4 = 3
s = 3*x = 36
y = 38

Example 17: Big-M#

Use this for the classic Opertions Research (OR) pattern [if boolean is on, integer bound shifts]. This appears in optional resources, setup-dependent capacities, truck/worker activation, and many Big-M formulations.

Efficiency#

This pattern is compiled as a pair of conditional ladder bounds instead of a generic PB encoding. That means fewer clauses and fewer auxiliary variables than a naive Big-M.

Model#

The user writes ordinary syntax. The boolean toggles a tighter or looser bound on the integer variable.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & b \in \{0,1\},\ load \in \{0,\dots,19\} \\ \text{Indicator capacity:}\quad & load - 8b \le 4 \\ \text{Equivalent branches:}\quad & b=0 \Rightarrow load \le 4,\quad b=1 \Rightarrow load \le 12 \\ \text{Objective:}\quad & \min \; 3(1-b) - value(load) \end{aligned}\end{split}\]

Code#

examples/model/17_big_m_indicator_capacity.py#
from hermax.model import Model

m = Model()

use_truck = m.bool("use_truck")
load = m.int("load", lb=0, ub=20)

# Big-M style indicator constraint:
# if truck is used, load <= 12; otherwise load <= 4
m &= (load - 8 * use_truck <= 4)

m.obj[3] += ~use_truck
m.obj[1] += -load.piecewise(base_value=0, steps={k: k for k in range(1, 20)})

r = m.solve()
assert r.ok

print("use_truck =", r[use_truck])
print("load =", r[load])

Output#

The boolean activation and the resulting load demonstrate the conditional capacity bound in a Big-M style model, compiled through the dedicated fast path.

$ python examples/model/17_big_m_indicator_capacity.py
use_truck = True
load = 12

Example 18: Running Maximum#

Show the running_max() helper, which packages the efficient cumulative-fold pattern for prefix maxima and avoids the common quadratic prefix-max modelling mistake.

Efficiency#

The naive way to compute every prefix maximum recomputes larger and larger prefixes independently. running_max() builds the sequence cumulatively, which is the best pattern for ladder max aggregation.

New primitives#

  • hermax.model.IntVector.running_max()

Model#

The output vector is defined by:

\[\begin{split}\begin{aligned} r_0 &= x_0 \\ r_i &= \max(r_{i-1}, x_i) \qquad i \ge 1 \end{aligned}\end{split}\]

Each prefix value is derived from the previous prefix maximum and the new item.

Code#

examples/model/18_running_watermark.py#
from hermax.model import Model

m = Model()

timeline = m.int_vector("lvl", length=6, lb=0, ub=10)
for i, v in enumerate([1, 4, 2, 7, 6, 8]):
    m &= (timeline[i] == v)

watermark = timeline.running_max(name="watermark")

m &= (watermark[3] == 7)

r = m.solve()
assert r.ok

print("timeline   =", r[timeline])
print("watermark  =", r[watermark])

Output#

The watermark vector is the running prefix maximum of the input timeline.

$ python examples/model/18_running_watermark.py
timeline   = [1, 4, 2, 7, 6, 8]
watermark  = [1, 4, 4, 7, 7, 8]

Example 19: all_different#

Use this when you care about modelling scalability and want to choose the right all_different backend for your domain size and vector length.

Both backends are equivalent, but they scale differently. This example shows how to compare them on the same model and inspect the resulting CNF size, which is often the deciding factor on larger instances.

New primitives#

  • hermax.model.IntVector.all_different() with backend selection

Model#

Both backends encode the same logical constraint.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & x_0,\dots,x_3 \in \{0,\dots,5\} \\ \text{Constraint:}\quad & x_i \ne x_j \quad \forall i<j \end{aligned}\end{split}\]

pairwise uses scalar inequalities directly. bipartite channels to value indicators and adds per-value AMO constraints.

Code#

examples/model/19_all_different_backends.py#
from hermax.model import Model

m = Model()

xs = m.int_vector("x", length=4, lb=0, ub=6)

pairwise = xs.all_different(backend="pairwise")
bipartite = xs.all_different(backend="bipartite")

m &= bipartite

m &= (xs[0] == 1)
m &= (xs[1] == 3)
m &= (xs[2] <= 2)

r = m.solve()
assert r.ok

print("xs =", r[xs])
print("all distinct =", len(set(r[xs])) == len(r[xs]))
print("pairwise_clause_count =", len(pairwise._clauses))
print("bipartite_clause_count =", len(bipartite._clauses))

Output#

Both backends produce valid all-different solutions. The clause counts show the kind of structural tradeoff this example is meant to compare.

$ python examples/model/19_all_different_backends.py
xs = [1, 3, 0, 2]
all distinct = True
pairwise_clause_count = 42
bipartite_clause_count = 84

Example 20: Interval Scheduling#

Use this as a template for small scheduling models where you want readable interval constraints.

New primitives#

  • hermax.model.Model.max() (explicit aggregate)

  • Direct objective minimization of an integer aggregate

Model#

This model combines interval-level disjunctive scheduling with a makespan objective:

\[\begin{split}\begin{aligned} \text{Intervals:}\quad & A,B,C \text{ with fixed durations} \\ \text{Hard constraints:}\quad & A \text{ and } B \text{ do not overlap}, \\ & B \text{ and } C \text{ do not overlap}, \\ & A \text{ ends before } B \\ \text{Makespan:}\quad & M = \max(e_A, e_B, e_C) \\ \text{Objective:}\quad & \min M \end{aligned}\end{split}\]

The makespan is modeled explicitly as an aggregate variable and minimized directly.

Code#

examples/model/20_interval_makespan_watermark.py#
from hermax.model import Model

m = Model()

a = m.interval("A", start=0, duration=3, end=12)
b = m.interval("B", start=0, duration=4, end=12)
c = m.interval("C", start=0, duration=2, end=12)

m &= a.no_overlap(b)
m &= b.no_overlap(c)

# Make task A happen before B as an extra precedence.
m &= a.ends_before(b)

makespan = m.max([a.end, b.end, c.end], name="makespan")

m.obj[1] += makespan

r = m.solve()
assert r.ok

print("A =", r[a])
print("B =", r[b])
print("C =", r[c])
print("makespan =", r[makespan])

Output#

The printed interval assignments and makespan show a small but complete scheduling optimization model with an explicit aggregate objective.

$ python examples/model/20_interval_makespan_watermark.py
A = {'start': 0, 'end': 3, 'duration': 3}
B = {'start': 3, 'end': 7, 'duration': 4}
C = {'start': 0, 'end': 2, 'duration': 2}
makespan = 7

Solution#

_images/interval_scheduling_solution.svg

Example 21: Decode Collections#

Use this when the model contains vectors, matrices, dictionaries, or enum collections and you want to inspect the decoded result in ordinary Python structures.

The collections are a small integer vector, an integer matrix, a boolean dictionary, and an enum dictionary. The model pins each entry to a known value and then shows how the result object decodes them back into ordinary Python containers.

Code#

examples/model/21_decode_collections.py#
from hermax.model import Model

m = Model()

vec = m.int_vector("vec", length=3, lb=0, ub=5)
mat = m.int_matrix("mat", rows=2, cols=2, lb=0, ub=5)
flags = m.bool_dict("flag", keys=["r1", "r2"])
mode = m.enum_dict("mode", keys=["r1", "r2"], choices=["eco", "boost"], nullable=True)

m &= (vec[0] == 1)
m &= (vec[1] == 3)
m &= (vec[2] == 2)

m &= (mat[0, 0] == 2)
m &= (mat[0, 1] == 4)
m &= (mat[1, 0] == 1)
m &= (mat[1, 1] == 0)

m &= flags["r1"]
m &= ~flags["r2"]

m &= (mode["r1"] == "eco")
m &= (mode["r2"] == "boost")

r = m.solve()
assert r.ok

print("vec        =", r[vec])
print("mat        =", r[mat])
print("mat col 1  =", r[mat[:, 1]])
print("flags      =", r[flags])
print("mode       =", r[mode])

Output#

The result object decodes each collection into the matching Python container.

$ python examples/model/21_decode_collections.py
vec        = [1, 3, 2]
mat        = [[2, 4], [1, 0]]
mat col 1  = [4, 0]
flags      = {'r1': True, 'r2': False}
mode       = {'r1': 'eco', 'r2': 'boost'}

Example 22: Indexed Lookup#

Use this when one decision chooses a value from a table. Common cases are machine processing times, worker costs, or plan limits.

New primitives#

  • variable-index element constraints with vec[idx]

Model#

Each job chooses one machine. The chosen machine determines the processing time through a table lookup.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & m_j \in \{0,\dots,k-1\}\ \text{(machine chosen for job } j\text{)} \\ \text{Lookup:}\quad & p_j = duration_j[m_j] \\ \text{Objective:}\quad & \min \sum_j p_j \end{aligned}\end{split}\]

This is a good pattern when the data is already stored as Python lists or vectors and you want the model to follow that structure directly.

Warning

Indexed lookup is a good fit for small tables and menu choices, but it does not scale well. Use it when the lookup itself is the natural model. For large tables or many repeated lookups, prefer a formulation with more direct structure if one is available.

Code#

examples/model/22_indexed_lookup.py#
from hermax.model import Model


m = Model()

durations = m.int_vector("duration", length=3, lb=0, ub=10)
for i, value in enumerate([6, 3, 5]):
    m &= (durations[i] == value)

machine = m.int("machine", 0, 2)
chosen_duration = m.int("chosen_duration", 0, 10)

# Pick the duration attached to the chosen machine.
m &= (durations[machine] == chosen_duration)

m &= (machine != 0)

m.obj[1] += chosen_duration

r = m.solve()
assert r.ok

print("status:", r.status)
print("cost:", r.cost)
print("durations:", r[durations])
print("machine:", r[machine])
print("chosen_duration:", r[chosen_duration])

Output#

The solver chooses the cheapest allowed machine and returns the matched value from the duration table.

$ python examples/model/22_indexed_lookup.py
status: optimum
cost: 3
durations: [6, 3, 5]
machine: 1
chosen_duration: 3

Example 23: Optional Assignment#

Use this when an item may be assigned to a resource, but leaving it unassigned is also allowed with a penalty.

Many real problems are not “assign everything no matter what”. This pattern is better for overload planning, staff shortages, fallback scheduling, and “serve the most important requests first” problems.

New primitives#

  • nullable hermax.model.EnumVar

  • enum equality literals (assign[t] == worker)

Model#

Each task is assigned to one worker, or to None if it is left unassigned. Leaving a task unassigned pays a penalty.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & a_t \in W \cup \{\text{None}\} \\ \text{Capacity:}\quad & \sum_t [a_t = w] \le cap_w \qquad \forall w \in W \\ \text{Penalty for skipping work:}\quad & penalty_t \cdot [a_t = \text{None}] \\ \text{Objective:}\quad & \min \sum_t penalty_t [a_t = \text{None}] + \sum_{t,w} c_{t,w}[a_t = w] \end{aligned}\end{split}\]

This is easier to read than a Boolean assignment matrix, especially when each item can go to at most one place.

Code#

examples/model/23_optional_assignment.py#
from hermax.model import Model


m = Model()

tasks = ["api", "ui", "ops"]
workers = ["alice", "bob"]

assign = m.enum_dict("assign", tasks, choices=workers, nullable=True)

for worker, cap in {"alice": 1, "bob": 1}.items():
    m &= sum(assign[t] == worker for t in tasks) <= cap

m &= ~(assign["ops"] == "bob")

unassigned_penalty = {"api": 8, "ui": 6, "ops": 2}
for task in tasks:
    m.obj[unassigned_penalty[task]] += assign[task].is_in(workers)

for task, worker, cost in [
    ("api", "alice", 1),
    ("api", "bob", 2),
    ("ui", "alice", 3),
    ("ui", "bob", 1),
    ("ops", "alice", 4),
]:
    m.obj[cost] += ~(assign[task] == worker)

r = m.solve()
assert r.ok

print("status:", r.status)
print("cost:", r.cost)
print("assign:", r[assign])

Output#

The result leaves the least important task unassigned and decodes that choice as None.

$ python examples/model/23_optional_assignment.py
status: optimum
cost: 4
assign: {'api': 'alice', 'ui': 'bob', 'ops': None}

Example 24: Facility Location#

Use this when opening a site has a fixed cost, and each client must be attached to one open site.

This is a classic optimization pattern because it combines three common ideas: open-or-close decisions, assignment decisions, and fixed costs.

New primitives#

  • booleans for opening sites

  • enums for client assignment

  • linking constraints between assignment and activation

Model#

Each facility may be opened or closed. Each client is assigned to one facility. Assignments are only allowed to open facilities.

\[\begin{split}\begin{aligned} \text{Variables:}\quad & open_f \in \{0,1\},\quad a_c \in F \\ \text{Open-link rule:}\quad & [a_c = f] \Rightarrow open_f \qquad \forall c,f \\ \text{Capacity:}\quad & \sum_c demand_c [a_c = f] \le cap_f \cdot open_f \qquad \forall f \\ \text{Objective:}\quad & \min \sum_f fixed_f\,open_f + \sum_{c,f} ship_{c,f}[a_c = f] \end{aligned}\end{split}\]

The same structure appears in warehouses, server placement, clinic selection, and planning problems.

Problem#

_images/facility_location_problem.svg

Code#

examples/model/24_facility_location.py#
from hermax.model import Model


m = Model()

facilities = ["north", "south"]
clients = ["A", "B", "C"]

open_facility = m.bool_dict("open", facilities)
assign = m.enum_dict("assign", clients, choices=facilities, nullable=False)

for client in clients:
    for facility in facilities:
        m &= (~(assign[client] == facility) | open_facility[facility])

for facility, cap in {"north": 3, "south": 3}.items():
    m &= sum(assign[c] == facility for c in clients) <= cap

fixed_cost = {"north": 4, "south": 6}
ship_cost = {
    ("A", "north"): 1,
    ("A", "south"): 4,
    ("B", "north"): 3,
    ("B", "south"): 1,
    ("C", "north"): 2,
    ("C", "south"): 2,
}

for facility in facilities:
    m.obj[fixed_cost[facility]] += ~open_facility[facility]

for client in clients:
    for facility in facilities:
        m.obj[ship_cost[(client, facility)]] += ~(assign[client] == facility)

r = m.solve()
assert r.ok

print("status:", r.status)
print("cost:", r.cost)
print("open:", r[open_facility])
print("assign:", r[assign])

Output#

The solver opens the cheaper facility and routes every client through it.

$ python examples/model/24_facility_location.py
status: optimum
cost: 10
open: {'north': True, 'south': False}
assign: {'A': 'north', 'B': 'north', 'C': 'north'}

Solution#

_images/facility_location_solution.svg

Example 25: Portfolio Solve#

Use this when the model is already written and you want a better default solve strategy without manually choosing a single backend.

  • hermax.model.Model.solve() with solver=CompletePortfolioSolver

  • solver_kwargs for solver selection and worker settings

Solver performance can vary a lot from one model family to another. A portfolio lets you keep the same model and try several solvers behind the same interface.

The point of the example is to keep the same constraints and objective, but switch the solve strategy to a complete preset portfolio.

Code#

examples/model/25_portfolio_solve.py#
from hermax.model import Model
from hermax.portfolio import CompletePortfolioSolver


m = Model()

a = m.bool("a")
b = m.bool("b")

m &= (a | b)

m.obj[3] += ~a
m.obj[1] += ~b

r = m.solve(
    solver=CompletePortfolioSolver,
    solver_kwargs={
        "max_workers": 1,
        "overall_timeout_s": 3.0,
        "per_solver_timeout_s": 2.0,
    },
)
assert r.ok

print("status:", r.status)
print("cost:", r.cost)
print("a:", r[a])
print("b:", r[b])

Warning

Hermax provides broader portfolio presets, including incomplete solvers. Those can be useful for speed, but they need more care: incomplete backends do not carry the exactness guarantees as the complete preset used in this example.

For the full portfolio API, preset classes, and selection policies, see Portfolio Solver.

Output#

The model is unchanged; only the solve strategy is switched to a portfolio wrapper.

$ python examples/model/25_portfolio_solve.py
status: optimum
cost: 1
a: False
b: True

Example 29: Floating Point Objective#

Use this when constraints are discrete but objective terms are fractional (expected ROI, probabilities, prices, rates).

You keep objective terms in natural units instead of manual 100/1000 scaling.

New primitives#

  • hermax.model.Model.set_objective_precision()

  • floating-point objective weights via model.obj[w] += ...

Model#

Capital budgeting variant: choose projects under a developer-month capacity and maximize expected ROI (millions USD). Since Hermax minimizes, the model minimizes missed ROI:

\[\begin{split}\begin{aligned} \min \quad & \sum_{p} roi_p \cdot [\neg fund_p] \\ \text{s.t.}\quad & \sum_{p} months_p \cdot [fund_p] \le 18 \end{aligned}\end{split}\]

Problem#

_images/float_objective_budgeting_problem.svg

Code#

examples/model/29_float_objective_budgeting.py#
from hermax.model import Model


m = Model()
m.set_objective_precision(decimals=2)

projects = ["Alpha", "Beta", "Gamma", "Delta"]
fund = m.bool_dict("fund", projects)

dev_months = {"Alpha": 5, "Beta": 8, "Gamma": 6, "Delta": 7}
m &= sum(dev_months[p] * fund[p] for p in projects) <= 18

expected_roi_millions = {"Alpha": 1.25, "Beta": 3.40, "Gamma": 2.10, "Delta": 2.80}

for p in projects:
    m.obj[expected_roi_millions[p]] += fund[p]

r = m.solve()
assert r.ok

selected = [p for p in projects if r[fund[p]]]
used_months = sum(dev_months[p] for p in selected)
total_roi = sum(expected_roi_millions.values())
selected_roi = round(total_roi - float(r.cost), 2)

print("status:", r.status)
print("total_dev_months:", used_months)
print("missed_roi_usd_millions:", r.cost)
print("selected_roi_usd_millions:", selected_roi)
print("funded_projects:", selected)

Note

Floating objective weights require explicit precision setup, for example set_objective_precision(decimals=2).

Output#

The output reports missed ROI (optimization cost) and selected ROI in the same units.

$ python examples/model/29_float_objective_budgeting.py
status: optimum
total_dev_months: 15
missed_roi_usd_millions: 3.35
selected_roi_usd_millions: 6.2
funded_projects: ['Beta', 'Delta']

Solution#

_images/float_objective_budgeting_solution.svg

Example 30: Incremental Queries#

Use this when you solve once, add a new constraint from updated conditions, and solve again.

You can iterate on one live model with three incremental operations: hard updates, temporary assumptions, and objective updates.

Model#

A small assignment model is solved in multiple passes:

  1. Baseline solve

  2. Hard update: forbid T2 -> M1

  3. What-if query with assumptions: force T1 -> M2 temporarily

  4. Objective replacement (automatic objective diffing)

Code#

examples/model/30_incremental_rescheduling.py#
from hermax.model import Model


m = Model()
tasks = ["T1", "T2", "T3"]
machines = ["M1", "M2"]

assign = m.enum_dict("assign", tasks, choices=machines, nullable=False)

def objective(t3_m1_weight: int):
    return (
        1 * (assign["T1"] == "M1")
        + 3 * (assign["T1"] == "M2")
        + 1 * (assign["T2"] == "M1")
        + 3 * (assign["T2"] == "M2")
        + t3_m1_weight * (assign["T3"] == "M1")
        + 3 * (assign["T3"] == "M2")
    )


m.obj = objective(1)

r1 = m.solve()
assert r1.ok
print("--- Baseline Plan ---")
print("cost:", r1.cost)
print("assignments:", r1[assign])

m &= ~(assign["T2"] == "M1")

r2 = m.solve()
assert r2.ok
print("--- Adjusted Plan (T2 on M1 forbidden) ---")
print("cost:", r2.cost)
print("assignments:", r2[assign])

r3 = m.solve(assumptions=[assign["T1"] == "M2"])
assert r3.ok
print("--- What-if (assume T1 on M2) ---")
print("cost:", r3.cost)
print("assignments:", r3[assign])

r4 = m.solve()
assert r4.ok
print("--- Back to model state (assumption removed) ---")
print("cost:", r4.cost)
print("assignments:", r4[assign])

m.obj = objective(5)

r5 = m.solve()
assert r5.ok
print("--- After objective update (T3 on M1 penalty raised) ---")
print("cost:", r5.cost)
print("assignments:", r5[assign])

Output#

The transcript shows that assumptions do not persist, while hard/objective updates do.

$ python examples/model/30_incremental_rescheduling.py
--- Baseline Plan ---
cost: 3
assignments: {'T1': 'M1', 'T2': 'M1', 'T3': 'M1'}
--- Adjusted Plan (T2 on M1 forbidden) ---
cost: 5
assignments: {'T1': 'M1', 'T2': 'M2', 'T3': 'M1'}
--- What-if (assume T1 on M2) ---
cost: 7
assignments: {'T1': 'M2', 'T2': 'M2', 'T3': 'M1'}
--- Back to model state (assumption removed) ---
cost: 5
assignments: {'T1': 'M1', 'T2': 'M2', 'T3': 'M1'}
--- After objective update (T3 on M1 penalty raised) ---
cost: 7
assignments: {'T1': 'M1', 'T2': 'M2', 'T3': 'M2'}

Solution#

_images/incremental_queries_solution.svg

Example 31: Hierarchical Objectives#

Use this when one objective strictly dominates another.

Lexicographic optimization avoids weighting hacks and enforces priority order.

Model#

Assignment scenario with two objective tiers:

  • Tier 0: minimize unassigned VIP clients

  • Tier 1: minimize travel cost

The model uses:

m.tier_obj.set_lexicographic(unassigned_vips, travel)

Problem#

_images/hierarchical_objectives_problem.svg

Code#

examples/model/31_hierarchical_objectives.py#
from hermax.model import Model


m = Model()
clients = ["VIP_1", "VIP_2", "Standard_1"]
drivers = ["Alice", "Bob"]
vips = ["VIP_1", "VIP_2"]

assign = m.enum_dict("assign", clients, choices=drivers, nullable=True)

for d in drivers:
    m &= sum(assign[c] == d for c in clients) <= 1

vip_served = sum(assign[v] == d for v in vips for d in drivers)
unassigned_vips = len(vips) - vip_served

travel_cost = {"Alice": 50, "Bob": 10}
travel = sum(travel_cost[d] * (assign[c] == d) for c in clients for d in drivers)

m.tier_obj.set_lexicographic(unassigned_vips, travel)

r = m.solve(lex_strategy="incremental")
assert r.ok

print("status:", r.status)
print("unassigned_vips (tier 0):", r.tier_costs[0])
print("travel_cost      (tier 1):", r.tier_costs[1])
print("assignments:", r[assign])

Output#

The result reports per-tier costs through SolveResult.tier_costs.

$ python examples/model/31_hierarchical_objectives.py
status: optimum
unassigned_vips (tier 0): 0
travel_cost      (tier 1): 60
assignments: {'VIP_1': 'Alice', 'VIP_2': 'Bob', 'Standard_1': None}

Solution#

_images/hierarchical_objectives_solution.svg

Note

This example uses solve(lex_strategy="incremental") (the default for tiered objectives), which optimizes tier by tier and hardens each optimum before moving to the next tier.

lex_strategy="stratified" is also available and solves a single stratified objective. It might be faster on some instances (or not, YMMV).

Next#

For NP-hard optimization examples, continue in NP-Hard Problems.