Design Decisions¶
Understanding the “why” behind LumiX’s design.
Variable Families vs. Individual Variables¶
Decision: Variables are families that expand automatically.
Traditional Approach¶
# Manual loops, error-prone
x = {}
for i, product in enumerate(products):
x[i] = model.addVar(name=f"x_{i}")
LumiX Approach¶
# Declarative, type-safe
production = LXVariable[Product, float]("production").from_data(products)
Rationale:
Elimination of Manual Loops: Reduces boilerplate and bugs
Type Safety: Lambda parameters are typed (IDE autocomplete)
Data-Driven: Variables naturally map to business entities
Solver Agnostic: Expansion logic separated from declaration
Maintainability: Changes to data structure don’t require code changes
Trade-offs:
✗ Slightly more memory for storing families
✗ Learning curve for family concept
✓ Far fewer bugs from manual indexing
✓ Much more readable code
✓ Better IDE support
Late Binding (Lazy Expansion)¶
Decision: Variables/constraints expand during solving, not construction.
Why Late Binding?¶
# Construction phase - only stores template
production = LXVariable[Product, float]("production").from_data(products)
# Solving phase - expands to solver variables
solution = optimizer.solve(model)
Rationale:
Solver Independence: Same model works with any solver
Flexibility: Can modify data before solving
Memory Efficiency: Don’t create solver vars until needed
Testing: Can validate model structure without solver
Trade-offs:
✗ Can’t inspect solver variables before solving
✓ Models are portable across solvers
✓ Can validate logic without solver
✓ Reduced memory footprint
Lambda Functions for Coefficients¶
Decision: Coefficients are lambdas, not values.
Example¶
# Lambda evaluated for each product
expr.add_term(production, lambda p: p.unit_cost * p.tax_rate)
Rationale:
Type Safety: IDE knows p is a Product
Expressiveness: Complex calculations inline
Data-Driven: Coefficients come from data
Lazy Evaluation: Computed only when needed
Alternatives Considered:
Dictionary Approach¶
# Rejected: Verbose, error-prone
coeffs = {p.id: p.unit_cost * p.tax_rate for p in products}
expr.add_term(production, coeffs)
Why lambdas won:
✓ Type-safe (mypy checks lambda body)
✓ Concise (one line vs. two)
✓ Fewer opportunities for index mismatches
Method Chaining (Fluent API)¶
Decision: All core classes use fluent API.
Example¶
production = (
LXVariable[Product, float]("production")
.continuous()
.bounds(lower=0)
.from_data(products)
)
Rationale:
Readability: Reads like a sentence
Discoverability: IDE suggests next methods
Immutability-Like: Each method returns modified self
Consistency: Same pattern across all classes
Alternatives Considered:
Named Arguments¶
# Rejected: Less discoverable
production = LXVariable(
name="production",
var_type=VarType.CONTINUOUS,
lower_bound=0,
data=products
)
Why fluent API won:
✓ Better IDE support (suggests next method)
✓ More flexible (methods can have complex logic)
✓ Easier to extend (add new methods)
Generic Type Parameters¶
Decision: Extensive use of Generic[TModel, TValue].
Example¶
production = LXVariable[Product, float]("production")
# TModel = Product, TValue = float
Rationale:
IDE Autocomplete: In lambdas, IDE knows types
Static Typing: mypy catches errors
Self-Documenting: Types explicit in code
Refactoring: Safer to rename attributes
Trade-offs:
✗ More verbose type annotations
✗ Generic syntax can be intimidating
✓ Catches errors at development time
✓ Makes code self-documenting
✓ Excellent IDE experience
Data-Driven vs. Imperative¶
Decision: Prefer data-driven declarative style.
Data-Driven (LumiX)¶
# Declare what you want
production = LXVariable[Product, float]("production").from_data(products)
capacity = LXConstraint[Resource]("capacity")\\
.expression(...)\\
.le()\\
.rhs(lambda r: r.capacity)\\
.from_data(resources)
Imperative (Traditional)¶
# Describe how to build it
x = {}
for i, product in enumerate(products):
x[i] = model.addVar()
for j, resource in enumerate(resources):
model.addConstr(
sum(x[i] * usage[i][j] for i in range(len(products)))
<= resource.capacity
)
Rationale:
Clarity: What vs. How
Maintainability: Less code to break
Testability: Easier to unit test
Separation: Model logic separate from data
Single Module Import¶
Decision: Import everything from top-level lumix.
Example¶
from lumix import (
LXModel,
LXVariable,
LXConstraint,
LXLinearExpression,
)
Rationale:
Simplicity: One import location
Discoverability: IDE shows all available items
Stability: Can reorganize internals without breaking imports
Implementation:
All public classes exported via __init__.py:
# src/lumix/__init__.py
from .core import LXModel, LXVariable, LXConstraint
from .solvers import LXOptimizer
# ...
Solver Abstraction¶
Decision: Unified interface across all solvers.
Interface¶
class LXSolverInterface:
def create_model(self, lx_model: LXModel) -> Any:
"""Create solver-specific model."""
def solve(self) -> LXSolution:
"""Solve and return solution."""
Rationale:
Portability: Switch solvers with one line
Testing: Test with free solver, deploy with commercial
Comparison: Easy to benchmark different solvers
Future-Proof: Add new solvers without changing user code
Trade-offs:
✗ Can’t use solver-specific features directly
✗ Interface must support lowest common denominator
✓ Models are portable
✓ Easy to add new solvers
✓ Users not locked to one solver
Automatic Linearization¶
Decision: Provide automatic linearization for non-linear terms.
Example¶
# User writes non-linear
expr = LXNonLinearExpression()
expr.add_product(x, y) # Bilinear
# LumiX linearizes automatically
linearized = linearizer.linearize(expr)
Rationale:
Accessibility: Use free solvers for non-linear problems
Transparency: Users don’t need to know linearization techniques
Correctness: Implemented once, tested thoroughly
Performance: Optimized implementations
Alternatives:
Manual linearization¶
Rejected because:
✗ Error-prone (McCormick envelopes are tricky)
✗ Verbose (many auxiliary variables and constraints)
✗ Non-portable (different techniques for different terms)
Performance Considerations¶
Late Binding Overhead¶
Concern: Storing lambdas and evaluating them has overhead.
Analysis:
Lambda evaluation: ~10-100 ns
Model building time: Usually <1% of solve time
Large models (10,000+ variables): Overhead ~10-50ms
Decision: Trade-off acceptable because:
Solve time dominates (seconds to hours)
User productivity gain is huge
Can optimize hot paths if needed
Memory Usage¶
Concern: Storing families uses more memory than direct arrays.
Analysis:
Family metadata: ~500 bytes per family
Typical model: 5-20 families
Overhead: <10 KB
Decision: Negligible compared to:
Solver memory (MB to GB)
Data storage (typically larger)
Future Directions¶
Planned Improvements¶
Compile-time validation: Detect errors before running
JIT compilation: Compile lambdas for performance
Incremental solving: Modify and re-solve efficiently
Parallel expansion: Multi-threaded variable creation
Symbolic differentiation: Auto-compute gradients
Research Questions¶
Can we infer index structure from data relationships?
How to best support streaming/online optimization?
What’s the right abstraction for stochastic programming?
Summary¶
LumiX’s design prioritizes:
User Experience: Type safety, IDE support, readability
Maintainability: Less code, fewer bugs, easier refactoring
Portability: Solver-agnostic models
Correctness: Tested implementations of complex techniques
The trade-offs (slight overhead, learning curve) are worth it for the dramatic improvement in development experience and code quality.
Next Steps¶
Core Architecture - See how it’s implemented
Extending Core Components - Build on these principles
Core Concepts - Use the features