-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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 |
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))
-
samplemust always return a feasible value. It is called both during initialisation (cold start) and when HMCR misses — there is no fallback. -
filtermust never return infeasible values. The optimiser picks from its output without further checking. -
filtermay return an empty list. When it does, the optimiser falls back tosampleautomatically. -
neighbormust always return a feasible value. If the suppliedvalueis somehow outside your domain (e.g. after a resume from a checkpoint with different parameters), returnself.sample(ctx)as a safe fallback. -
Use
ctxonly to read earlier variables. Never write toctxinside a variable method — the optimiser owns that dict.
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.
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.
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))]
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)
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"
)
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.
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())
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"
| 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 |
- 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
ctxenables dependent bounds across the variable chain - Contributing — how to propose a new built-in space for inclusion in harmonix