Core Architecture¶
Deep dive into the core module’s architecture and design patterns.
Design Philosophy¶
The core module implements a data-driven, type-safe approach to optimization modeling using three key patterns:
Builder Pattern: Fluent API with method chaining
Family Pattern: Variables/constraints as families, not individuals
Late Binding: Expansion happens during solving, not construction
Architecture Overview¶
classDiagram
class LXModel {
+name: str
+variables: List[LXVariable]
+constraints: List[LXConstraint]
+objective_expr: Expression
+add_variable(var)
+add_constraint(const)
+maximize(expr)
+minimize(expr)
}
class LXVariable {
+name: str
+var_type: LXVarType
+bounds: tuple
+_data: List
+continuous()
+integer()
+binary()
+from_data(data)
+indexed_by(func)
}
class LXConstraint {
+name: str
+lhs: Expression
+sense: ConstraintSense
+rhs_value: float
+expression(expr)
+le() / ge() / eq()
+rhs(value)
+from_data(data)
}
class LXLinearExpression {
+terms: Dict
+constant: float
+add_term(var, coeff)
+add_constant(value)
}
LXModel --> LXVariable
LXModel --> LXConstraint
LXConstraint --> LXLinearExpression
LXLinearExpression --> LXVariable
Component Details¶
LXVariable: The Family Pattern¶
Key Insight: An LXVariable is NOT a single variable - it’s a template that expands
to multiple solver variables.
Implementation:
@dataclass
class LXVariable(Generic[TModel, TValue]):
name: str
var_type: LXVarType
_data: Optional[List[TModel]] = None
index_func: Optional[Callable[[TModel], TIndex]] = None
def get_instances(self) -> List[TModel]:
"""Get data instances for expansion."""
if self._data is not None:
return self._data
# ... ORM query logic ...
Expansion (happens in solver):
# User creates ONE LXVariable
production = LXVariable[Product, float]("production").from_data(products)
# Solver expands to MANY solver variables
for instance in production.get_instances():
index = production.index_func(instance)
solver_var = solver.create_var(f"{production.name}[{index}]")
Benefits:
No manual loops in user code
Type-safe coefficients via lambdas
Automatic indexing
Late binding (solver-agnostic)
LXConstraint: Indexed Constraints¶
Similar family pattern for constraints:
@dataclass
class LXConstraint(Generic[TModel]):
name: str
lhs: Optional[LXLinearExpression] = None
sense: LXConstraintSense = LXConstraintSense.LE
rhs_func: Optional[Callable[[TModel], float]] = None
_data: Optional[List[TModel]] = None
Single vs. Family:
# Single constraint (no indexing)
total_budget = LXConstraint("budget").expression(...).le().rhs(1000)
# Constraint family (indexed by Resource)
capacity = (
LXConstraint[Resource]("capacity")
.expression(...)
.le()
.rhs(lambda r: r.capacity) # Data-driven RHS
.from_data(resources)
)
LXExpression: Coefficient Functions¶
Expressions store coefficient functions, not values:
@dataclass
class LXLinearExpression(Generic[TModel]):
terms: Dict[str, Tuple[LXVariable, Callable, Callable]]
constant: float = 0.0
def add_term(self, var, coeff, where=None):
coeff_func = coeff if callable(coeff) else lambda _: coeff
self.terms[var.name] = (var, coeff_func, where)
Evaluation (happens in solver):
# User provides lambda
expr.add_term(production, lambda p: p.profit)
# Solver evaluates for each instance
for instance in production.get_instances():
coefficient = coeff_func(instance) # p.profit evaluated here
solver_expr.add_term(solver_var, coefficient)
Multi-Model Terms:
When a constraint references multiple variable families:
# Lambda receives instances from BOTH dimensions
.add_term(production, lambda p, r: p.usage[r.id])
# p: from production variable
# r: from constraint indexing
Type System¶
Generics for Type Safety¶
TModel = TypeVar("TModel") # Data model type
TValue = TypeVar("TValue", int, float) # Variable value type
class LXVariable(Generic[TModel, TValue]):
...
# Usage
production = LXVariable[Product, float]("production")
# TModel = Product
# TValue = float
Benefits:
IDE autocomplete in lambdas
mypy type checking
Self-documenting code
Fluent API Implementation¶
All methods return Self for chaining:
from typing_extensions import Self
class LXVariable:
def continuous(self) -> Self:
self.var_type = LXVarType.CONTINUOUS
return self
def bounds(self, lower, upper) -> Self:
self.lower_bound = lower
self.upper_bound = upper
return self
Usage:
production = (
LXVariable[Product, float]("production")
.continuous() # Returns self
.bounds(lower=0) # Returns self
.from_data(data) # Returns self
)
Data Flow¶
Model Building Phase¶
sequenceDiagram
participant User
participant Variable
participant Expression
participant Model
User->>Variable: from_data(products)
Note over Variable: Stores data reference
User->>Expression: add_term(var, lambda)
Note over Expression: Stores lambda
User->>Model: add_variable(var)
Note over Model: Stores variable family
User->>Model: add_constraint(expr)
Note over Model: Stores constraint
Key Point: Nothing is expanded yet. We only store templates.
Solving Phase¶
sequenceDiagram
participant Solver
participant Model
participant Variable
participant Expression
Solver->>Model: solve(model)
Model->>Variable: get_instances()
Variable-->>Model: [product1, product2, ...]
loop For each instance
Solver->>Solver: Create solver variable
Solver->>Expression: Evaluate lambda(instance)
Expression-->>Solver: coefficient value
end
Key Point: Expansion and evaluation happen here.
Extension Points¶
Custom Variable Types¶
Subclass LXVariable for specialized behavior:
class LXSemiContinuousVariable(LXVariable[TModel, float]):
"""Variable that is either 0 or in [L, U]."""
def __init__(self, name: str):
super().__init__(name)
self.var_type = LXVarType.CONTINUOUS
def semi_continuous(self, lower: float, upper: float) -> Self:
# Implementation
return self
Custom Expressions¶
Subclass expression classes for new term types:
class LXConicExpression(LXLinearExpression):
"""Second-order cone expression."""
def add_cone(self, vars: List[LXVariable]) -> Self:
# Implementation
return self
Performance Considerations¶
Late Binding Overhead¶
Trade-off: Late binding adds overhead but provides flexibility.
Mitigation:
Lambda evaluation is cached where possible
Data is stored as references, not copied
Expansion happens once per solve
Memory Usage¶
Family Storage:
Storing families (metadata) is cheap
Actual solver variables created only during solving
Large models: Memory scales with data, not code
Optimization:
Use filters (
where()) to reduce expansionIndex efficiently (simple keys better than complex tuples)
Testing Strategy¶
Unit Tests¶
Test individual components:
def test_variable_continuous():
var = LXVariable[Product, float]("x").continuous()
assert var.var_type == LXVarType.CONTINUOUS
Integration Tests¶
Test end-to-end workflows:
def test_production_model():
model = build_production_model(products)
solution = optimizer.solve(model)
assert solution.is_optimal()
Type Tests¶
Use mypy for static type checking:
mypy src/lumix/core
Next Steps¶
Extending Core Components - How to add new features
Design Decisions - Why things work this way
Core Module API - Full API reference