Building a constraint-based rate rule evaluator in Python

This guide builds a small, production-shaped rate rule evaluator: a function that takes a room-night’s decision context and an ordered set of rules and returns a final rate with a full trace of what happened. It is the concrete implementation behind the Rule-Based Pricing Engines component of the Dynamic Pricing Rule Engines & Optimization pillar. The goal is not a general expression language but a constrained, testable evaluator that separates adjustments from constraints and is deterministic enough to unit-test.

Prerequisites

  • Python 3.11+ (for from __future__ import annotations ergonomics and modern typing)
  • Standard library only — dataclasses, enum, logging — no third-party dependencies
  • A decision context feed expressed in your rate taxonomy, as defined in Rate Plan Structuring & Mapping
  • Monetary values in integer minor units (cents) to avoid floating-point drift
  • A logging configuration that ships structured records to your observability stack

Step 1 — Model the context and rules as typed objects

Before evaluating anything, pin down the data. A rule that operates on stringly-typed dictionaries is impossible to validate; typed objects let you reject nonsense at load time. We separate the effect kinds because adjustments and constraints combine differently — adjustments accumulate, constraints clamp.

python
from __future__ import annotations

import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable

logger = logging.getLogger("pricing.rules")


class Effect(Enum):
    MULTIPLIER = "multiplier"
    DELTA = "delta"
    FLOOR = "floor"
    CEILING = "ceiling"


@dataclass(frozen=True)
class Context:
    room_type: str
    stay_date: str
    is_weekend: bool
    pace_ratio: float
    base_rate_cents: int


@dataclass(frozen=True)
class Rule:
    rule_id: str
    priority: int
    effect: Effect
    value: float
    applies: Callable[[Context], bool]

Freezing the dataclasses makes contexts hashable and safe to cache, and guarantees a rule cannot mutate the context it inspects.

Step 2 — Validate rules before they can run

The cheapest bug to fix is the one caught at load time. Validate the ruleset once, on load, so a malformed rule never reaches a live rate. This is the same check you will later wire into CI.

python
def validate_rules(rules: list[Rule]) -> None:
    seen_priorities: dict[int, str] = {}
    for rule in rules:
        if rule.effect is Effect.MULTIPLIER and rule.value <= 0:
            raise ValueError(f"{rule.rule_id}: multiplier must be positive")
        if rule.effect in (Effect.FLOOR, Effect.CEILING) and rule.value < 0:
            raise ValueError(f"{rule.rule_id}: bound must be non-negative")
        if rule.priority in seen_priorities:
            # equal priorities make ordering non-deterministic
            logger.warning("duplicate priority %d: %s and %s",
                           rule.priority, seen_priorities[rule.priority], rule.rule_id)
        seen_priorities[rule.priority] = rule.rule_id

Duplicate priorities are the classic source of rates that “flap” between refreshes; flagging them here explains the why before you hit it in production.

Step 3 — Evaluate adjustments, then clamp constraints

The evaluation order is the whole design. Apply every eligible adjustment in priority order first, accumulating a running rate, then apply constraints strictly afterward so a floor or ceiling can never be undone by a subsequent multiplier.

python
@dataclass
class Decision:
    rate_cents: int
    trace: list[str] = field(default_factory=list)


def evaluate(ctx: Context, rules: list[Rule]) -> Decision:
    rate = float(ctx.base_rate_cents)
    trace = [f"base={ctx.base_rate_cents}"]
    ordered = sorted(rules, key=lambda r: r.priority)

    for rule in ordered:
        if rule.effect not in (Effect.MULTIPLIER, Effect.DELTA) or not rule.applies(ctx):
            continue
        rate = rate * rule.value if rule.effect is Effect.MULTIPLIER else rate + rule.value
        trace.append(f"{rule.rule_id}->{round(rate)}")

    floor = max((r.value for r in ordered
                 if r.effect is Effect.FLOOR and r.applies(ctx)), default=None)
    ceiling = min((r.value for r in ordered
                   if r.effect is Effect.CEILING and r.applies(ctx)), default=None)
    if floor is not None and ceiling is not None and floor > ceiling:
        raise ValueError(f"infeasible band for {ctx.room_type}: {floor} > {ceiling}")
    if floor is not None and rate < floor:
        rate, _ = floor, trace.append(f"clamp_floor={floor}")
    if ceiling is not None and rate > ceiling:
        rate, _ = ceiling, trace.append(f"clamp_ceiling={ceiling}")

    decision = Decision(round(rate), trace)
    logger.info("priced %s %s -> %d (%s)", ctx.room_type, ctx.stay_date,
                decision.rate_cents, " | ".join(trace))
    return decision

Verification and testing

The evaluator’s value is that it is deterministic, so test it as such. Assert both the numeric result and that the guardrail clamps last.

python
def test_floor_beats_discount() -> None:
    ctx = Context("suite", "2026-08-01", True, 0.4, base_rate_cents=20000)
    rules = [
        Rule("weekend", 10, Effect.MULTIPLIER, 1.15, lambda c: c.is_weekend),
        Rule("soft_demand", 20, Effect.MULTIPLIER, 0.5, lambda c: c.pace_ratio < 0.6),
        Rule("contract_floor", 99, Effect.FLOOR, 15000, lambda c: True),
    ]
    validate_rules(rules)
    decision = evaluate(ctx, rules)
    # 20000 * 1.15 * 0.5 = 11500, below the 15000 floor -> clamped
    assert decision.rate_cents == 15000
    assert decision.trace[-1] == "clamp_floor=15000"

Run it with pytest -q; a green result confirms the clamp ordering holds even when adjustments would otherwise push the rate below the contracted floor.

Common pitfalls and edge cases

  • Equal priorities. Two rules at the same priority evaluate in an undefined order after sort ties; enforce unique priorities in validation.
  • Float drift on repeated multipliers. Keep money in integer cents and only round once, at the end, or long rule chains accumulate sub-cent error.
  • Infeasible bands. A floor above a ceiling should raise, not silently pick one; a franchise floor colliding with a promotional ceiling is a real production case.
  • Non-pure predicates. A rule whose applies reads the clock or a mutable global breaks reproducibility — keep predicates functions of the context only.
  • Missing ceilings. A context with adjustments but no ceiling can run away; assert every context is covered by at least one ceiling rule.