Extending Goal Programming

Guide for extending LumiX’s goal programming functionality.

Adding Custom Goal Types

Extended Goal Metadata

Create custom metadata with additional attributes:

from dataclasses import dataclass
from lumix.goal_programming import LXGoalMetadata
from lumix.core.enums import LXConstraintSense

@dataclass
class LXAsymmetricGoalMetadata(LXGoalMetadata):
    """Goal with asymmetric deviation penalties."""

    pos_penalty: float = 1.0
    neg_penalty: float = 1.0

    def get_deviation_penalty(self, deviation_type: str) -> float:
        """Get penalty for deviation type."""
        if deviation_type == "pos":
            return self.pos_penalty
        elif deviation_type == "neg":
            return self.neg_penalty
        else:
            raise ValueError(f"Unknown deviation type: {deviation_type}")

Usage:

# Under-stock is 3× worse than over-stock
metadata = LXAsymmetricGoalMetadata(
    priority=1,
    weight=1.0,
    constraint_sense=LXConstraintSense.EQ,
    pos_penalty=1.0,  # Over-stock penalty
    neg_penalty=3.0   # Under-stock penalty (worse)
)

Conditional Goals

Goals that activate based on conditions:

@dataclass
class LXConditionalGoalMetadata(LXGoalMetadata):
    """Goal that activates conditionally."""

    activation_func: Callable[[Any], bool]

    def is_active(self, instance: Any) -> bool:
        """Check if goal is active for this instance."""
        return self.activation_func(instance)

Usage:

# Only apply goal to high-value customers
metadata = LXConditionalGoalMetadata(
    priority=1,
    weight=1.0,
    constraint_sense=LXConstraintSense.GE,
    activation_func=lambda customer: customer.lifetime_value > 10000
)

Custom Relaxation Strategies

Bounded Deviations

Limit maximum allowed deviation:

from lumix.goal_programming import relax_constraint, RelaxedConstraint
from typing import Optional

def relax_with_bounds(
    constraint: LXConstraint[TModel],
    metadata: LXGoalMetadata,
    max_pos_deviation: Optional[float] = None,
    max_neg_deviation: Optional[float] = None
) -> RelaxedConstraint[TModel]:
    """
    Relax constraint with bounded deviations.

    Args:
        constraint: Constraint to relax
        metadata: Goal metadata
        max_pos_deviation: Upper bound for positive deviation
        max_neg_deviation: Upper bound for negative deviation

    Returns:
        RelaxedConstraint with bounded deviation variables
    """
    # Standard relaxation
    relaxed = relax_constraint(constraint, metadata)

    # Add bounds
    if max_pos_deviation is not None:
        relaxed.pos_deviation.upper_bound = max_pos_deviation

    if max_neg_deviation is not None:
        relaxed.neg_deviation.upper_bound = max_neg_deviation

    return relaxed

Usage:

# Overtime can exceed target by at most 10 hours
overtime_goal = (
    LXConstraint("overtime")
    .expression(hours_expr)
    .le()
    .rhs(40)
)

metadata = LXGoalMetadata(
    priority=2,
    weight=1.0,
    constraint_sense=LXConstraintSense.LE
)

relaxed = relax_with_bounds(
    overtime_goal,
    metadata,
    max_pos_deviation=10.0  # Can't exceed by more than 10
)

Soft Hard Constraints

Goals that become hard constraints at threshold:

def relax_with_hard_limit(
    constraint: LXConstraint[TModel],
    metadata: LXGoalMetadata,
    hard_limit: float,
    hard_sense: LXConstraintSense
) -> Tuple[RelaxedConstraint[TModel], LXConstraint[TModel]]:
    """
    Relax constraint but add hard limit.

    Args:
        constraint: Constraint to relax
        metadata: Goal metadata
        hard_limit: Hard limit value
        hard_sense: Sense for hard limit (LE or GE)

    Returns:
        Tuple of (relaxed_constraint, hard_constraint)
    """
    # Relax constraint
    relaxed = relax_constraint(constraint, metadata)

    # Create hard limit constraint
    hard_constraint = (
        LXConstraint[TModel](f"{constraint.name}_hard_limit")
        .expression(constraint.lhs)
        .sense(hard_sense)
        .rhs(hard_limit)
    )

    if constraint._data:
        hard_constraint._data = constraint._data
    if constraint.index_func:
        hard_constraint.index_func = constraint.index_func

    return relaxed, hard_constraint

Usage:

# Target 1000 units (soft), but at least 800 (hard)
demand_goal = (
    LXConstraint[Product]("demand")
    .expression(production_expr)
    .ge()
    .rhs(lambda p: p.demand_target)
    .from_data(products)
)

metadata = LXGoalMetadata(
    priority=1,
    weight=1.0,
    constraint_sense=LXConstraintSense.GE
)

relaxed, hard = relax_with_hard_limit(
    demand_goal,
    metadata,
    hard_limit=lambda p: p.demand_target * 0.8,
    hard_sense=LXConstraintSense.GE
)

model.add_constraint(relaxed.constraint)
model.add_constraint(hard)  # Hard minimum

Custom Objective Builders

Minimax Objective

Minimize maximum deviation:

from lumix.goal_programming import RelaxedConstraint
from lumix.core.variables import LXVariable
from lumix.core.expressions import LXLinearExpression

def build_minimax_objective(
    relaxed_constraints: List[RelaxedConstraint],
    priority: Optional[int] = None
) -> Tuple[LXLinearExpression, LXVariable, List[LXConstraint]]:
    """
    Build minimax objective: minimize max deviation.

    Args:
        relaxed_constraints: Relaxed constraints
        priority: Only include goals at this priority (None = all)

    Returns:
        Tuple of (objective, max_var, auxiliary_constraints)
    """
    # Filter by priority if specified
    if priority is not None:
        relaxed_constraints = [
            r for r in relaxed_constraints
            if r.goal_metadata.priority == priority
        ]

    # Create max deviation variable
    max_dev = (
        LXVariable[None, float]("max_deviation")
        .continuous()
        .bounds(lower=0)
    )

    # Auxiliary constraints: max_dev >= each deviation
    aux_constraints = []

    for i, relaxed in enumerate(relaxed_constraints):
        # For each undesired deviation
        for dev_var in relaxed.get_undesired_variables():
            # max_dev >= dev_var
            constraint = (
                LXConstraint(f"max_dev_constraint_{i}")
                .expression(
                    LXLinearExpression()
                    .add_term(max_dev, coeff=1.0)
                    .add_term(dev_var, coeff=-1.0)
                )
                .ge()
                .rhs(0)
            )
            aux_constraints.append(constraint)

    # Objective: minimize max_dev
    objective = LXLinearExpression().add_term(max_dev, coeff=1.0)

    return objective, max_dev, aux_constraints

Usage:

# Minimize worst-case deviation
objective, max_var, aux_constraints = build_minimax_objective(relaxed_list)

model.add_variable(max_var)
for constraint in aux_constraints:
    model.add_constraint(constraint)
model.minimize(objective)

Weighted Sum of Squared Deviations

from lumix.core.expressions import LXQuadraticExpression, LXQuadraticTerm

def build_quadratic_objective(
    relaxed_constraints: List[RelaxedConstraint]
) -> LXQuadraticExpression:
    """
    Build quadratic objective: minimize sum of squared deviations.

    Note: Requires solver with quadratic objective support.
    """
    objective = LXQuadraticExpression()

    for relaxed in relaxed_constraints:
        priority_weight = priority_to_weight(relaxed.goal_metadata.priority)
        goal_weight = relaxed.goal_metadata.weight
        combined_weight = priority_weight * goal_weight

        # Add squared deviation terms
        for dev_var in relaxed.get_undesired_variables():
            # Add dev_var^2 with weight
            quad_term = LXQuadraticTerm(dev_var, dev_var, combined_weight)
            objective.add_quadratic_term(quad_term)

    return objective

Custom Solving Modes

Hybrid Mode

Weighted within priorities, sequential across priorities:

class LXHybridGoalProgrammingSolver:
    """Hybrid: weighted within priority, sequential across."""

    def __init__(self, optimizer: LXOptimizer):
        self.optimizer = optimizer

    def solve_hybrid(
        self,
        model: LXModel[TModel],
        relaxed_constraints: List[RelaxedConstraint[TModel]]
    ) -> LXSolution[TModel]:
        """
        Solve using hybrid approach.

        1. Group goals by priority
        2. For each priority, build weighted objective for that priority
        3. Solve sequentially, fixing higher priority deviations
        """
        # Group by priority
        from collections import defaultdict
        priority_groups = defaultdict(list)

        for relaxed in relaxed_constraints:
            priority = relaxed.goal_metadata.priority
            priority_groups[priority].append(relaxed)

        # Solve each priority
        final_solution = None

        for priority in sorted(priority_groups.keys()):
            if priority == 0:
                continue  # Skip custom objectives

            # Build weighted objective for this priority only
            priority_relaxed = priority_groups[priority]
            objective = build_weighted_objective(
                priority_relaxed,
                base=1.0,  # No exponential scaling within priority
                exponent_offset=0
            )

            # Set objective
            model.objective_expr = objective
            model.objective_sense = LXObjectiveSense.MINIMIZE

            # Solve
            solution = self.optimizer.solve(model)

            if not solution.is_optimal():
                return solution

            # Fix deviations for next priority
            # (implementation similar to sequential solver)

            final_solution = solution

        return final_solution

Epsilon-Constraint Method

def solve_epsilon_constraint(
    model: LXModel[TModel],
    optimizer: LXOptimizer,
    primary_goal: str,
    secondary_goals: List[Tuple[str, float]]  # (goal_name, epsilon)
) -> LXSolution[TModel]:
    """
    Epsilon-constraint method for multi-objective optimization.

    Args:
        model: Model with goals
        optimizer: Optimizer
        primary_goal: Goal to optimize
        secondary_goals: List of (goal_name, max_deviation) constraints

    Returns:
        Solution optimizing primary goal subject to epsilon constraints
    """
    # Add epsilon constraints for secondary goals
    for goal_name, epsilon in secondary_goals:
        # Find relaxed constraint for this goal
        relaxed = next(
            r for r in model._relaxed_constraints
            if r.constraint.name == goal_name
        )

        # Add constraint: total_deviation <= epsilon
        epsilon_constraint = (
            LXConstraint(f"{goal_name}_epsilon")
            .expression(
                LXLinearExpression()
                .add_term(relaxed.pos_deviation, coeff=1.0)
                .add_term(relaxed.neg_deviation, coeff=1.0)
            )
            .le()
            .rhs(epsilon)
        )

        model.add_constraint(epsilon_constraint)

    # Build objective for primary goal only
    primary_relaxed = next(
        r for r in model._relaxed_constraints
        if r.constraint.name == primary_goal
    )

    objective = LXLinearExpression()
    for dev_var in primary_relaxed.get_undesired_variables():
        objective.add_term(dev_var, coeff=1.0)

    model.minimize(objective)

    return optimizer.solve(model)

Testing Extensions

Unit Tests

import pytest
from lumix.goal_programming import LXGoalMetadata, relax_constraint
from lumix.core.enums import LXConstraintSense

def test_asymmetric_goal_metadata():
    """Test asymmetric deviation penalties."""
    metadata = LXAsymmetricGoalMetadata(
        priority=1,
        weight=1.0,
        constraint_sense=LXConstraintSense.EQ,
        pos_penalty=1.0,
        neg_penalty=3.0
    )

    assert metadata.get_deviation_penalty("pos") == 1.0
    assert metadata.get_deviation_penalty("neg") == 3.0

def test_bounded_relaxation():
    """Test relaxation with bounds."""
    constraint = build_test_constraint()
    metadata = LXGoalMetadata(1, 1.0, LXConstraintSense.LE)

    relaxed = relax_with_bounds(
        constraint,
        metadata,
        max_pos_deviation=10.0
    )

    assert relaxed.pos_deviation.upper_bound == 10.0

Integration Tests

def test_minimax_objective():
    """Test minimax objective builder."""
    model = build_test_model()
    relaxed_list = get_relaxed_constraints(model)

    objective, max_var, aux_constraints = build_minimax_objective(relaxed_list)

    # Add to model
    model.add_variable(max_var)
    for constraint in aux_constraints:
        model.add_constraint(constraint)
    model.minimize(objective)

    solution = optimizer.solve(model)

    assert solution.is_optimal()

    # Verify max_var captures maximum deviation
    max_deviation = solution.get_variable(max_var)
    for relaxed in relaxed_list:
        deviations = solution.get_goal_deviations(relaxed.constraint.name)
        # All deviations should be <= max_deviation
        assert all(v <= max_deviation + 1e-6 for v in deviations['pos'].values())
        assert all(v <= max_deviation + 1e-6 for v in deviations['neg'].values())

Documentation

Docstring Template

Use Google-style docstrings:

def custom_relaxation_function(
    constraint: LXConstraint[TModel],
    metadata: LXGoalMetadata,
    custom_param: float
) -> RelaxedConstraint[TModel]:
    """
    One-line summary of custom relaxation.

    Longer description explaining the relaxation strategy,
    when to use it, and any special considerations.

    Args:
        constraint: Constraint to relax
        metadata: Goal metadata with priority and weight
        custom_param: Description of custom parameter

    Returns:
        RelaxedConstraint with custom relaxation applied

    Raises:
        ValueError: If custom_param is invalid

    Examples:
        Basic usage::

            constraint = LXConstraint("demand").expression(...).ge().rhs(100)
            metadata = LXGoalMetadata(priority=1, weight=1.0, ...)
            relaxed = custom_relaxation_function(constraint, metadata, 0.5)

    Note:
        Any important notes or warnings about the function.

    See Also:
        - :func:`~lumix.goal_programming.relaxation.relax_constraint`
        - Related documentation
    """

Adding to Documentation

  1. API Reference: Add autodoc to docs/source/api/goal_programming/index.rst

  2. User Guide: Add usage examples to appropriate guide

  3. Development Guide: Document architecture and design decisions

Contributing Guidelines

Code Style

Follow existing patterns:

  • Use Google-style docstrings

  • Type all function signatures

  • Use fluent API patterns where appropriate

  • Follow naming conventions (LX prefix for public classes)

Example:

from typing_extensions import Self
from dataclasses import dataclass

@dataclass
class LXCustomGoal:
    """Custom goal type."""

    name: str
    priority: int

    def with_priority(self, priority: int) -> Self:
        """Set priority (fluent API)."""
        self.priority = priority
        return self

Testing Requirements

All extensions must have:

  • Unit tests (>90% coverage)

  • Integration tests with actual optimization

  • Type annotations and mypy compliance

  • Comprehensive docstrings

Pull Request Process

  1. Fork the repository

  2. Create feature branch: git checkout -b feature/custom-goal-type

  3. Add tests and documentation

  4. Run full test suite: pytest tests/

  5. Run type checker: mypy src/lumix/goal_programming

  6. Submit PR with description of changes and motivation

Best Practices

  1. Maintain Type Safety

    # Good: Full type annotations
    def custom_function(
        constraint: LXConstraint[TModel],
        metadata: LXGoalMetadata
    ) -> RelaxedConstraint[TModel]:
        ...
    
    # Bad: Missing types
    def custom_function(constraint, metadata):
        ...
    
  2. Follow Existing Patterns

    # Study existing code in lumix.goal_programming
    # Match architectural patterns
    # Reuse utilities like priority_to_weight, get_deviation_var_name
    
  3. Document Thoroughly

    • Explain why, not just what

    • Provide usage examples

    • Document edge cases and limitations

  4. Test Edge Cases

    def test_edge_cases():
        # Empty constraint list
        # Single priority
        # All same priority
        # Priority 0 only
        # Mixed indexed and non-indexed
        ...
    

Next Steps