4 releases
| 0.1.3 | Jul 23, 2025 |
|---|---|
| 0.1.2 | Jul 9, 2025 |
| 0.1.1 | Jul 2, 2025 |
| 0.1.0 | Jun 24, 2025 |
#734 in Parser implementations
212 downloads per month
73KB
1.5K
SLoC
Lynx – A Minimal DSL for Discrete Optimisation (July 2025)
Lynx lets you model MILP/SAT-style problems with just a few orthogonal
concepts: primitive values, type definitions, five logical combinators,
and a single optimisation call. Everything you write is guaranteed to
compile to a linear/MILP model; if you accidentally introduce
non-linear logic, Lynx aborts at compile-time.
0. Comment syntax
// single-line comment
/*
multi
line
comment
*/
1. Primitives and value ranges
| Kind | Syntax | Notes |
|---|---|---|
| Boolean decision | Bool() |
Built‑in 0 / 1 variable; shorthand: x = 0..1 is equivalent to x = Bool() |
| Bounded integer | Integer(min, max) |
Decision ∈ [min,max]. |
| Named range | alias Week = Integer<1,52> |
Pure type synonym. |
| Sub-range variable | Week(lo, hi) |
Bounds tightened at declaration time. |
| Protected bounds | every var exposeslower,upper |
After solve:lower == upper. |
2. Defining types
Before the syntax, here is what the example domain means:
- Color — a single Boolean decision (pick or don’t pick). It carries one attribute
tagso we can refer to colours symbolically. - Wheel — another Boolean decision with attributes
tag(e.g.front,back) andweight. Weight is numeric so we can penalise heavy wheels later. - Price — a composite variable of type
All; it is true only if both of its children are true (colors,wheel). It holds a single scalar attributeprice. Modellers can hang an unbounded set ofPricenodes under a bicycle, one per colour–wheel combination. - Bicycle — the top‑level decision we want to optimise. It is also an
All, meaning the bike exists iff itscolorsandwheelsrules are satisfied. Extra attributes:@totalPrice— linear aggregate of all activePricenodes; safe for the MILP objective.@heavyPen— linear penalty based on any wheel weighing > 3 kg.@nlMetric— a deliberately non‑linear attribute, computed after the solve for reporting.
With the semantics clear, here’s how that maps to Lynx syntax:
// leaf types
type Color : Bool {
@tag : str,
}
type Wheel : Bool {
@tag : str,
@weight : float,
}
type Price : All {
colors : Any[Color],
wheel : Wheel,
@price : float,
}
// composite type
type Bicycle : All {
colors : ColorRule,
wheels : WheelRule,
prices : Free[Price],
@totalPrice : float = (Bicycle b) -> sum(b.prices.price),
@heavyPen : float = (Bicycle b) -> 50 * Any(filter(b.wheels, w -> w.weight > 3.0)),
@nlMetric : float = (Bicycle b) -> sum(b.prices.price) - 1.2 * b.heavyPen,
}
typedeclares either a leaf (Bool / Integer) or a composite(logical operator after the colon).- Fields are separated by commas so you may keep everything on oneline if you like.
- Attributes are marked only at the declaration site with
@; youaccess them via normal dot notation (wheel.weight). - A right-hand side may be:
-
a literal
-
a lambda that resolves to a constant at call-time (compile-time
for attributes, run-time for relationships).
-
2.1 Logical combinators (run-time)
| Name | Set form | Variadic form | Meaning |
|---|---|---|---|
All |
All[S] |
All(x1,…,xn) |
AND – true iffeverychild true |
Any |
Any[S] |
Any(x1,…,xn) |
OR – true iffat least onechild true |
Not |
Not[S] |
Not(x1,…,xn) |
NOR – true iffallchildren false |
Exactly<k> |
Exactly<k>[S] |
Exactly<k>(x)⇔x == k |
Cardinality / equality |
AtMost<k> |
AtMost<k>[S] |
AtMost<k>(x) |
≤ k |
AtLeast<k> |
AtLeast<k>[S] |
AtLeast<k>(x) |
≥ k |
Free |
Free[S] |
Free(x1,…,xn) |
Children areunconstrained; truth values propagate as-is |
Each call returns a Boolean variable. Inside suchThat you simply provide the expression; Lynx implicitly locks it to true (1). You never write == 1; inside
suchThat Lynx implicitly forces the given Boolean to true.
3 Compile-time helpers
| Helper | Purpose | Allowed operators |
|---|---|---|
filter(context, λ) |
static subset;contextmay be*(root) or any set expression |
&&, ` |
firstTrue() |
on any set; returns the first member whose solved value is true | – |
A lambda is any (param) -> expr expression. Inside filter it must
be static (no decision values). Inside attributes it may reference
other attributes but must stay linear if the attribute itself is used
in objective or suchThat.
3.2 Spread operator ...
...expr unpacks a static set (or list) into positional arguments when calling a constructor. Common use-case: turning the result of filter into the child list expected by Any, All, Exactly, or a rule constructor.
reds = filter(*, c -> c.tag == "red")
rule = ColorRule(...reds) // same as ColorRule(red1, red2, …)
The operator works only on compile-time contexts (e.g., inside constructor calls or in default field values) and is a no-op after expansion: the compiler replaces it with the individual elements.
3.1 Detecting non-linearity
If an @attribute is referenced in the optimisation layer and the
compiler cannot linearise the expression, Lynx aborts with a clear error
message. Non-linear attributes are still computed post-solve for
reporting.
4 Optimisation call
solution = minimize(
objective = <scalar expr>, // MILP-safe
suchThat = <Boolean expr>, // implicitly = true
options = { timeLimit = 300 } // optional
)
maximizemirrorsminimize.- Build the Boolean with the combinators; wrap with
Not( … )to force false. - Named keys inside the Boolean (
bicycle = myBike) create local references for readability (identical to using the expression directly).
5 End-to-end example
// leaf types
type Color : Bool { @tag : str, }
type Wheel : Bool { @tag : str, @weight : float }
type Price : All {
colors : Any[Color],
wheel : Wheel,
@price : float,
}
alias ColorRule = Exactly<1>[Color]
alias WheelRule = Exactly<1>[Wheel]
// composite
type Bicycle : All {
colors : ColorRule,
wheels : WheelRule,
prices : Free[Price],
@totalPrice : float = (Bicycle b) -> sum(b.prices.price),
}
// data
red = Color(tag = "red")
front = Wheel(tag = "front", weight = 3.2)
price1 = Price(colors = Any(red), wheel = front, price = 150)
// compile-time sets
aRed = filter(*, c -> c.tag == "red")
frontW = filter(*, w -> w.tag == "front")
myBike = Bicycle(
colors = ColorRule(...aRed),
wheels = WheelRule(...frontW),
prices = Free(price1),
)
alias Week = Integer<1,52>
productionWeek = Week(4,32)
solution = minimize(
objective = myBike.totalPrice,
suchThat = All(
myBike,
Exactly<12>(productionWeek),
Has(frontW.tag, "front"),
),
)
chosenColour = solution.colors.firstTrue()
The solver returns a new, solved instance of Bicycle; you drill down
through dot access, firstTrue(), or filter(..., v -> v.lower == 1) to
inspect any decision.
6 Key guarantees
- Linear-safety – optimisation layer refuses non-linear expressions.
- No hidden operators – only five combinators and the helpers above.
- Immutable modelling – optimisation call never mutates your originalobjects; results are returned as new solved instances.
Enjoy modelling with Lynx—minimal syntax, maximal clarity.
Dependencies
~9–14MB
~237K SLoC