Skip to content

Custom Variables

Abdulkadir Özcan edited this page Mar 23, 2026 · 1 revision

Custom Variables

When none of the built-in types — Continuous, Discrete, Integer, Categorical, or the engineering spaces — fit your domain, you can define your own variable type. harmonix provides three patterns for this, ranging from a quick two-minute prototype to a fully registered, reusable plugin.


The three patterns at a glance

Pattern Best for Boilerplate
Subclass Variable Complex domains, reusable components, production code Medium — one class, three methods
Factory make_variable Quick prototyping, one-off domains, scripting Low — three lambdas or functions
Registry register_variable Sharing types across modules, plugin architectures One decorator on top of either pattern

Pattern 1 — Subclass Variable

Subclassing gives you full control: constructor arguments, internal state, helper methods, and anything else your domain requires. This is the recommended pattern for anything beyond a quick experiment.

from harmonix import Variable, DesignSpace, Minimization
import random

class EvenInteger(Variable):
    """Variable restricted to even integers in [lo, hi]."""

    def __init__(self, lo: int, hi: int):
        if lo % 2 != 0:
            lo += 1   # snap to nearest even
        if hi % 2 != 0:
            hi -= 1
        if lo > hi:
            raise ValueError(f"No even integers in [{lo}, {hi}].")
        self._values = list(range(lo, hi + 1, 2))

    def sample(self, ctx) -> int:
        return random.choice(self._values)

    def filter(self, candidates, ctx):
        valid = set(self._values)
        return [v for v in candidates if v in valid]

    def neighbor(self, value, ctx) -> int:
        if value not in self._values:
            return self.sample(ctx)
        idx = self._values.index(value)
        delta = random.choice([-1, 1])
        new_idx = max(0, min(len(self._values) - 1, idx + delta))
        return self._values[new_idx]


space = DesignSpace()
space.add("n", EvenInteger(4, 20))

Rules for a correct implementation

  • sample must always return a feasible value. It is called both during initialisation (cold start) and when HMCR misses — there is no fallback.
  • filter must never return infeasible values. The optimiser picks from its output without further checking.
  • filter may return an empty list. When it does, the optimiser falls back to sample automatically.
  • neighbor must always return a feasible value. If the supplied value is somehow outside your domain (e.g. after a resume from a checkpoint with different parameters), return self.sample(ctx) as a safe fallback.
  • Use ctx only to read earlier variables. Never write to ctx inside a variable method — the optimiser owns that dict.

Pattern 2 — Factory function make_variable

make_variable creates a Variable subclass from three plain functions. No class declaration needed — useful for quick experimentation or for domains that can be expressed in a few lines.

from harmonix import make_variable, DesignSpace
import random

PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]
PRIME_SET = set(PRIMES)

PrimeVar = make_variable(
    sample   = lambda ctx: random.choice(PRIMES),
    filter   = lambda cands, ctx: [c for c in cands if c in PRIME_SET],
    neighbor = lambda val, ctx: PRIMES[
        max(0, min(len(PRIMES) - 1,
            (PRIMES.index(val) if val in PRIME_SET else 0) + random.choice([-1, 1])
        ))
    ],
    name = "prime_under_50",
)

space = DesignSpace()
space.add("p", PrimeVar())   # instantiate like any other variable class

make_variable returns a class, not an instance. Call it with PrimeVar() when adding to the space. You can also pass constructor arguments if your functions close over mutable state — but for anything beyond simple closures, the subclass pattern is cleaner.


Pattern 3 — Registry

The registry lets you store a variable type under a string name and retrieve it later with create_variable. This is useful when you want to define variable types in one module and use them in another, or when building a library of domain-specific types.

Registering with a decorator

from harmonix import Variable, register_variable

@register_variable("even_integer")
class EvenInteger(Variable):
    def __init__(self, lo: int, hi: int):
        if lo % 2 != 0:
            lo += 1
        if hi % 2 != 0:
            hi -= 1
        self._values = list(range(lo, hi + 1, 2))

    def sample(self, ctx):
        return random.choice(self._values)

    def filter(self, candidates, ctx):
        valid = set(self._values)
        return [v for v in candidates if v in valid]

    def neighbor(self, value, ctx):
        idx = self._values.index(value) if value in self._values else 0
        delta = random.choice([-1, 1])
        return self._values[max(0, min(len(self._values) - 1, idx + delta))]

Retrieving and instantiating

from harmonix import create_variable, list_variable_types

# See everything currently registered
print(list_variable_types())
# ['categorical', 'continuous', 'discrete', 'even_integer', 'integer', ...]

# Instantiate by name — kwargs forwarded to __init__
var = create_variable("even_integer", lo=4, hi=20)
space.add("n", var)

Registering a factory-made class

PrimeVar = make_variable(
    sample   = lambda ctx: random.choice(PRIMES),
    filter   = lambda cands, ctx: [c for c in cands if c in PRIME_SET],
    neighbor = lambda val, ctx: ...,
    name     = "prime_under_50",
    register = True,   # registers automatically under "prime_under_50"
)

Overwriting and unregistering

from harmonix import register_variable, unregister_variable

# Replace an existing registration
register_variable("even_integer", NewEvenInteger, overwrite=True)

# Remove entirely
unregister_variable("even_integer")

Re-registering an existing name without overwrite=True raises VariableAlreadyRegisteredError. Unregistering a name that does not exist raises VariableNotFoundError.


Dependent bounds in custom variables

Custom variables participate in the dependency chain exactly like built-in types. The ctx dict passed to every method contains all variables defined before this one. You can use it to resolve bounds, filter a catalogue, or adjust any other domain parameter at call time.

class RoundedBar(Variable):
    """
    Diameter chosen from standard bar sizes, filtered to those
    that fit within a width already determined by an earlier variable.
    """

    DIAMETERS = [8, 10, 12, 16, 20, 25, 32, 40]   # mm

    def _feasible(self, ctx):
        width_mm = ctx.get("width", float("inf")) * 1000   # ctx["width"] in metres
        max_dia  = width_mm / 4   # simplified spacing rule
        return [d for d in self.DIAMETERS if d <= max_dia]

    def sample(self, ctx):
        feasible = self._feasible(ctx)
        return random.choice(feasible) if feasible else self.DIAMETERS[0]

    def filter(self, candidates, ctx):
        valid = set(self._feasible(ctx))
        return [c for c in candidates if c in valid]

    def neighbor(self, value, ctx):
        feasible = self._feasible(ctx)
        if value not in feasible:
            return self.sample(ctx)
        idx   = feasible.index(value)
        delta = random.choice([-1, 1])
        return feasible[max(0, min(len(feasible) - 1, idx + delta))]


space = DesignSpace()
space.add("width", Continuous(0.20, 0.60))   # metres
space.add("dia",   RoundedBar())

Decoding catalogue-based custom variables

When a custom variable stores an index or code rather than the final value (as ACIRebar and SteelSection do), add a decode method to convert the raw harmony value back to a human-readable form. harmonix does not call decode automatically — it is a convenience you add for your own use.

class ProfileVar(Variable):
    PROFILES = ["IPE 200", "IPE 240", "IPE 270", "IPE 300"]

    def sample(self, ctx):
        return random.randrange(len(self.PROFILES))

    def filter(self, candidates, ctx):
        return [c for c in candidates if 0 <= c < len(self.PROFILES)]

    def neighbor(self, value, ctx):
        delta = random.choice([-1, 1])
        return max(0, min(len(self.PROFILES) - 1, value + delta))

    def decode(self, code: int) -> str:
        return self.PROFILES[code]


var = ProfileVar()
space.add("section", var)

result = optimizer.optimize(...)
print(var.decode(result.best_harmony["section"]))   # e.g. "IPE 270"

Common mistakes

Mistake Consequence Fix
sample can return None Harmony contains None; objective crashes Ensure the domain is always non-empty, or raise a ValueError at construction
neighbor returns an infeasible value Pitch-adjusted harmonies violate constraints silently Always clamp or re-sample within the feasible set
Referencing a later variable in ctx KeyError at runtime Only reference variables defined before this one in the space
Mutating ctx inside a method Corrupts harmony state for all subsequent variables Read ctx but never write to it
Expensive computation inside sample/filter/neighbor Slow iterations — these methods are called thousands of times Pre-compute catalogues and feasibility sets in __init__
Re-registering without overwrite=True VariableAlreadyRegisteredError Pass overwrite=True or unregister first

See also

  • Variable Types — the four built-in primitive types and the variable contract
  • Engineering Spaces — domain-specific types to use as reference implementations
  • Dependent Spaces — how ctx enables dependent bounds across the variable chain
  • Contributing — how to propose a new built-in space for inclusion in harmonix

Clone this wiki locally