Packages

Property-based testing and metamorphic testing combinator library for Gleam

Current section

Files

Jump to
metamon src metamon relation.gleam
Raw

src/metamon/relation.gleam

//// Named two-argument predicates used by metamorphic relations.
////
//// A `Relation(b)` answers "do these two outputs satisfy the
//// constraint?". The `name` is surfaced in failure reports so the
//// user immediately sees which property broke.
import gleam/float
import gleam/list
import gleam/order
/// A named two-argument predicate over outputs.
pub type Relation(b) {
Relation(name: String, holds: fn(b, b) -> Bool)
}
/// Construct a relation from a name and a binary predicate.
pub fn new(name: String, holds: fn(b, b) -> Bool) -> Relation(b) {
Relation(name: name, holds: holds)
}
/// Equality (Gleam's structural `==`).
pub fn equal() -> Relation(a) {
Relation(name: "equal", holds: fn(left, right) { left == right })
}
/// Inequality.
pub fn not_equal() -> Relation(a) {
Relation(name: "not_equal", holds: fn(left, right) { left != right })
}
/// Equality after a shared normalisation step. Useful for
/// "equal modulo whitespace / key order / formatting".
pub fn equivalent_under(via: fn(b) -> c, name: String) -> Relation(b) {
Relation(name: "equivalent_under(" <> name <> ")", holds: fn(left, right) {
via(left) == via(right)
})
}
/// Floats within `epsilon` of each other.
///
/// Panics at construction if `epsilon` is negative. A negative epsilon
/// would silently yield a degenerate "always false" relation (the
/// absolute difference is always non-negative, so `diff <=. epsilon`
/// can never hold). Failing visibly on a sign mistake matches the
/// project's posture for malformed numeric inputs in `Range.constant`,
/// `Range.linear`, etc.
pub fn approximately(epsilon: Float) -> Relation(Float) {
case epsilon <. 0.0 {
True ->
panic as "metamon.approximately: epsilon must be >= 0.0 (a negative epsilon would make every pair compare False, including a value with itself)"
False ->
Relation(
name: "approximately(" <> float.to_string(epsilon) <> ")",
holds: fn(left, right) {
let diff = case left >=. right {
True -> left -. right
False -> right -. left
}
diff <=. epsilon
},
)
}
}
/// Two lists are permutations of each other (same multiset).
pub fn permutation_of() -> Relation(List(a)) {
Relation(name: "permutation_of", holds: is_permutation)
}
fn is_permutation(left: List(a), right: List(a)) -> Bool {
case list.length(left) == list.length(right) {
False -> False
True -> remove_each(left, right) == []
}
}
fn remove_each(to_remove: List(a), remaining: List(a)) -> List(a) {
case to_remove {
[] -> remaining
[first, ..rest] ->
case remove_one(first, remaining) {
Ok(remaining_after) -> remove_each(rest, remaining_after)
Error(_) -> [first]
// Mismatch — return non-empty so the caller sees inequality.
}
}
}
fn remove_one(target: a, items: List(a)) -> Result(List(a), Nil) {
remove_one_loop(target, items, [])
}
fn remove_one_loop(
target: a,
items: List(a),
acc: List(a),
) -> Result(List(a), Nil) {
case items {
[] -> Error(Nil)
[first, ..rest] ->
case first == target {
True -> Ok(list.append(list.reverse(acc), rest))
False -> remove_one_loop(target, rest, [first, ..acc])
}
}
}
/// Multiset subset: every element of `left` is matched against a
/// *distinct* element of `right` (one-to-one, by structural equality).
/// `subset_of([1, 1], [1])` is `False`; `subset_of([1, 1], [1, 1, 2])`
/// is `True`. This matches the semantics of `permutation_of` (which is
/// also multiset-based).
///
/// For the set-style interpretation that ignores multiplicity, use
/// `set_subset_of()` instead.
pub fn subset_of() -> Relation(List(a)) {
Relation(name: "subset_of", holds: fn(left, right) { is_subset(left, right) })
}
/// Set subset: every element of `left` is contained somewhere in
/// `right`, ignoring multiplicity. `set_subset_of([1, 1], [1])` is
/// `True` because the only distinct value `1` is present.
///
/// For the multiset interpretation that requires distinct matches
/// per occurrence, use `subset_of()` instead.
pub fn set_subset_of() -> Relation(List(a)) {
Relation(name: "set_subset_of", holds: fn(left, right) {
is_set_subset(left, right)
})
}
fn is_subset(left: List(a), right: List(a)) -> Bool {
case left {
[] -> True
[first, ..rest] ->
case remove_one(first, right) {
Ok(remaining) -> is_subset(rest, remaining)
Error(_) -> False
}
}
}
fn is_set_subset(left: List(a), right: List(a)) -> Bool {
case left {
[] -> True
[first, ..rest] ->
case list.contains(right, first) {
True -> is_set_subset(rest, right)
False -> False
}
}
}
/// `cmp(left, right)` returns `Lt` or `Eq` (i.e. left ≤ right).
pub fn monotone(cmp: fn(a, a) -> order.Order) -> Relation(a) {
Relation(name: "monotone", holds: fn(left, right) {
case cmp(left, right) {
order.Lt -> True
order.Eq -> True
order.Gt -> False
}
})
}
/// Conditional relation: if `antecedent(left, right)` is `True`, the
/// inner relation must hold. Otherwise the relation is trivially
/// satisfied.
pub fn implies(antecedent: fn(b, b) -> Bool, inner: Relation(b)) -> Relation(b) {
Relation(name: "implies(" <> inner.name <> ")", holds: fn(left, right) {
case antecedent(left, right) {
False -> True
True -> inner.holds(left, right)
}
})
}
/// Both `r1` and `r2` must hold.
pub fn and(r1: Relation(b), r2: Relation(b)) -> Relation(b) {
Relation(name: r1.name <> " and " <> r2.name, holds: fn(left, right) {
r1.holds(left, right) && r2.holds(left, right)
})
}
/// At least one of `r1` and `r2` must hold.
pub fn or(r1: Relation(b), r2: Relation(b)) -> Relation(b) {
Relation(name: r1.name <> " or " <> r2.name, holds: fn(left, right) {
r1.holds(left, right) || r2.holds(left, right)
})
}
/// Negate a relation.
pub fn invert(r: Relation(b)) -> Relation(b) {
Relation(name: "not(" <> r.name <> ")", holds: fn(left, right) {
!r.holds(left, right)
})
}
/// Override a relation's name.
pub fn rename(r: Relation(b), name: String) -> Relation(b) {
Relation(name: name, holds: r.holds)
}
// ---------- N-ary relations ----------
/// A relation across an arbitrary number of follow-up outputs.
/// `holds(outputs)` receives the source output as `outputs[0]` and
/// the i-th follow-up output as `outputs[i + 1]`. Used by
/// `metamon.forall_morph_n` for MRs that compare more than two
/// outputs (e.g. `f(x), f(T1(x)), f(T2(x))` are pairwise equal).
pub type RelationN(b) {
RelationN(name: String, holds: fn(List(b)) -> Bool)
}
/// Construct an N-ary relation directly.
pub fn n_new(name: String, holds: fn(List(b)) -> Bool) -> RelationN(b) {
RelationN(name: name, holds: holds)
}
/// All elements of the input list are equal under structural `==`.
/// Trivially `True` for lists of length 0 or 1.
pub fn all_equal() -> RelationN(b) {
RelationN(name: "all_equal", holds: fn(items) { all_equal_loop(items) })
}
fn all_equal_loop(items: List(b)) -> Bool {
case items {
[] -> True
[_] -> True
[first, second, ..rest] ->
case first == second {
True -> all_equal_loop([second, ..rest])
False -> False
}
}
}
/// Lift a binary `Relation(b)` to an N-ary relation by checking it
/// pairwise: every consecutive pair `(items[i], items[i+1])` must
/// satisfy `r`. For `name == "equal"` this gives a chain of equal
/// values; for `monotone` it gives a sorted chain.
pub fn pairwise(r: Relation(b)) -> RelationN(b) {
RelationN(name: "pairwise(" <> r.name <> ")", holds: fn(items) {
pairwise_loop(r, items)
})
}
fn pairwise_loop(r: Relation(b), items: List(b)) -> Bool {
case items {
[] -> True
[_] -> True
[first, second, ..rest] ->
case r.holds(first, second) {
True -> pairwise_loop(r, [second, ..rest])
False -> False
}
}
}