Read the docs here or on docs.rs.
Galois connections as first-class Rust values. Use them to cast lawfully
between numeric types, and compose ladders of conversions whose round-trip
behavior is determined by simple inequalities rather than left to chance.
Every operation derived from a Conn (rounding, saturation, median, ...)
carries a property-tested invariant. The generated fixed-width
integer, Q-format, NonZero, and iso families also have Kani harnesses
for full bit-width SMT proofs; float SMT coverage is narrower and
called out under Testing → SMT verification.
MSRV: Rust 1.88. Bumps to the MSRV will be treated as minor-version
changes — pin connections = "0.1" and an MSRV upgrade will surface as
a 0.2 release rather than a silent break on a patch update.
This crate is a Rust-native port of the Haskell library
connections.
Galois connections are the right shape for static, lawful
conversions between partially ordered types (e.g. f64 → f32,
Duration → seconds, f32 → u32 → IpAddr, etc) where each link in
the chain is specifiable at compile time.
The standard cast operators as, From, and Into give you exactly one
direction at a time — and as in particular is silent on rounding,
saturation, and lossy conversion. Two concrete things this crate gives
you that the standard tools don't:
-
Clear semantics.
(x as f32) as f64 != xfor manyx: f64. With aConn, at least one of the following pairs of inequalities is property-tested for every connection in this crate:- left-Galois:
ceil(a) ≤ b iff a ≤ upper(b) - right-Galois:
lower(b) ≤ a iff b ≤ floor(a)
A
ConnisCopy,const-constructible, heap-free, and the crate is#![forbid(unsafe_code)]. - left-Galois:
-
Safely composable.
The
compose!macro folds a chain of pairwise Conns into one freshConn<Src, Dst>at compile time. A composedConnobeys the same properties as its component connections by construction.
use connections::conn::ConnR;
use connections::core::u032::U032I032;
// Rust's `as` keeps the low bits; this Conn makes saturation explicit.
assert_eq!(u32::MAX as i32, -1);
assert_eq!(U032I032.floor(u32::MAX), i32::MAX);
// The reverse arm is paired with `floor` by the right-Galois law.
assert_eq!(U032I032.lower(-1), 0_u32);See EXAMPLES.md for a sequence of ten worked examples in various domains.
A Galois connection
between preorders A and B is a pair of monotone maps f: A → B and
g: B → A such that f(x) ≤ y ⇔ x ≤ g(y). We say f is the left
or lower adjoint, and g is the right or upper adjoint of the
connection.
Here is a simple connection between two 3-element sets:
(image courtesy of 7 Sketches in Compositionality).
Each row is a (a, b) pair; arrows show the action of f (A → B,
bottom legend) and g (B → A, top legend). Lone arrows mark
single-direction maps (f(1) = 1, g(2) = 2); the ↔ marks a
matched pair where both adjoints agree (f(3) = 3, g(3) = 3); the
adjacent ↰ ↳ glyphs depict the lens f(2) ↔ g(1) — two
non-crossing curves between rows 2 and 1, the geometric signature of
adjointness.
Galois connections compose: (f₁ ⊣ g₁) ∘ (f₂ ⊣ g₂) is again an
adjunction, and compose! builds that composite statically,
law-checked as a whole. The moment you apply a destructor (e.g.
upper , lower, ceil, floor, etc) you've left the Conn algebra
and produced a concrete value that can no longer be composed. So early
destructuring throws away the static guarantees that the full
chain would have otherwise enjoyed. Therefore you will get the most
bang for your buck if you follow two heuristics.
Lift through the Conn. A Conn is a little black box: the higher-
arity helpers (e.g. ceil*, floor*, round*, truncate*, etc)
take your arguments, do something with them in the Conn's other
(usually higher-fidelity) domain, and return the result back in
your original domain. ceil2(t, h, b1, b2) is f(h(g(b1), g(b2))):
embed b1/b2 into the wider domain via g, run the closure h
there, round back via f. Use this for domain arithmetic that would
overflow or lose precision in your own type - do it in the wider
domain and round back. Never hand-roll saturating math.
Compose at the site. Export Conns at the library level and use
the ConnL/ConnR/ConnK API instead of get/set functions. When
client code needs a multi-hop conversion, build the exact Conn with
the compose macros (compose, compose_l, compose_r, compose_k)
statically at the call site. Do not thread intermediates by hand.
If the client code takes a runtime parameter then it's best to keep the
helper as a normal named function whose body visibly composes with
the lawful Conns it depends on.
The discipline of pushing runtime parameters and conversion policy choices close to the static Conn call site means that the policy and the static cast are both visible in the same body. The result is code that is visibly correct, easy to test, and extensible to future use cases.
The basic type in this library is:
pub struct Conn<A, B, K: Kind = L> {
f: fn(A) -> B, // L-kind: ceil; R-kind: floor
g: fn(B) -> A, // L-kind: upper, R-kind: lower
// plus a phantom kind tag K ∈ {L, R}
}
A Conn<A, B, K> is exactly a Galois connection — a pair of monotone
functions (f, g) whose adjoint role depends on the kind tag. An
L-kind Conn satisfies f(a) ≤ b ⟺ a ≤ g(b); an R-kind Conn
satisfies g(b) ≤ a ⟺ b ≤ f(a).
The kind K = {L, R} determines the API. L/ConnL exposes .ceil()
and .upper(), while R/ConnR exposes .floor() and .lower().
- Direction names —
ceil(rounds up) andfloor(rounds down) — match downstream intuition. "Give me a ceiling cast" doesn't require the caller to know which side of an adjunction they're on. However calling.floor()on an L-kind connection, orceilon an R-kind connection results in a compiler error. - Position names —
upper(the upper adjoint of the L-pair) andlower(the lower adjoint of the R-pair) — match the math: a genericT: ConnKbound exposes both because a triple has both adjunctions, regardless of which way each one rounds in any concrete instance. - Consts vs markers - Regular connections are
pub consts of typeConn<A, B, L>orConn<A, B, R>. Two-sidedConnKconnections ship aspub structs — zero-sized marker types implementing bothConnLandConnR. The const-vs-struct shape tells you which kind a name refers to at a glance.
- L-side methods on
Conn<_, _, L>(and on anyConnLimplementor via default-method dispatch):ceil,upper, plusceil1/2,upper1/2lifters. - R-side methods on
Conn<_, _, R>(and on anyConnRimplementor):floor,lower, plusfloor1/2,lower1/2lifters. - Two-sided helpers (re-exported at the crate root):
interval,round/round1/round2,truncate/truncate1/truncate2,median. All bind onT: ConnK(super-trait ofConnL + ConnRover the same(A, B)), so they're callable only on triple markers — not on one-sided Conns.
Kind discipline is structural: calling .floor(...) on an L-kind
Conn is a compile error (the method only exists on Conn<_, _, R>),
and likewise .ceil(...) on R. Two-sided helpers similarly reject
one-sided operands at compile time because a one-sided Conn doesn't
implement ConnK.
| Family | Module |
|---|---|
| IEEE-754 types | float |
Q-format binary fixed-point (Q###Q###, i8/u8 … i128/u128 backing) |
fixed::{i008,…,i128, u008,…,u128} (fixed cargo feature) |
Std-int widening + narrowing + cross-sign (I###I###, U###I###, U###U###, I###U###) |
core::{i008,…,i128, u008,…,u128} |
iN/uN ↔ NonZero<{i,u}N> (I###N###, U###N###) |
core::{i008,…,i128, u008,…,u128} |
Cross-crate iso Fixed{I,U}<U0> ↔ {i,u}{N} (Q000I###, Q000U###) and signed normalized bit isos (Q007I008 … Q127I128) |
fixed::{i008,…,i128, u008,…,u128} (fixed cargo feature) |
Float narrowing f64 ↔ f32 ↔ f16 under N5 (F064F032, F032F016, F064F016) |
core::{f032,f064} (f16 cargo feature for f16) |
time crate types (DATEJDAY, TIMENANO, TIMESECS, TDURSECS, F032TDUR, F064TDUR, PDTMDATE, ODTMNANO, ODTMSECS) and the std::time::Duration family (SDURU064, SDURU128, F064SDUR, F032SDUR) for users on std::time |
time::{clock,date,datetime,duration,offset} (time cargo feature) |
hifitime nanosecond Duration + Epoch Conns — calendar (MONTU008, WKDYU008), duration (HDURNANO, HDURSECS, F064HDUR), and per-timescale epoch bridges (EUNXNANO, ETAINANO, F064ETAI, EGPSNANO, …) |
hifi::{calendar,duration,epoch} (hifi cargo feature) |
uhlc hybrid-logical-clock Conns — NTP64 ↔ u64 (NDURU064) and the HLC ID bridge (HLIDLX16) |
uhlc::{ntp64,id} (uhlc cargo feature) |
std::net addresses (U032IPV4, U128IPV6, IPV6IPV4, IPVXIPV4, IPVXIPV6, SOVXSOV4, SOVXSOV6) |
addr |
char codepoint projection (U032CHAR, surrogate-gap-aware) |
core::char |
Pointer-width usize saturating casts (USZEU008, USZEU016, USZEU032, USZEU064, USZEU128) |
core::usize |
Pointer-width isize casts (ISZEI008, ISZEI016, ISZEI128; → i32/→ i64 deferred) |
core::isize |
Sortable byte encodings (U008BE01, U008LE01, I008BE01, I008LE01, BOOLBE01, BOOLLE01, through U128BE16, U128LE16, I128BE16, I128LE16) |
core::{bool, i008,…,i128, u008,…,u128} |
Constant-name prefixes are letter-disambiguated: Q for Q-format
wrappers (sign and host bit-width come from the module path), I/U
for std primitives (digits = bit-width), N for NonZero<*>, F for
IEEE floats. Cross-module name collisions are allowed and resolved by
qualified import (e.g. fixed::i008::Q008Q000 and
fixed::i064::Q008Q000 co-exist).
When the same inner function can serve as both upper and lower
and satisfies an additional order-reflecting property (see
Sandwich inequality), the library combines
the two resulting connections into a zero-sized marker struct that
gains a third group of 'ambidextrous' helpers via a super-trait that
ties the L and R sides together:
ConnL— capability trait with associated typestype A: Copy; type B: Copy;and aconn_l()projection to the L-viewConn<A, B, L>. Default methods expose.ceil()and.upper().ConnR— symmetric capability trait whoseconn_r()projects to the R-viewConn<A, B, R>. Default methods expose.floor()and.lower().ConnK— super-traitConnL + ConnRover the same(A, B)pair; the two-sided helpers (round,truncate, …) bind onConnKand reach through both views.
The trait names match the value-type spellings on purpose: a blanket
impl ConnL for Conn<A, B, L> (and the R-side analogue) makes every
one-sided value also satisfy the trait, so a generic T: ConnL bound
accepts triple markers and raw Conn<A, B, L> values uniformly, and
inner is defined as a free function in module scope, referenced
from the marker's trait impls; no struct in the crate stores three
function pointers.
You construct a ConnK marker out of three functions: ceil, inner,
and floor using one of the crate's provided macros. Note that both
ceil/inner and inner/floor must satisfy the connection
inequalities given above. In addition ceil/floor must satisfy
the following 'sandwich' inequality: for every a, floor(a) ≤ ceil(a)
Triples ceil/inner/floor that satisfy all three properties are known
as adjoint triples — the
ceil ⊣ inner ⊣ floor shape outlined in
Example 3.
The sandwich inequality is equivalent to the earlier requirement that
inner be order-reflecting.
The prop::conn::law_battery! full subset enforces both floor_le_ceil
as well as order_reflecting:
Proof of equivalence is outlined in the following section.
Both directions of the equivalence follow from applications of the
adjunction laws. Both proofs use only L-Galois f ⊣ g, R-Galois
g ⊣ h, monotonicity, and transitivity — no extra assumptions.
Sufficiency (inner order-reflecting ⟹ floor(a) ≤ ceil(a)).
Take any a ∈ A. The two closure laws give
inner(floor(a)) ≤ a ≤ inner(ceil(a))
so by transitivity inner(floor(a)) ≤ inner(ceil(a)). Since inner
is order-reflecting, this lifts to floor(a) ≤ ceil(a). ∎
Necessity (floor(a) ≤ ceil(a) everywhere ⟹ inner order-reflecting).
Take x, y ∈ B with inner(x) ≤ inner(y). Chain:
x ≤ floor(inner(x)) -- kernel of inner ⊣ floor, with b = x
≤ ceil(inner(x)) -- assumption at a = inner(x)
≤ y -- L-Galois ceil(a) ≤ b ⟺ a ≤ inner(b),
with a = inner(x), b = y; the RHS
inner(x) ≤ inner(y) is given
So x ≤ y. ∎
(Categorically: in an adjoint triple f ⊣ g ⊣ h over posets, g
fully faithful ⟺ the counit of g ⊣ h is iso ⟺ the unit of f ⊣ g
is iso ⟺ h ≤ f. The two displays above are that equivalence written for
posets, where "fully faithful" reduces to "order-reflecting" and "iso" to
"equality".)
Counterexample — necessity is sharp. Let A = {a} (one element)
and B = {b₁ < b₂ < b₃}, with inner: B → A the constant map
(inner(b) = a for every b — monotone but maximally non-injective).
Watch what the per-side Galois laws force:
- L-Galois
ceil(a) ≤ b ⟺ a ≤ inner(b). The RHS reduces toa ≤ a, which is always true, soceil(a) ≤ bfor everyb ∈ B. The smallest suchbisb₁, soceil(a) = b₁. - R-Galois
inner(b) ≤ a ⟺ b ≤ floor(a). The LHS reduces toa ≤ a, always true, sob ≤ floor(a)for everyb, givingfloor(a) = b₃.
Both per-side adjunctions hold, every monotonicity check passes — and
yet floor(a) = b₃ > b₁ = ceil(a). The "triple" type-checks and the
per-side laws are satisfied, but the rounding sandwich is inverted.
The two-sided helpers inherit the inversion. round(a) compares
inner(floor(a)) = a with inner(ceil(a)) = a to pick the closer
endpoint, finds them equal, and falls through to truncate, which
returns whichever side the source-zero rule selects — a value with no
in-band signal that anything is wrong. A connection that fails the
sandwich inequality isn't an academic foul; the two-sided helpers
actively misbehave on it.
| MSRV | Rust 1.88 (matches rust-toolchain.toml) |
| Edition | 2024 |
| License | MIT (see LICENSE-MIT) |
Optional cargo features:
| Feature | What it enables | Toolchain |
|---|---|---|
fixed |
connections::fixed::{i008,…,u128} Q-format ladders, float→Q bridges, Q.0 primitive isos, and signed normalized bit isos |
stable |
proptest |
Re-exports connections::prop::arb (proptest strategies) for downstream test suites |
stable |
macros |
Re-exports the internal codegen macro families under connections::macros / crate-root macro paths |
stable |
time |
Civil-calendar and time-span Conns backed by the time crate |
stable |
hifi |
Nanosecond-precision hifitime::Duration / Epoch Conns |
stable |
f16 |
IEEE binary16 connections (F016, F032F016, F064F016) and their proptest strategies |
nightly (uses #![feature(f16)] — tracking #116909) |
try_trait |
?-operator (Try / FromResidual) support on Interval, Extended, N5 — extracts the success payload; Interval / Extended short-circuit on boundary variants, N5 is infallible |
nightly (uses #![feature(try_trait_v2)] — tracking #84277) |
The connections::prop::conn and connections::prop::lattice
predicate modules are always public — they're pure bool-returning
functions over this crate's own types and don't depend on proptest.
The proptest feature only adds prop::arb, the strategy module that
does pull proptest in as a regular dependency.
Every connection runs its proptest law suite in CI and in the
repository's pre-push gate. Float
generators are biased toward NaN, ±∞, ±0, denormals, and ULP-boundary
values. Fixed-point generators are biased toward 0, ±PREC, and
±i64::MAX/PREC so saturation boundaries are exercised on every run.
Runtime dependencies are feature-gated. The
fixed crate backs the optional
binary fixed-point ladder, time
backs the optional civil-calendar / clock surface, and
hifitime backs the optional
high-precision time surface. Proptest is a dev-dependency, exposed
publicly behind the proptest feature for downstream test suites.
Every connection ships with proptest coverage of the following laws — the
predicates live in prop::conn and are re-runnable by downstream
crates against their own connections:
| Law | Statement |
|---|---|
conn_galois_l |
ceil(a) ≤ b ⟺ a ≤ inner(b) |
conn_galois_r |
inner(b) ≤ a ⟺ b ≤ floor(a) |
conn_closure_l |
a ≤ inner(ceil(a)) (unit) |
conn_closure_r |
inner(floor(a)) ≤ a |
conn_kernel_l |
ceil(inner(b)) ≤ b (counit) |
conn_kernel_r |
b ≤ floor(inner(b)) |
conn_monotone_l |
a₁ ≤ a₂ ⟹ ceil(a₁) ≤ ceil(a₂) ∧ floor(a₁) ≤ floor(a₂) |
conn_monotone_r |
b₁ ≤ b₂ ⟹ inner(b₁) ≤ inner(b₂) |
conn_idempotent |
inner ∘ ceil is idempotent on its image |
A tenth law, conn_floor_le_ceil (floor(a) ≤ ceil(a)), is asserted
only on ConnK connections whose inner is an injective embedding. See
above.
For float-bearing types, the ≤ is an N5 lattice.
In particular, NaN is reflexive, NaN sits between ±∞, and finite values
are strictly ordered. N5 carries these semantics.
Beyond the proptest law suite — which samples — the generated
fixed-width integer / Q-format / NonZero / iso connection families
listed in src/kani.rs
have Kani harnesses for their Galois-law predicates over the full
bit-width domain. The pointer-width
usize / isize families (core::usize / core::isize) are the
exception: CBMC models a single concrete pointer width per run, so
those Conns are covered by the proptest battery on the host target
rather than a width-agnostic SMT proof. The proof tree lives at
src/kani/
and is gated behind #[cfg(kani)] so it compiles only under
cargo kani — release builds, cargo test, and downstream consumers
see no proof code. No new runtime dependency: Kani injects its own
crate at proof time.
The float-specific SMT result is deliberately narrower: the IEEE bit
space is too large for full-Galois proofs to be tractable, so the
f64 → f32 ULP-walk in src/core/f064.rs (ceil_f64_f32 /
floor_f64_f32) is proven to converge in ≤ 2 iterations for every
finite non-NaN f64, not just the proptest sample. Three tiered harnesses
(float_walk::t0_* for the full domain, t1_* for |x| ≤ 1e6,
t2_* for the [1, 2) binade) each verify the bound under
progressively tighter input restrictions. These harnesses do not prove
full float Galois laws over NaN/±∞, and they do not cover the
float→integer or float→fixed macro helper bodies; those branches are
covered by the proptest law batteries and explicit helper/unit tests.
Run with:
cargo install --locked kani-verifier
cargo kani setup
cargo kani # full proof tree
cargo kani --harness 'float_walk::t0_' # the headline
cargo kani --harness 'int_narrow::' # one familyPer-harness wall times are in the milliseconds-to-seconds range; the full tree runs in well under two minutes.
