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:

  1. Builder Pattern: Fluent API with method chaining

  2. Family Pattern: Variables/constraints as families, not individuals

  3. 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 expansion

  • Index 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