Current section
Files
Jump to
Current section
Files
src/metamon.erl
-module(metamon).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/metamon.gleam").
-export([seed/1, random_seed/0, default_config/0, with_seed/2, with_runs/2, with_max_size/2, with_shrink_limit/2, with_max_edges/2, with_regression_file/2, with_diff_enabled/2, with_runs_or_panic/2, with_max_size_or_panic/2, with_shrink_limit_or_panic/2, with_max_edges_or_panic/2, with_regression_file_or_panic/2, with_output_format/2, mr/3, mr_equivariant/4, name_of/1, forall_with/3, forall/2, forall_observable_with/3, forall_observable/2, forall_morph_with/4, forall_morph/3, assert_morph/3, forall_morph_n_with/5, forall_morph_n/4, forall_morphs/3, idempotency_of/2, invariant_under/2, equivariant_under/4, commutativity_of/1, forall_round_trip_with/5, forall_round_trip/4, forall_round_trip_partial_with/5, forall_round_trip_partial/4, forall_round_trip_under_with/6, forall_round_trip_under/5]).
-export_type([mr/2]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Metamon top-level public API.\n"
"\n"
" `metamon` exports the small surface that most tests interact with:\n"
" `forall`, `forall_observable`, `forall_morph`, `assert_morph`,\n"
" `forall_morphs`, `forall_round_trip`, the `Mr` smart constructors,\n"
" and a handful of metamorphic-relation templates (`idempotency_of`,\n"
" `invariant_under`, `equivariant_under`, `commutativity_of`).\n"
"\n"
" Configuration lives in `metamon/config`; generators in\n"
" `metamon/generator`; transforms in `metamon/transform`; relations\n"
" in `metamon/relation`; per-property context in `metamon/annotate`\n"
" and `metamon/coverage`; structural diff in `metamon/diff`.\n"
).
-opaque mr(HDG, HDH) :: {mr, metamon@internal@runner:morph_spec(HDG, HDH)}.
-file("src/metamon.gleam", 31).
?DOC(" Construct a deterministic seed from an integer.\n").
-spec seed(integer()) -> metamon@generator@seed:seed().
seed(Value) ->
metamon@generator@seed:seed(Value).
-file("src/metamon.gleam", 36).
?DOC(" Convenience: a fresh random seed.\n").
-spec random_seed() -> metamon@generator@seed:seed().
random_seed() ->
metamon@generator@seed:random_seed().
-file("src/metamon.gleam", 41).
?DOC(" Re-export of `Config` and `default_config`.\n").
-spec default_config() -> metamon@config:config().
default_config() ->
metamon@config:default_config().
-file("src/metamon.gleam", 46).
?DOC(" Re-export of `with_seed`.\n").
-spec with_seed(metamon@config:config(), metamon@generator@seed:seed()) -> metamon@config:config().
with_seed(C, S) ->
metamon@config:with_seed(C, S).
-file("src/metamon.gleam", 51).
?DOC(" Re-export of `with_runs`.\n").
-spec with_runs(metamon@config:config(), integer()) -> {ok,
metamon@config:config()} |
{error, metamon@config:config_error()}.
with_runs(C, N) ->
metamon@config:with_runs(C, N).
-file("src/metamon.gleam", 56).
?DOC(" Re-export of `with_max_size`.\n").
-spec with_max_size(metamon@config:config(), integer()) -> {ok,
metamon@config:config()} |
{error, metamon@config:config_error()}.
with_max_size(C, N) ->
metamon@config:with_max_size(C, N).
-file("src/metamon.gleam", 61).
?DOC(" Re-export of `with_shrink_limit`.\n").
-spec with_shrink_limit(metamon@config:config(), integer()) -> {ok,
metamon@config:config()} |
{error, metamon@config:config_error()}.
with_shrink_limit(C, N) ->
metamon@config:with_shrink_limit(C, N).
-file("src/metamon.gleam", 69).
?DOC(" Re-export of `with_max_edges`.\n").
-spec with_max_edges(metamon@config:config(), integer()) -> {ok,
metamon@config:config()} |
{error, metamon@config:config_error()}.
with_max_edges(C, N) ->
metamon@config:with_max_edges(C, N).
-file("src/metamon.gleam", 74).
?DOC(" Re-export of `with_regression_file`.\n").
-spec with_regression_file(metamon@config:config(), binary()) -> {ok,
metamon@config:config()} |
{error, metamon@config:config_error()}.
with_regression_file(C, Path) ->
metamon@config:with_regression_file(C, Path).
-file("src/metamon.gleam", 82).
?DOC(" Re-export of `with_diff_enabled`.\n").
-spec with_diff_enabled(metamon@config:config(), boolean()) -> metamon@config:config().
with_diff_enabled(C, Enabled) ->
metamon@config:with_diff_enabled(C, Enabled).
-file("src/metamon.gleam", 89).
?DOC(
" Re-export of `with_runs_or_panic`. Use in test code where the bound\n"
" is statically known and the `let assert Ok(c) = ...` arm would be\n"
" dead code.\n"
).
-spec with_runs_or_panic(metamon@config:config(), integer()) -> metamon@config:config().
with_runs_or_panic(C, N) ->
metamon@config:with_runs_or_panic(C, N).
-file("src/metamon.gleam", 94).
?DOC(" Re-export of `with_max_size_or_panic`.\n").
-spec with_max_size_or_panic(metamon@config:config(), integer()) -> metamon@config:config().
with_max_size_or_panic(C, N) ->
metamon@config:with_max_size_or_panic(C, N).
-file("src/metamon.gleam", 99).
?DOC(" Re-export of `with_shrink_limit_or_panic`.\n").
-spec with_shrink_limit_or_panic(metamon@config:config(), integer()) -> metamon@config:config().
with_shrink_limit_or_panic(C, N) ->
metamon@config:with_shrink_limit_or_panic(C, N).
-file("src/metamon.gleam", 104).
?DOC(" Re-export of `with_max_edges_or_panic`.\n").
-spec with_max_edges_or_panic(metamon@config:config(), integer()) -> metamon@config:config().
with_max_edges_or_panic(C, N) ->
metamon@config:with_max_edges_or_panic(C, N).
-file("src/metamon.gleam", 109).
?DOC(" Re-export of `with_regression_file_or_panic`.\n").
-spec with_regression_file_or_panic(metamon@config:config(), binary()) -> metamon@config:config().
with_regression_file_or_panic(C, Path) ->
metamon@config:with_regression_file_or_panic(C, Path).
-file("src/metamon.gleam", 120).
?DOC(
" Choose the failure-report output format. `Text` (default) is\n"
" human-friendly; `Json` is single-line JSON for CI / LLM consumers.\n"
).
-spec with_output_format(
metamon@config:config(),
metamon@config:output_format()
) -> metamon@config:config().
with_output_format(C, Fmt) ->
metamon@config:with_output_format(C, Fmt).
-file("src/metamon.gleam", 138).
?DOC(
" Construct a Plain MR. The relation is checked between\n"
" `f(source_input)` and `f(transform.apply(source_input))`.\n"
).
-spec mr(
binary(),
metamon@transform:transform(HDS),
metamon@relation:relation(HDU)
) -> mr(HDS, HDU).
mr(Name, Transform, Relation) ->
{mr, metamon@internal@runner:plain(Name, Transform, Relation)}.
-file("src/metamon.gleam", 149).
?DOC(
" Construct an Equivariant MR. The relation is checked between\n"
" `output_transform.apply(f(source_input))` and\n"
" `f(input_transform.apply(source_input))`.\n"
).
-spec mr_equivariant(
binary(),
metamon@transform:transform(HDY),
metamon@transform:transform(HEA),
metamon@relation:relation(HEA)
) -> mr(HDY, HEA).
mr_equivariant(Name, Input_transform, Output_transform, Relation) ->
{mr,
metamon@internal@runner:equivariant(
Name,
Input_transform,
Output_transform,
Relation
)}.
-file("src/metamon.gleam", 159).
?DOC(" Get the user-facing name of an MR.\n").
-spec name_of(mr(any(), any())) -> binary().
name_of(M) ->
metamon@internal@runner:morph_name(erlang:element(2, M)).
-file("src/metamon.gleam", 171).
?DOC(" Run a property with an explicit configuration.\n").
-spec forall_with(
metamon@config:config(),
metamon@generator:generator(HEL),
fun((HEL) -> boolean())
) -> nil.
forall_with(Cfg, G, Property) ->
metamon@internal@runner:run_forall(Cfg, <<"forall"/utf8>>, G, Property).
-file("src/metamon.gleam", 166).
?DOC(" Run a property over many random inputs.\n").
-spec forall(metamon@generator:generator(HEJ), fun((HEJ) -> boolean())) -> nil.
forall(G, Property) ->
forall_with(default_config(), G, Property).
-file("src/metamon.gleam", 191).
?DOC(" `forall_observable` with an explicit configuration.\n").
-spec forall_observable_with(
metamon@config:config(),
metamon@generator:generator(HEQ),
fun((HEQ) -> {any(), boolean()})
) -> nil.
forall_observable_with(Cfg, G, Predicate) ->
metamon@internal@runner:run_forall(
Cfg,
<<"forall_observable"/utf8>>,
G,
fun(Input) ->
{Observed, Holds} = Predicate(Input),
metamon@annotate:annotate_value(
<<"predicate value"/utf8>>,
Observed
),
Holds
end
).
-file("src/metamon.gleam", 186).
?DOC(
" Run a property whose predicate also exposes its intermediate value.\n"
"\n"
" `predicate` returns `#(observation, holds)`. `holds` decides whether\n"
" the property is satisfied (same as `forall`); `observation` is\n"
" recorded under the label `predicate value` and shown in the failure\n"
" report — no manual `annotate.annotate_value` is needed.\n"
"\n"
" Use this when the predicate's intermediate value (typically `f(input)`)\n"
" is what determines the branch. Without it, `forall` failure reports\n"
" only show the shrunk source input, which can force a debug round-trip\n"
" to recover what `f(input)` actually was.\n"
).
-spec forall_observable(
metamon@generator:generator(HEN),
fun((HEN) -> {any(), boolean()})
) -> nil.
forall_observable(G, Predicate) ->
forall_observable_with(default_config(), G, Predicate).
-file("src/metamon.gleam", 209).
?DOC(" Run a metamorphic relation with an explicit configuration.\n").
-spec forall_morph_with(
metamon@config:config(),
metamon@generator:generator(HEY),
mr(HEY, HFA),
fun((HEY) -> HFA)
) -> nil.
forall_morph_with(Cfg, G, M, F) ->
metamon@internal@runner:run_forall_morph(
Cfg,
<<"forall_morph"/utf8>>,
G,
erlang:element(2, M),
F
).
-file("src/metamon.gleam", 204).
?DOC(" Run a metamorphic relation over many random inputs.\n").
-spec forall_morph(
metamon@generator:generator(HET),
mr(HET, HEV),
fun((HET) -> HEV)
) -> nil.
forall_morph(G, M, F) ->
forall_morph_with(default_config(), G, M, F).
-file("src/metamon.gleam", 219).
?DOC(" Run a metamorphic relation against a single input. Generator-free.\n").
-spec assert_morph(HFD, mr(HFD, HFE), fun((HFD) -> HFE)) -> nil.
assert_morph(Input, M, F) ->
metamon@internal@runner:run_assert_morph(
<<"assert_morph"/utf8>>,
erlang:element(2, M),
F,
Input
).
-file("src/metamon.gleam", 239).
?DOC(" `forall_morph_n` with an explicit configuration.\n").
-spec forall_morph_n_with(
metamon@config:config(),
metamon@generator:generator(HFN),
list(metamon@transform:transform(HFN)),
metamon@relation:relation_n(HFR),
fun((HFN) -> HFR)
) -> nil.
forall_morph_n_with(Cfg, G, Transforms, Rel, F) ->
metamon@internal@runner:run_forall_morph_n(
Cfg,
<<"forall_morph_n"/utf8>>,
G,
Transforms,
Rel,
F
).
-file("src/metamon.gleam", 229).
?DOC(
" Run an N-ary metamorphic relation: apply each of `transforms` to\n"
" the source input to build follow-up inputs, then assert that the\n"
" resulting outputs `[f(x), f(T0(x)), ..., f(Tn(x))]` satisfy\n"
" `relation`. Useful when the property requires comparing more than\n"
" two outputs in one shot (e.g. `(a, b, c) ↦ op(op(a,b), c)` and its\n"
" re-associations all agree).\n"
).
-spec forall_morph_n(
metamon@generator:generator(HFH),
list(metamon@transform:transform(HFH)),
metamon@relation:relation_n(HFL),
fun((HFH) -> HFL)
) -> nil.
forall_morph_n(G, Transforms, Rel, F) ->
forall_morph_n_with(default_config(), G, Transforms, Rel, F).
-file("src/metamon.gleam", 262).
-spec list_map_specs(list(mr(HFZ, HGA))) -> list(metamon@internal@runner:morph_spec(HFZ, HGA)).
list_map_specs(Ms) ->
case Ms of
[] ->
[];
[First | Rest] ->
[erlang:element(2, First) | list_map_specs(Rest)]
end.
-file("src/metamon.gleam", 252).
?DOC(
" Run multiple metamorphic relations against the same generator.\n"
" Each MR is tried independently; failures are collected and reported\n"
" together at the end.\n"
).
-spec forall_morphs(
metamon@generator:generator(HFT),
list(mr(HFT, HFV)),
fun((HFT) -> HFV)
) -> nil.
forall_morphs(G, Ms, F) ->
metamon@internal@runner:run_forall_morphs(
default_config(),
<<"forall_morphs"/utf8>>,
G,
list_map_specs(Ms),
F
).
-file("src/metamon.gleam", 289).
?DOC(
" `f(f(x)) == f(x)`. Idempotency.\n"
"\n"
" Encoded as a Plain MR whose transform is `f` itself and whose\n"
" relation is structural equality.\n"
).
-spec idempotency_of(binary(), fun((HGH) -> HGH)) -> mr(HGH, HGH).
idempotency_of(Name, F) ->
Apply_t = metamon@transform:new(<<"apply "/utf8, Name/binary>>, F),
mr(Name, Apply_t, metamon@relation:equal()).
-file("src/metamon.gleam", 295).
?DOC(" `f(T(x)) == f(x)` — `f` is invariant under the input transform.\n").
-spec invariant_under(binary(), metamon@transform:transform(HGK)) -> mr(HGK, any()).
invariant_under(Name, Under) ->
mr(Name, Under, metamon@relation:equal()).
-file("src/metamon.gleam", 301).
?DOC(
" `R(U(f(x)), f(T(x)))`. Equivariance: the input transform `T` and\n"
" the output transform `U` commute with `f` modulo `R`.\n"
).
-spec equivariant_under(
binary(),
metamon@transform:transform(HGP),
metamon@transform:transform(HGR),
metamon@relation:relation(HGR)
) -> mr(HGP, HGR).
equivariant_under(Name, Input_transform, Output_transform, Rel) ->
mr_equivariant(Name, Input_transform, Output_transform, Rel).
-file("src/metamon.gleam", 328).
?DOC(
" `op(a, b) == op(b, a)` — `op` is commutative.\n"
"\n"
" The MR is over the input pair `#(a, a)` and the output type `b`.\n"
" Use it as:\n"
"\n"
" ```gleam\n"
" let mr = metamon.commutativity_of(name: \"add_commutative\")\n"
" metamon.forall_morph(\n"
" generator.tuple2(int_gen, int_gen),\n"
" mr,\n"
" fn(pair) { add(pair.0, pair.1) },\n"
" )\n"
" ```\n"
).
-spec commutativity_of(binary()) -> mr({HGW, HGW}, any()).
commutativity_of(Name) ->
Swap = metamon@transform:new(
<<"swap"/utf8>>,
fun(Pair) -> {erlang:element(2, Pair), erlang:element(1, Pair)} end
),
mr(Name, Swap, metamon@relation:equal()).
-file("src/metamon.gleam", 365).
?DOC(" `forall_round_trip` with an explicit configuration.\n").
-spec forall_round_trip_with(
metamon@config:config(),
metamon@generator:generator(HHG),
binary(),
fun((HHG) -> HHI),
fun((HHI) -> {ok, HHG} | {error, any()})
) -> nil.
forall_round_trip_with(Cfg, Gen, Name, Encode, Decode) ->
metamon@internal@runner:run_forall(
Cfg,
<<<<"round_trip["/utf8, Name/binary>>/binary, "]"/utf8>>,
Gen,
fun(Input) -> case Decode(Encode(Input)) of
{ok, Decoded} ->
Decoded =:= Input;
{error, _} ->
false
end end
).
-file("src/metamon.gleam", 349).
?DOC(
" Run a round-trip property over many random inputs:\n"
" `decode(encode(x))` must equal `Ok(x)` for every generated `x`.\n"
"\n"
" The failure report header is `round_trip[<name>]` so it is\n"
" immediately obvious from the panic which round-trip broke. The\n"
" underlying machinery is the same as `forall`, including shrinking\n"
" of the source input.\n"
"\n"
" ```gleam\n"
" metamon.forall_round_trip(\n"
" gen: generator.bit_array(range.constant(0, 16)),\n"
" name: \"base64\",\n"
" encode: base64.encode,\n"
" decode: base64.decode,\n"
" )\n"
" ```\n"
).
-spec forall_round_trip(
metamon@generator:generator(HHA),
binary(),
fun((HHA) -> HHC),
fun((HHC) -> {ok, HHA} | {error, any()})
) -> nil.
forall_round_trip(Gen, Name, Encode, Decode) ->
forall_round_trip_with(default_config(), Gen, Name, Encode, Decode).
-file("src/metamon.gleam", 428).
?DOC(" `forall_round_trip_partial` with an explicit configuration.\n").
-spec forall_round_trip_partial_with(
metamon@config:config(),
metamon@generator:generator(HHV),
binary(),
fun((HHV) -> {ok, HHX} | {error, any()}),
fun((HHX) -> {ok, HHV} | {error, any()})
) -> nil.
forall_round_trip_partial_with(Cfg, Gen, Name, Encode, Decode) ->
metamon@internal@runner:run_forall(
Cfg,
<<<<"round_trip["/utf8, Name/binary>>/binary, "]"/utf8>>,
Gen,
fun(Input) -> case Encode(Input) of
{error, _} ->
true;
{ok, Encoded} ->
case Decode(Encoded) of
{ok, Decoded} ->
Decoded =:= Input;
{error, _} ->
false
end
end end
).
-file("src/metamon.gleam", 412).
?DOC(
" Run a round-trip property where the encoder is partial: the\n"
" encoder returns `Result(b, e_enc)` because not every generated\n"
" input is a valid input for the codec. Inputs the encoder rejects\n"
" (`Error(_)`) are treated as out of scope and skipped — the\n"
" property succeeds for them.\n"
"\n"
" Use this variant for codecs whose encoder has structural\n"
" preconditions: byte-alignment requirements, value-range checks,\n"
" hrp / version constraints, etc. A typical pattern is to combine\n"
" `forall_round_trip_partial` with a generator that produces the\n"
" surrounding inputs (e.g. arbitrary `BitArray` plus arbitrary\n"
" version bytes); the encoder filters down to the valid subset and\n"
" the property checks the round-trip on that subset.\n"
"\n"
" The failure report header is `round_trip[<name>]` so it is\n"
" immediately obvious from the panic which round-trip broke. The\n"
" underlying machinery is the same as `forall`, including shrinking\n"
" of the source input.\n"
"\n"
" ```gleam\n"
" metamon.forall_round_trip_partial(\n"
" gen: generator.tuple2(byte_gen, payload_gen),\n"
" name: \"base58check\",\n"
" encode: fn(pair) { base58check.encode(pair.0, pair.1) },\n"
" decode: fn(s) {\n"
" case base58check.decode(s) {\n"
" Ok(decoded) -> Ok(#(decoded.version, decoded.payload))\n"
" Error(e) -> Error(e)\n"
" }\n"
" },\n"
" )\n"
" ```\n"
).
-spec forall_round_trip_partial(
metamon@generator:generator(HHM),
binary(),
fun((HHM) -> {ok, HHO} | {error, any()}),
fun((HHO) -> {ok, HHM} | {error, any()})
) -> nil.
forall_round_trip_partial(Gen, Name, Encode, Decode) ->
forall_round_trip_partial_with(default_config(), Gen, Name, Encode, Decode).
-file("src/metamon.gleam", 497).
?DOC(" `forall_round_trip_under` with an explicit configuration.\n").
-spec forall_round_trip_under_with(
metamon@config:config(),
metamon@generator:generator(HIL),
binary(),
fun((HIL) -> HIN),
fun((HIN) -> {ok, HIL} | {error, any()}),
metamon@relation:relation(HIL)
) -> nil.
forall_round_trip_under_with(Cfg, Gen, Name, Encode, Decode, Equality) ->
metamon@internal@runner:run_forall(
Cfg,
<<<<"round_trip["/utf8, Name/binary>>/binary, "]"/utf8>>,
Gen,
fun(Input) -> case Decode(Encode(Input)) of
{ok, Decoded} ->
(erlang:element(3, Equality))(Decoded, Input);
{error, _} ->
false
end end
).
-file("src/metamon.gleam", 479).
?DOC(
" Run a round-trip property using a caller-supplied equality\n"
" `Relation(a)` instead of structural `==`. The decoded value must\n"
" satisfy `equality.holds(decoded, input)`.\n"
"\n"
" Use this when the source type carries values that are not\n"
" preserved verbatim across encode → decode: opaque types whose\n"
" decoded form normalises (e.g. multipart `Part` with re-derived\n"
" convenience fields), MIME types that lowercase the essence, JSON\n"
" values whose key order is implementation-defined. Composing with\n"
" `relation.equivalent_under(via, name)` lets you compare on a\n"
" projection (e.g. `headers + body` ignoring derived caches).\n"
"\n"
" The failure report header is `round_trip[<name>]`. The relation's\n"
" own `name` appears under `relation:` in the failure block, just\n"
" like `forall_morph` failures.\n"
"\n"
" ```gleam\n"
" metamon.forall_round_trip_under(\n"
" gen: parts_gen(),\n"
" name: \"multipart_round_trip\",\n"
" encode: fn(parts) { multipartkit.encode(boundary, parts) },\n"
" decode: fn(body) { multipartkit.parse(body, content_type) },\n"
" equality: relation.equivalent_under(\n"
" fn(parts) { list.map(parts, part_payload) },\n"
" \"wire_payload\",\n"
" ),\n"
" )\n"
" ```\n"
).
-spec forall_round_trip_under(
metamon@generator:generator(HIE),
binary(),
fun((HIE) -> HIG),
fun((HIG) -> {ok, HIE} | {error, any()}),
metamon@relation:relation(HIE)
) -> nil.
forall_round_trip_under(Gen, Name, Encode, Decode, Equality) ->
forall_round_trip_under_with(
default_config(),
Gen,
Name,
Encode,
Decode,
Equality
).