0.1f Constraints
Spec 0.1f — VFI Control Bounds via ⊥ Declarations¶
Status: Implemented Scope: Vanilla Dolo enhancement (not dolo-plus pipeline)
1. Goal¶
Enable Value Function Iteration (VFI) solvers to read control bounds directly from the model YAML by allowing ⊥ (up tack) bound declarations within the value equation block.
Currently:
- Bounds are only parsed from arbitrage block using ⟂ (complementarity) syntax
- VFI solver expects controls_lb(m, s, p) and controls_ub(m, s, p) functions
- These functions don't exist unless arbitrage equations are defined
- VFI models that use felicity/value (not arbitrage) cannot specify bounds
This spec introduces the ⊥ prefix for bound constraints within the value block.
2. Motivation¶
VFI solves:
The solver needs callable bound functions:
lb = controls_lb(m, s, parms) # returns array of lower bounds
ub = controls_ub(m, s, parms) # returns array of upper bounds
Current workaround: Define dummy arbitrage equations just to attach ⟂ bounds — semantically incorrect for VFI models.
Proposed: Use ⊥ prefix to declare bounds within the value block.
2.1 Formal semantics (constraints define the feasible set Γ)¶
This spec is easiest to interpret using the same "syntax → formal problem" reading used elsewhere in this repo:
06-from-syntax-to-formal-problem-definition.md.
In the formal dynamic program, bounds are not "extra equations" and they do not define implicit maps like transition equalities do. Instead, bounds specify the feasible (constraint) correspondence for the maximization step:
So a line like:
is a declarative constraint-set statement ("\(c\) is chosen subject to \(0\le c\le w\)"), i.e. it defines \(\Gamma(\cdot)\) for VFI.
Important (timing/semantics):
- In Dolang+ semantics, a control is chosen at the decision perch (even if some representations evaluate policies on other grids).
- In vanilla Dolo VFI, the maximization is implemented by the solver; the
value:block provides the objective and⊥lines provide the solver's box constraints.
Infinity constants:
infis treated as \(+\infty\) (and-infas \(-\infty\)), so e.g.⊥ 0 <= n[t] <= infis valid.
3. Syntax¶
3.1 Symbol choice: ⊥ (up tack)¶
- Symbol:
⊥(U+22A5, "up tack" or "bottom") - Meaning: Marks a bound constraint line (as opposed to an equation)
- Relation to
⟂: Visually related but semantically distinct: ⟂= complementarity (equation ⟂ constraint, used in arbitrage)⊥= bound constraint (standalone, used in value/VFI)
3.2 Bounds within value block¶
3.3 Multiple controls¶
value: |
V[t] = c[t]^(1-sigma)/(1-sigma) - chi*n[t]^(1+eta)/(1+eta) + beta*V[t+1]
⊥ 0.0 <= n[t] <= inf
⊥ 0.0 <= i[t] <= y[t]
3.4 State-dependent bounds¶
The upper bound w[t] is a state variable — bounds can depend on (m, s, p).
4. Parsing rules¶
4.1 Grammar additions (dolang)¶
Add to grammar.lark:
Extend block parsing to recognize bound_constraint lines within value blocks.
4.2 Line classification¶
Within a value: | block, lines are classified as:
- Equation line: Contains = (the Bellman equation)
- Bound line: Starts with ⊥ followed by lb <= x[t] <= ub
V[t] = u[t] + beta*V[t+1] → equation (parse as equality)
⊥ 0.0 <= c[t] <= w[t] → bound (parse as bound_constraint)
4.3 Bound ordering¶
Bounds must be declared in the same order as symbols.controls:
symbols:
controls: [n, i] # order matters
value: |
V[t] = ...
⊥ 0.0 <= n[t] <= inf # first bound → first control (n)
⊥ 0.0 <= i[t] <= y[t] # second bound → second control (i)
Parser validates that the variable in each ⊥ declaration matches the expected control at that index.
4.4 Output functions¶
Extracted bounds compile to:
- controls_lb(m, s, p) → returns [lb_0, lb_1, ...]
- controls_ub(m, s, p) → returns [ub_0, ub_1, ...]
These are the exact function names VFI expects (see value_iteration.py lines 53-54).
5. Recipe additions¶
Add to recipes.yaml:
controls_lb:
optional: True
recursive: True
eqs:
- ['exogenous', 0, 'm']
- ['states', 0, 's']
- ['parameters', 0, 'p']
controls_ub:
optional: True
recursive: True
eqs:
- ['exogenous', 0, 'm']
- ['states', 0, 's']
- ['parameters', 0, 'p']
6. Implementation changes¶
6.1 dolang/grammar.lark¶
Add bound constraint rule:
Update start and block rules to recognize bound constraints:
?start: equation | predicate | bound_constraint
value_block: _NEWLINE? equation (_NEWLINE equation | _NEWLINE bound_constraint)* _NEWLINE?
6.2 dolo/compiler/model.py¶
-
When parsing
valueblock, split lines into equations vs bound constraints:if g == "value": equations = [] bounds_lb = [] bounds_ub = [] for line in block_lines: if line.strip().startswith("⊥"): # Parse as bound_constraint parsed = parse_string(line, start="bound_constraint") # Extract lb, variable, ub from double_inequality bounds_lb.append(extract_lb(parsed)) bounds_ub.append(extract_ub(parsed)) else: # Parse as equation equations.append(line) d["value"] = equations if bounds_lb: d["controls_lb"] = bounds_lb d["controls_ub"] = bounds_ub -
Validate bound variable order matches
symbols.controls.
6.3 dolo/compiler/factories.py¶
Add handling for controls_lb and controls_ub equation types (line ~72):
if eq_type in ("arbitrage_lb", "arbitrage_ub", "controls_lb", "controls_ub"):
specs = {
"eqs": recipes["dtcc"]["specs"]["arbitrage"]["complementarities"]["left-right"]
}
6.4 dolo/algos/value_iteration.py¶
Update to use model.x_bounds fallback (recommended):
# Current (lines 53-54):
controls_lb = model.functions["controls_lb"]
controls_ub = model.functions["controls_ub"]
# Proposed:
if model.x_bounds is not None:
controls_lb, controls_ub = model.x_bounds
else:
raise ValueError("VFI requires control bounds. Add ⊥ declarations to value block.")
This leverages the existing model.x_bounds property which already checks both controls_* and arbitrage_* functions.
7. Edge cases and gotchas¶
7.1 Old-style YAML list form¶
The parser currently handles two YAML styles for equation blocks:
Block scalar (recommended for ⊥):
List form (legacy):
The list form parses each item with start="equation", which does not recognize ⊥ as a valid start token. Implementation must either:
- Extend list-form parsing to detect and handle ⊥ lines, OR
- Reject ⊥ in list form with a clear error message recommending block scalar syntax
Recommendation: Support block scalar only for ⊥ bounds. Add validation that rejects ⊥ in list form with helpful error:
Error: Bound constraints (⊥) must use block scalar syntax.
Change:
value:
- V[t] = ...
- ⊥ 0 <= c[t] <= w[t]
To:
value: |
V[t] = ...
⊥ 0 <= c[t] <= w[t]
7.2 Control variable time-index validation¶
The existing arbitrage complementarity validation (model.py lines 210-222) requires the bounded variable to be a timed variable at t=0:
expected = (self.symbols["controls"][i], 0) # e.g., ("c", 0) for c[t]
if (v, t) != expected:
raise Exception(f"Incorrect variable in complementarity...")
Edge cases to handle:
| Syntax | Valid? | Notes |
|---|---|---|
⊥ 0 <= c[t] <= w[t] |
✓ | Standard form |
⊥ 0 <= c <= w[t] |
✗ | Missing time index on c — reject |
⊥ 0 <= c[t-1] <= w[t] |
✗ | Wrong time index — reject |
⊥ 0 <= c[t+1] <= w[t] |
✗ | Wrong time index — reject |
Implementation: Reuse existing validation logic. Reject non-timed or wrong-time variables with clear error:
7.3 Default bounds behavior¶
Current state: value_iteration.py directly accesses model.functions["controls_lb"] with no fallback. Models with value but no ⊥ lines will raise KeyError.
Options:
| Option | Behavior | Pros | Cons |
|---|---|---|---|
| A. Require bounds | Error if no ⊥ for VFI models |
Explicit, catches mistakes | More verbose for simple models |
| B. Default to ±inf | Generate controls_lb = [-inf, ...], controls_ub = [+inf, ...] |
Convenient | May hide missing constraints |
C. Use model.x_bounds fallback |
Check controls_* → arbitrage_* → error |
Unified interface | Slightly more complex |
Recommendation: Option C — use model.x_bounds property.
The property already exists (model.py lines 1020-1032):
@property
def x_bounds(self):
if "controls_ub" in self.functions:
fun_lb = self.functions["controls_lb"]
fun_ub = self.functions["controls_ub"]
return [fun_lb, fun_ub]
elif "arbitrage_ub" in self.functions:
fun_lb = self.functions["arbitrage_lb"]
fun_ub = self.functions["arbitrage_ub"]
return [fun_lb, fun_ub]
else:
return None
This allows:
1. VFI models with ⊥ bounds → uses controls_lb/ub
2. Legacy models with arbitrage + ⟂ → falls back to arbitrage_lb/ub
3. Models with neither → clear error from VFI solver
7.4 recipes.yaml gap¶
Current state: recipes.yaml has no entry for controls_lb or controls_ub. The factory will fail when trying to compile these equation types.
Required addition (as specified in Section 5):
controls_lb:
optional: True
recursive: True
eqs:
- ['exogenous', 0, 'm']
- ['states', 0, 's']
- ['parameters', 0, 'p']
controls_ub:
optional: True
recursive: True
eqs:
- ['exogenous', 0, 'm']
- ['states', 0, 's']
- ['parameters', 0, 'p']
Note: The signature (m, s, p) matches the existing arbitrage complementarities spec at recipes["dtcc"]["specs"]["arbitrage"]["complementarities"]["left-right"].
7.5 Number of bounds vs number of controls¶
Validation required: The number of ⊥ declarations must match len(symbols.controls).
symbols:
controls: [n, i] # 2 controls
value: |
V[t] = ...
⊥ 0 <= n[t] <= inf # only 1 bound — ERROR
Error message:
8. Example: Full VFI model¶
name: consumption_savings_vfi
symbols:
exogenous: [e_y]
states: [w]
controls: [c]
values: [V]
rewards: [u]
parameters: [beta, gamma, r, sigma_y]
definitions:
y[t]: exp(e_y[t])
equations:
transition:
- w[t] = (w[t-1] - c[t-1]) * r + y[t]
felicity:
- u[t] = c[t]^(1-gamma) / (1-gamma)
value: |
V[t] = u[t] + beta*V[t+1]
⊥ 0.01 <= c[t] <= w[t]
calibration:
beta: 0.96
gamma: 2.0
r: 1.03
sigma_y: 0.1
w: 1.0
c: 0.5
V: 0.0
u: c^(1-gamma)/(1-gamma)
exogenous: !Normal
Sigma: [[sigma_y**2]]
domain:
w: [0.1, 10.0]
options:
grid: !Cartesian
orders: [100]
Multiple controls example (RBC):
value: |
V[t] = c[t]^(1-sigma)/(1-sigma) - chi*n[t]^(1+eta)/(1+eta) + beta*V[t+1]
⊥ 0.0 <= n[t] <= inf
⊥ 0.0 <= i[t] <= y[t]
Usage:
from dolo import yaml_import
from dolo.algos.value_iteration import value_iteration
model = yaml_import("consumption_savings_vfi.yaml")
result = value_iteration(model, verbose=True)
9. Backwards compatibility¶
- Arbitrage models: Unchanged.
arbitrage+⟂continues to producearbitrage_lb/ub. - Time iteration: Uses
arbitrage_lb/ub— unaffected. - VFI models: Can now use
valueblock with⊥bound declarations. - Symbol distinction:
⟂(U+27C2) = complementarity (inarbitrage)⊥(U+22A5) = bound constraint (invalue)
10. Acceptance criteria¶
10.1 Core functionality¶
- [ ] Grammar updated:
bound_constraintrule added togrammar.lark - [ ]
⊥declarations parsed fromvalueblock (block scalar form) - [ ] Bounds compiled to
controls_lb/controls_ubfunctions - [ ]
recipes.yamlincludescontrols_lbandcontrols_ubspecs - [ ] VFI solver uses
model.x_boundsfallback - [ ] VFI solver runs on models using new syntax
10.2 Validation¶
- [ ] Bound variable order validated against
symbols.controlsorder - [ ] Bound variable time-index validated (
c[t]required, notcorc[t-1]) - [ ] Number of bounds validated against number of controls
- [ ] State-dependent bounds (e.g.,
c[t] <= w[t]) work correctly
10.3 Error handling¶
- [ ] Clear error for
⊥in list-form YAML (recommend block scalar) - [ ] Clear error for missing time index on bounded variable
- [ ] Clear error for wrong time index on bounded variable
- [ ] Clear error for mismatched control name in bound
- [ ] Clear error for wrong number of bounds vs controls
- [ ] Clear error for VFI model with no bounds defined
10.4 Backwards compatibility¶
- [ ] Existing
arbitrage+⟂models continue to work unchanged - [ ]
model.x_boundsreturnsarbitrage_lb/ubwhencontrols_lb/ubnot present - [ ] Time iteration solver unaffected
11. Future extensions¶
- Unified
x_boundsinterface:model.x_boundsproperty already checks bothcontrols_*andarbitrage_*(seemodel.pyline 1020-1032). Ensure this works seamlessly. - Bounds in
felicityblock: Could extend if needed for alternative model structures. - General inequality constraints:
g(x) <= 0beyond box bounds (would require different solver).