Dimensions: what the numbers in your rules mean
A solver sees integers and reals. A departure time, a shift length, a claim value and a suspicion score are all just numbers to it — which is how a rule set ends up comparing a clock reading to a currency amount and getting a confident, meaningless answer back.
A dimension is the declaration that keeps them apart. In Guardrail Calculus it is data: a carrier, an algebraic structure, an invariant, units, and constructors. What follows from it — which operators exist, what they return, what the solver may assume about every slot of that kind, how a client renders a value — is derived from that one record rather than written out again per dimension.
Every code block below is quoted from the source it describes, and every table
of results is produced by running that source during this site's build. The
module tests/examples/test_dimensions.py pins the outcomes, so a change that
moved a verdict or stopped a rejection fails a test rather than quietly rotting
this page.
A dimension is a declaration
Here is the whole of what Instant — a clock reading — is:
INSTANT = Dimension(
name="Instant",
carrier=Carriers.INT,
invariant=_And(_Ge(CarrierValue(), 0), _Le(CarrierValue(), 1439)),
base_unit="minutes_of_day",
# Cyclic-affine over Duration derives the temporal algebra: Instant - Instant ->
# Duration, Instant +/- Duration -> Instant (and the inert Duration + Instant).
structure=CyclicAffine(
displacement=DURATION, modulus=24 * 60, difference_policy="signed"
),
# Clients render this dimension's values as a 24h clock reading rather
# than a raw minute count -- declared here, interpreted client-side;
# solvers return raw values and never format.
display=DisplayFormat(radices=(60,), pattern="{0:02}:{1:02}"),
)
Five decisions, all in one place. The carrier is the solver-visible sort
(int), so an Instant lowers to an integer and nothing else. The invariant
is what is true of every value of this kind, and it travels with the dimension
all the way into the solver. The base unit fixes what the carrier counts.
The structure says what algebra a clock reading admits — that it is a point
on a cycle, over Duration as its displacement. The display format says how
a client should render one; solvers return raw values and never format.
The four shipped dimensions differ only in those fields:
| Dimension | Carrier | Structure | Declared invariant | Authored with |
|---|---|---|---|---|
Duration |
int (minutes) |
Vector |
none — a difference may be negative | duration_minutes, duration_hours, duration_hm |
Instant |
int (minutes of day) |
CyclicAffine over Duration |
0 <= v <= 1439 |
clock_hm, clock_from_hhmm, time_at |
Money |
int |
TaggedVector (equal tags only) |
none — signed, for credits and refunds | money_gbp, money_usd |
Probability |
real |
ScalarKind (comparison-only) |
0 <= v <= 1 |
probability |
Money is the interesting one. Its currencies are unit tags, not scales:
MONEY = Dimension(
name="Money",
carrier=Carriers.INT,
invariant=None, # current Money enforces no bound; signed-vs->=0 is an open pin
# Currencies are unit *tags*, not scales: each is base-1 and not inter-convertible
# (no implicit FX). Cross-currency arithmetic is blocked by the algebra precondition.
base_unit="GBP",
# Tagged-vector derives same-tag +/- (with the equal-tag precondition and left-tag
# result) and tag-agnostic scalar multiplication.
structure=TaggedVector(compatibility="equal-tag-only"),
)
Nothing converts between them, because there is no honest constant to convert
with. Probability, at the other extreme, declares that it has no arithmetic at
all:
PROBABILITY = Dimension(
name="Probability",
carrier=Carriers.REAL,
invariant=_And(_Ge(CarrierValue(), 0), _Le(CarrierValue(), 1)),
base_unit="",
structure=ScalarKind(arithmetic="comparison-only"),
# Comparison-only: no constructors and no arithmetic.
)
The structure derives the algebra
A structure is not a label. It is the single source of a dimension's operators:
| Structure | Derives | Declared by |
|---|---|---|
Vector |
+ and - with itself; scalar multiplication both ways |
Duration, and most user quantities |
Affine / CyclicAffine |
point − point → displacement; point ± displacement → point | Instant over Duration |
TaggedVector |
+/- under an equal-tag precondition, result keeping the left tag; tag-agnostic scaling |
Money |
ScalarKind |
nothing — comparison only | Probability |
Point + point is absent from the affine row on purpose: two clock readings do not add. Neither list is written out anywhere as operator code — a dimension declares its structure and inherits the mechanics every other dimension uses.
Arithmetic that keeps its meaning
A schema field names a dimension, and the slot behind it carries that dimension through every expression it appears in:
@dataclass(frozen=True)
class Shift:
"""One shift: two clock readings, both declared as ``Instant`` slots."""
start: ClockRef
close: ClockRef
Given that the early shift starts at 06:20 and runs eight hours, three questions about its own algebra:
early_shift = (
subject()
.given(
(
(_shift.start @ clock_from_hhmm(620))
& (_shift.close @ (_shift.start + duration_hours(8)))
).annotate("The early shift starts at 06:20 and runs eight hours.")
)
.check("A", (_shift.close @ clock_from_hhmm(1420)).annotate("It closes at 14:20."))
.check(
"B",
((_shift.close - _shift.start) @ duration_hours(8)).annotate(
"Close minus start is a duration of eight hours."
),
)
.check("C", (_shift.close > _shift.start).annotate("It closes after it starts."))
.block()
)
| Check | Proposition | Verdict |
|---|---|---|
| A | It closes at 14:20 | VERIFIED |
| B | Close minus start is a duration of eight hours | VERIFIED |
| C | It closes after it starts | VERIFIED |
Check B is the one that shows what a dimension buys. close - start is not
an integer difference that happens to be comparable against another integer: the
affine structure says two Instants subtract to a Duration, so the result can
be compared against duration_hours(8) and against nothing else. Check A
runs the same rule in the other direction — an Instant plus a Duration is an
Instant, which is why 06:20 + 8h can be checked against a clock reading at
all.
What the solver assumes about every slot
A declared invariant is not documentation. Each of these candidates is classified against no givens whatsoever:
DECLARED_BOUND_CASES: tuple[tuple[str, Any], ...] = (
("_.risk <= probability(1.0)", _.risk <= probability(1.0)),
("_.risk <= probability(0.5)", _.risk <= probability(0.5)),
("_.start <= clock_from_hhmm(2359)", _.start <= clock_from_hhmm(2359)),
("_.budget >= money_gbp(0)", _.budget >= money_gbp(0)),
)
| Proposition | Verdict | Model |
|---|---|---|
_.risk <= probability(1.0) |
VERIFIED |
{ risk: 0 } |
_.risk <= probability(0.5) |
UNKNOWN |
{ risk: 0 } |
_.start <= clock_from_hhmm(2359) |
VERIFIED |
{ start: 0 } |
_.budget >= money_gbp(0) |
UNKNOWN |
{ budget: 0 } |
Two of them are VERIFIED with nothing given at all,
because the classifier realises every dimensioned slot's declared invariant as a
constraint before it asks its two questions. Probability declared 0 <= v <= 1,
so a risk score is at most 1.0 as a matter of what a probability is — while
"at most 0.5" stays UNKNOWN, because the declaration
bounds the score at 1 and nothing bounds it at 0.5. The same declaration is what
makes a clock reading no later than 23:59. (The model column is the assignment
the solver returned — a satisfying example, not a counterexample.)
The last row is the honest counterpart: Money declares no invariant, so a
budget is not known to be non-negative. That is a decision recorded in the
declaration — money is signed, because credits and refunds are real — and not an
omission the analysis papers over.
Combinations the declarations do not admit
The rules above are also the rules for what you cannot write:
REJECTED_CASES: tuple[tuple[str, Any], ...] = (
# Two dimensions, no rule joining them: Duration's algebra admits Durations.
("duration_hours(2) + money_gbp(5)", lambda: duration_hours(2) + money_gbp(5)), # type: ignore[operator]
# One dimension, two unit tags: Money's equal-tag precondition fails.
("money_gbp(5) + money_usd(5)", lambda: money_gbp(5) + money_usd(5)),
# Probability is declared comparison-only, so it has no `+` at all. Unlike
# the two above, this one type-checks: the static layer admits operands of
# one family, and whether that family *has* a `+` is the declaration's call,
# made when the value is built.
(
"probability(0.2) + probability(0.3)",
lambda: probability(0.2) + probability(0.3),
),
# Two points on a cycle: an affine structure derives no point + point rule.
("clock_hm(9, 30) + clock_hm(1, 0)", lambda: clock_hm(9, 30) + clock_hm(1, 0)), # type: ignore[operator]
# The same check one layer down, for a payload Python never authored.
(
"normalize([budget >= money_gbp(0), budget >= duration_hours(1)])",
_cross_dimension_comparison,
),
)
| Expression | Rejected with |
|---|---|
duration_hours(2) + money_gbp(5) |
TypeError — unsupported operand type(s) for +: 'DisplacementValue' and 'DisplacementValue' |
money_gbp(5) + money_usd(5) |
TypeError — Precondition failed for + on DisplacementValue(_node=5, unit='GBP', _dimension=Dimension(name='M… |
probability(0.2) + probability(0.3) |
TypeError — unsupported operand type(s) for +: 'DisplacementValue' and 'DisplacementValue' |
clock_hm(9, 30) + clock_hm(1, 0) |
TypeError — unsupported operand type(s) for +: 'PointValue' and 'PointValue' |
normalize([budget >= money_gbp(0), budget >= duration_hours(1)]) |
ValueError — dimension mismatch in 'ge' comparison: 'Money' vs 'Duration' |
Those are the exceptions the library actually raised, quoted rather than
described. The two rows that mix different dimensions are also static errors —
the # type: ignore comments above are executable proof of that, since the type
gate rejects an ignore that has stopped being necessary. For those, the first
line of defence is the type checker, at the moment the rule is written.
The other two expressions type-check, and are refused when the value is built.
That split is the design, not a gap: the static layer decides whether two
operands belong to the same family, and the declaration decides what that family
admits. Two Money values are the same family — it is the declared equal-tag
precondition that separates pounds from dollars. Two Probability values are
the same family too — it is ScalarKind(arithmetic="comparison-only") that says
there is no + to reach for.
The final row is the same check one layer down. A payload that arrives already encoded — from a generated guard, or a client in another language — never passed through Python's type checker, so the normalizer re-derives every slot's dimension from the registry and refuses a comparison between two definitively different ones.
Author your own dimension
The four built-ins are not privileged. A dimension of your own is a params
dataclass, a Dimension record, a constructor formula, and one binder line:
@dataclass(frozen=True)
class TokensParams:
"""The one constructor's parameters -- its names survive into the API."""
count: int
TOKENS = Dimension(
name="Tokens",
carrier=Carriers.INT,
invariant=_Ge(CarrierValue(), 0), # a token budget cannot go negative
base_unit="tokens",
structure=Vector(), # derives +, -, and scalar multiplication
)
@dataclass(frozen=True)
class TokensConstructors[OfT]:
"""A constructor namespace; generic so dotted access keeps its signature."""
of: OfT
TOKENS_SPEC = DimensionSpec(
dimension=TOKENS,
constructors=TokensConstructors(
of=ConstructorDescriptor(
params=TokensParams,
carrier_expr=param_ref[TokensParams].count,
result_unit="tokens",
)
),
)
tokens = dim[Literal["Tokens"]](TOKENS_SPEC).ctor(TOKENS_SPEC.constructors.of)
That is the whole declaration — no kernel change, no subclass, and not one
operator method. Declaring structure=Vector() derived these:
| Operation | Result |
|---|---|
Tokens * scalar |
Tokens |
scalar * Tokens |
Tokens |
Tokens + Tokens |
Tokens |
Tokens - Tokens |
Tokens |
And the analysis machinery treats it exactly as it treats a built-in. Putting
_.budget >= tokens(0) through the same normalizer, against a registry holding
Tokens and nothing else:
slot sorts = { budget: int }
slot dimensions = { budget: Tokens }
invariants = 1 realised
The slot's sort and dimension are inferred from the literal it is compared
against, and the invariant declared above is realised as a constraint on that
slot — with no Tokens-specific code anywhere in the wire, the registry
artifact, or the normalizer.
Limits today
- Tags are tags, not scales. Arithmetic across currencies is refused, as the
table above shows. A comparison across them is not: both sides are
Money, and the dimension check works at that level. - Not every entry point runs every check. The classification path infers slot dimensions and asserts their declared invariants, but the cross-dimension comparison rejection above belongs to the normalization pass that the guard generation and cross-language paths run.
- A cycle is declared, not yet computed.
CyclicAffine's modulus ships in the registry artifact for clients that need it; the analyses treat anInstantas a bounded integer and do not wrap arithmetic around midnight. - A solver is built with a registry, not handed one. A request carries the digest of the declarations it was built against, and a mismatch is an error rather than a quietly different analysis — so a user dimension reaches a deployed solver as part of its registry, not as a per-request payload.
How reasoning works · Guardrails over a whole rule set · Dimensions API reference