Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Soft vs. Hard Constraints

Every rule in a quadratic model is a penalty term, but not every penalty term means the same thing to the problem it belongs to. A hard constraint is a rule the solution must obey. It is satisfied or it is violated, with nothing in between, and the only question a penalty weight answers is how expensive violating it becomes. A soft constraint – a preference is the more precise name – is a cost a solution pays continuously: more of the disfavoured thing costs more, but nothing marks any amount as forbidden. apply_equality, apply_onehot_row, apply_exclude, and the rest of the constraint family from Constraints are how a hard rule is written. A preference is written directly into the objective, the way Objectives covers, with no constraint call at all.

Both in One Problem

examples/portfolio_opt/ carries one of each. The budget is hard – sum(x_i) = B via apply_equality, unconditional, penalty 200 – and the risk term is soft – a cubic penalty added straight into the objective for every asset triple whose combination the problem considers risky, with no accompanying constraint call (Portfolio Optimization, examples/portfolio_opt/runner.py):

# Cubic risk cross-terms via REDUCE
with problem.range(0, num_risk) as t:
    ...
    w = problem.model.reduce(ti, tj, _P_AUX)
    problem.model.quadratic[w, tk].add(sigma)

# Budget constraint: sum(x_i) = B  (EQUALITY with unit coefficients)
...
problem.model.apply_equality(indices, coeffs, budget_in, 200)

reduce and ADDQUAD build the risk cost the same way any other objective term is built – see Objectives and High-Level Constraints for REDUCE’s own Rosenberg terms, which enforce the auxiliary variable’s algebraic identity, not the risk rule itself. Nothing about sigma is a threshold; it is a coefficient like any other, weighing a return against a risk directly in the same sum.

The Discriminating Difference: A Threshold vs. a Continuous Trade-off

Fix four assets with returns [10, 9, 8, 7], a budget of 3, and one risky triple (0, 1, 2, sigma), then sweep sigma. run_case below covers the encoder half – compiling the problem, running the encoder to build the model, and solving it – the same way the earlier snippets on this page do. Decoding the returned sample back into a portfolio runs programs.decoder over [sample, n], exactly as examples/portfolio_opt/runner.py does it. That step is elided here because it is the same on both sides of the comparison:

from examples.portfolio_opt.runner import build_problem
from xquad.vm import VM, VMBackend
from xqsa import build_solver

def run_case(sigma):
    risk_terms = [(0, 1, 2, sigma)]
    problem = build_problem(4, [10, 9, 8, 7], 3, risk_terms)
    programs = problem.compile()
    flat_risk = [v for term in risk_terms for v in term]
    vm = VM(backend=VMBackend.RUST)
    vm.set_calldata([4, [10, 9, 8, 7], 3, 1, flat_risk])
    vm.set_output_slots(1)
    vm.run(programs.encoder)
    model = vm.outputs()[0]
    result = build_solver("dwave-cpu", seed=42).solve(model, num_reads=200)
    ...  # decode via programs.decoder and print, as below

Decoded and printed, sigma=1 and sigma=2 give:

sigma=1: portfolio=[1, 1, 1, 0] total_return=27 energy=-1826
sigma=2: portfolio=[1, 1, 0, 1] total_return=26 energy=-1826

Across twenty solver seeds at each sigma (seed in range(1, 21)), sigma=2 always switches to {0, 1, 3} (return 26), stable on every seed. sigma=1 does not behave the same way: it sits exactly at the tie, so the sampler returns either portfolio, split close to evenly across seeds – {0, 1, 2} on 12 of 20 and {0, 1, 3} on 8 of 20, both at the identical energy -1826. The tie is exact, not approximate: asset 2’s risk cost only applies when all three of 0, 1, 2 are selected together (REDUCE’s product term), so choosing the risky trio costs -27 + sigma against the safer trio’s fixed -26; the two costs are equal at sigma = 1 and the risky choice stops paying for itself once sigma > 1, which is exactly where the stable switch to {0, 1, 3} sets in. A weight that lands exactly on a tie is not evidence either portfolio is preferred – it is evidence the weight is at the boundary, and a single sample there does not tell you which side you are on. Every selection in this sweep still has exactly three assets – the hard budget constraint held at every sigma, unaffected – while which three assets the soft risk cost favoured changed at its break-even point, with a tie rather than a clean switch exactly at that point. That contrast is what the two kinds of constraint buy: a hard rule’s own penalty weight only decides whether the rule can be broken at all (Quadratic Models, Constraints), while a soft term’s weight decides how much of the preference the solution actually buys.

Soft, With No Hard Constraint at All

examples/max3sat/ sits at the other end: every clause is a preference, none is a rule the solution must satisfy, and the problem carries no apply_* call anywhere (Max-3-SAT). A clause (i, j, k) costs P_CLAUSE exactly when all three of its literals are false, added directly to the objective; a solution that satisfies zero clauses is legal, just expensive. Running examples/max3sat/runner.py --seed 42 (6 variables, 8 clauses) satisfies all eight (satisfied: 8, energy: -80) for this random instance, but nothing in the model would reject a sample that satisfied fewer – there is no valid check tied to clause satisfaction, because there is no constraint to check.

The valid flag draws exactly this line. A hard constraint is an apply_* call, and the generated verifier emits one check per call: portfolio_opt’s budget is an apply_equality, so a sample selecting all four assets against a budget of 3 comes back valid: 0. Max-3-SAT’s clauses are objective terms, so nothing in its verifier looks at them and a sample satisfying two clauses is as valid as one satisfying eight. If a rule must hold, declare it as a constraint; Verification says what valid then covers.

Cost in Variables

Neither pattern taxes the model directly – a soft term is ordinary linear/quadratic coefficient writes, and a hard constraint’s cost is whatever its own instruction adds (ATLEAST/ATLEASTW/REDUCE grow model.size; EXCLUDE/IMPLIES/EQUALITY-with-in-range-indices do not). portfolio_opt’s risk terms cost one REDUCE auxiliary variable each, so the model grows from n to n + num_risk, independent of the hard budget constraint sharing the same model.

Failure Mode

Writing a preference as a hard constraint at a large penalty forces an all-or-nothing rule where a graded cost was wanted – the model will never choose to pay a little of the disfavoured thing even when the rest of the objective would gain more from it, because the constraint’s cost jumps from 0 to penalty * d^2 the instant it is touched at all (Constraints). The reverse mistake, writing a rule that must always hold as a soft objective term, is worse: nothing stops the solver from breaking it whenever the rest of the objective offers enough of a reward, and there is no way to check afterward that it did not, the way max3sat’s missing valid check above demonstrates directly.