{"id":50421227,"url":"https://github.com/nao1215/metamon","last_synced_at":"2026-05-31T08:03:26.972Z","repository":{"id":355872224,"uuid":"1229636770","full_name":"nao1215/metamon","owner":"nao1215","description":"Property-based testing and metamorphic testing combinator library for Gleam","archived":false,"fork":false,"pushed_at":"2026-05-20T12:34:46.000Z","size":312,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-20T16:59:27.672Z","etag":null,"topics":["gleam","gleam-library","metamorphic-testing","model-based-testing","pbt","property-based-testing","shrinking","testing"],"latest_commit_sha":null,"homepage":null,"language":"Gleam","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/nao1215.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"nao1215"}},"created_at":"2026-05-05T08:38:01.000Z","updated_at":"2026-05-20T12:34:40.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/nao1215/metamon","commit_stats":null,"previous_names":["nao1215/metamon"],"tags_count":9,"template":false,"template_full_name":null,"purl":"pkg:github/nao1215/metamon","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Fmetamon","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Fmetamon/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Fmetamon/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Fmetamon/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/nao1215","download_url":"https://codeload.github.com/nao1215/metamon/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Fmetamon/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33723550,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-31T02:00:06.040Z","response_time":95,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["gleam","gleam-library","metamorphic-testing","model-based-testing","pbt","property-based-testing","shrinking","testing"],"created_at":"2026-05-31T08:03:25.842Z","updated_at":"2026-05-31T08:03:26.963Z","avatar_url":"https://github.com/nao1215.png","language":"Gleam","funding_links":["https://github.com/sponsors/nao1215"],"categories":[],"sub_categories":[],"readme":"# metamon\n\n[![CI](https://github.com/nao1215/metamon/actions/workflows/ci.yml/badge.svg)](https://github.com/nao1215/metamon/actions/workflows/ci.yml)\n[![Hex.pm](https://img.shields.io/hexpm/v/metamon)](https://hex.pm/packages/metamon)\n\nProperty-based testing and metamorphic testing combinator library for\nGleam.\n\nmetamon treats both styles of testing as first-class concepts:\n\n- Property-based testing (PBT): state a single-input predicate and let\n  metamon search the input space for counter-examples.\n- Metamorphic testing (MT): state a relation between outputs produced\n  by two related inputs (e.g. `f(x)` and `f(reverse(x))`) and let\n  metamon search for inputs where the relation breaks.\n\nEvery snippet on this page is the body of a `pub fn readme_*_test` in\n[`test/readme_test.gleam`](test/readme_test.gleam) and is checked by\n`gleam test` on every CI run, so the examples cannot drift out of\nsync with the API.\n\n## Table of contents\n\n- [Install](#install)\n- [Quick start](#quick-start)\n- [Property-based testing](#property-based-testing)\n- [Metamorphic relations](#metamorphic-relations)\n- [Round-trip variants](#round-trip-variants)\n- [Generators](#generators)\n- [Transforms and relations](#transforms-and-relations)\n- [Coverage and annotations](#coverage-and-annotations)\n- [JSON output](#json-output)\n- [N-ary metamorphic relations](#n-ary-metamorphic-relations)\n- [Stateful / model-based testing](#stateful--model-based-testing)\n- [Case study: CRDT algebraic laws](#case-study-crdt-algebraic-laws)\n- [Configuration](#configuration)\n- [Reading a failure report](#reading-a-failure-report)\n- [Choosing PBT vs MT vs `assert_morph`](#choosing-pbt-vs-mt-vs-assert_morph)\n- [Modules](#modules)\n- [Compatibility](#compatibility)\n- [Further reading](#further-reading)\n- [Development](#development)\n- [Contributing](#contributing)\n- [License](#license)\n\n## Install\n\n```sh\ngleam add metamon --dev\n```\n\nRequirements: Gleam 1.15+, Erlang/OTP 27+, Node.js 22+.\n\nSee [doc/targets.md](doc/targets.md) for target details and the\nruntime dependency footprint.\n\n## Quick start\n\nThe smallest useful test states a metamorphic relation. `string.trim`\nis idempotent — applying it twice gives the same result as applying\nit once. metamon ships a template for this exact shape.\n\n```gleam\nimport gleam/string\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\n\npub fn trim_idempotent_test() {\n  let mr = metamon.idempotency_of(name: \"trim_idempotent\", of: string.trim)\n  metamon.forall_morph(\n    generator.string_ascii(range.constant(0, 16)),\n    mr,\n    string.trim,\n  )\n}\n```\n\nIf `string.trim` ever stops being idempotent, the test panics with a\nnamed report:\n\n```\n× metamorphic relation `trim_idempotent` failed\n  test:        forall_morph\n  source:      random(seed=..., size=12)\n  config seed: 1714867200000123\n  runs:        7 / 100\n  shrinks:     4\n\n  transform:   `apply trim_idempotent`\n  relation:    `equal`\n\n  source input  (shrunk):\n    \"  a\"\n  ...\n```\n\n## Property-based testing\n\n### `forall`\n\n`metamon.forall` runs a single-argument predicate against many\ngenerated inputs:\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\nimport gleam/list\n\npub fn reverse_twice_is_identity_test() {\n  metamon.forall(\n    generator.list_of(\n      generator.int(range.constant(0, 9)),\n      range.constant(0, 5),\n    ),\n    fn(xs) { list.reverse(list.reverse(xs)) == xs },\n  )\n}\n```\n\n### `forall_observable` — show the predicate's intermediate value\n\nWhen the branch in the predicate hinges on an intermediate value\n(`f(input)`), the plain `forall` failure report only shows the shrunk\nsource input, not what the predicate was actually inspecting.\n`forall_observable` lets the predicate return `#(observation, holds)`;\nthe observation is rendered into the failure report under the label\n`predicate value`:\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\nimport gleam/string\n\npub fn parse_round_trip_test() {\n  metamon.forall_observable(\n    generator.string_ascii(range.constant(0, 8)),\n    fn(s) {\n      let trimmed = string.trim(s)\n      // `trimmed` is what the property body cares about, so expose it.\n      #(trimmed, string.length(trimmed) \u003c= string.length(s))\n    },\n  )\n}\n```\n\nThis is equivalent to a plain `forall` plus a manual\n`annotate.annotate_value(\"predicate value\", trimmed)`; the helper\nsaves that one line and makes the intent explicit.\n\n## Metamorphic relations\n\nA metamorphic relation says \"if you transform the input in this known\nway, the output should change in this known way.\" metamon ships\ntemplates for the most common shapes.\n\n### Idempotency: `f(f(x)) == f(x)`\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\n\npub fn sort_dedupe_idempotent_test() {\n  let mr =\n    metamon.idempotency_of(name: \"sort_dedupe_idempotent\", of: sort_dedupe)\n  metamon.forall_morph(\n    generator.list_of(\n      generator.int(range.constant(0, 9)),\n      range.constant(0, 6),\n    ),\n    mr,\n    sort_dedupe,\n  )\n}\n```\n\n### Round-trip: `decode(encode(x)) == Ok(x)`\n\n`f` and `inverse` should round-trip cleanly. `forall_round_trip` is\nthe one-liner — the failure report header is `round_trip[\u003cname\u003e]` so\nit is immediately obvious from the panic which round-trip broke:\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\nimport gleam/int\n\npub fn int_string_round_trip_test() {\n  metamon.forall_round_trip(\n    gen: generator.int(range.constant(-1000, 1000)),\n    name: \"int_string_round_trip\",\n    encode: int.to_string,\n    decode: int.parse,\n  )\n}\n```\n\nRound-trip is not exposed as an `Mr` template because the relation\ncompares the decoded output against the source input, which the\ntwo-point `f(source) ⟷ f(transform(source))` shape of an MR cannot\nexpress directly. `forall_round_trip` wraps `forall` instead, so the\nerror reports retain the same shrunk-source rendering.\n\n### Invariance: `f(T(x)) == f(x)`\n\nThe function is unaffected by the transformation. `list.length` is\ninvariant under `reverse`:\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\nimport metamon/transform/list as list_t\nimport gleam/list\n\npub fn length_invariant_under_reverse_test() {\n  let mr =\n    metamon.invariant_under(\n      name: \"length_invariant_under_reverse\",\n      under: list_t.reverse(),\n    )\n  metamon.forall_morph(\n    generator.list_of(\n      generator.int(range.constant(0, 9)),\n      range.constant(0, 8),\n    ),\n    mr,\n    list.length,\n  )\n}\n```\n\n### Equivariance: `U(f(x)) == f(T(x))`\n\nThe output also transforms in a known way. `map(g)` commutes with\n`reverse`:\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\nimport metamon/relation\nimport metamon/transform/list as list_t\nimport gleam/list\n\npub fn map_commutes_with_reverse_test() {\n  let mr =\n    metamon.equivariant_under(\n      name: \"map_commutes_with_reverse\",\n      input: list_t.reverse(),\n      output: list_t.reverse(),\n      relation: relation.equal(),\n    )\n  metamon.forall_morph(\n    generator.list_of(\n      generator.int(range.constant(0, 9)),\n      range.constant(0, 6),\n    ),\n    mr,\n    fn(xs) { list.map(xs, fn(n) { n * 2 }) },\n  )\n}\n```\n\n### Manual MR construction\n\nWhen the four templates above don't fit, build the MR by hand from a\n`Transform(a)` and a `Relation(b)`:\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\nimport metamon/relation\nimport metamon/transform/list as list_t\nimport gleam/list\n\npub fn sum_invariant_under_append_zero_test() {\n  let append_zero = list_t.append(0)\n  let mr =\n    metamon.mr(\n      name: \"sum_invariant_under_append_zero\",\n      transform: append_zero,\n      relation: relation.equal(),\n    )\n  metamon.forall_morph(\n    generator.list_of(\n      generator.int(range.constant(0, 9)),\n      range.constant(0, 5),\n    ),\n    mr,\n    fn(items) { list.fold(items, 0, fn(acc, n) { acc + n }) },\n  )\n}\n```\n\n### `assert_morph` — single hand-supplied input\n\nNo generator, just a fixed input. Useful for regression tests of a\nspecific failing case:\n\n```gleam\nimport metamon\nimport metamon/transform/list as list_t\n\npub fn sum_reverse_regression_test() {\n  let mr =\n    metamon.invariant_under(\n      name: \"sum_invariant_under_reverse\",\n      under: list_t.reverse(),\n    )\n  metamon.assert_morph([1, 2, 3, 4, 5], mr, list_sum)\n}\n```\n\n### Commutativity: `op(a, b) == op(b, a)`\n\nThe `commutativity_of` template builds an MR over the input pair\n`#(a, a)` whose transform swaps the two components:\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\n\nfn add(a: Int, b: Int) -\u003e Int {\n  a + b\n}\n\npub fn add_commutative_test() {\n  let mr = metamon.commutativity_of(name: \"add_commutative\")\n  metamon.forall_morph(\n    generator.tuple2(\n      generator.int(range.constant(-50, 50)),\n      generator.int(range.constant(-50, 50)),\n    ),\n    mr,\n    fn(pair) { add(pair.0, pair.1) },\n  )\n}\n```\n\n### `forall_morphs` — multiple MRs against the same `f`\n\nEach MR is exercised independently and the runner reports all\nfailures, not just the first:\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\nimport metamon/transform/list as list_t\n\npub fn sum_multi_mr_test() {\n  let invariant_under_reverse =\n    metamon.invariant_under(name: \"sum_under_reverse\", under: list_t.reverse())\n  let invariant_under_append_zero =\n    metamon.invariant_under(\n      name: \"sum_under_append_zero\",\n      under: list_t.append(0),\n    )\n  metamon.forall_morphs(\n    generator.list_of(\n      generator.int(range.constant(0, 9)),\n      range.constant(0, 4),\n    ),\n    [invariant_under_reverse, invariant_under_append_zero],\n    list_sum,\n  )\n}\n```\n\n## Round-trip variants\n\n\u003e **Tip — encoder / decoder libraries.** If your library exposes a\n\u003e paired `encode` / `decode`, a single `forall_round_trip` call\n\u003e exercises a useful first invariant with no per-input handwriting.\n\u003e Drop this into `test/` as a starter property:\n\u003e\n\u003e ```gleam\n\u003e import metamon\n\u003e import metamon/generator\n\u003e import metamon/generator/range\n\u003e\n\u003e pub fn encode_decode_round_trip_test() {\n\u003e   metamon.forall_round_trip(\n\u003e     gen: generator.bit_array(range.constant(0, 64)),\n\u003e     name: \"my_codec\",\n\u003e     encode: my_codec.encode,\n\u003e     decode: my_codec.decode,\n\u003e   )\n\u003e }\n\u003e ```\n\u003e\n\u003e The two variants below\n\u003e (`forall_round_trip_partial` and `forall_round_trip_under`) cover\n\u003e the common shapes a real codec hits — partial encoders that reject\n\u003e some inputs, and decoded forms that compare equal only under a\n\u003e normalising projection.\n\n`forall_round_trip` requires `encode: a -\u003e b` and\n`decode: b -\u003e Result(a, _)`. Real codec libraries often produce\n`encode: a -\u003e Result(b, _)` (when not every input is valid for the\ncodec) or have a source type whose decoded form does not compare equal\nunder structural `==`. Two named variants cover both cases without a\nhand-rolled shim.\n\n### Partial encoder — `forall_round_trip_partial`\n\nInputs the encoder rejects are skipped (treated as out of scope, not\nfailures). Useful for codecs with structural preconditions\n(byte-alignment, version range, hrp / variant constraints, etc.).\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\nimport gleam/int\n\npub fn readme_round_trip_partial_test() {\n  // Stand-in for a real partial encoder: only encodes even integers.\n  metamon.forall_round_trip_partial(\n    gen: generator.int(range.constant(-50, 50)),\n    name: \"even_only_round_trip\",\n    encode: fn(n) {\n      case n % 2 == 0 {\n        True -\u003e Ok(int.to_string(n))\n        False -\u003e Error(Nil)\n      }\n    },\n    decode: fn(s) { int.parse(s) },\n  )\n}\n```\n\n### Custom equality — `forall_round_trip_under`\n\nPass a `Relation(a)` instead of structural `==`. Useful for opaque\ntypes whose decoded form normalises (multipart `Part` re-deriving its\n`name` / `filename` cache, MIME types whose essence lowercases, etc.).\nCombine with `relation.equivalent_under(via, name)` to compare on a\nprojection.\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\nimport metamon/relation\nimport gleam/string\n\npub fn readme_round_trip_under_test() {\n  let case_insensitive =\n    relation.equivalent_under(string.lowercase, \"case_insensitive\")\n  metamon.forall_round_trip_under(\n    gen: generator.string_alpha(range.constant(0, 8)),\n    name: \"case_insensitive_round_trip\",\n    encode: string.lowercase,\n    decode: fn(s) { Ok(s) },\n    equality: case_insensitive,\n  )\n}\n```\n\n## Generators\n\n### Common shortcuts\n\n```gleam\nimport metamon/generator\nimport metamon/generator/range\n\npub fn shortcut_examples() {\n  let _: generator.Generator(Bool)      = generator.bool()\n  let _: generator.Generator(Int)       = generator.non_negative_int()\n  let _: generator.Generator(Int)       = generator.positive_int()\n  let _: generator.Generator(Int)       = generator.negative_int()\n  let _: generator.Generator(Int)       = generator.byte()\n  let _: generator.Generator(BitArray)  = generator.bit_array(range.constant(0, 16))\n  let _: generator.Generator(BitArray)  = generator.bit_array_printable(range.constant(0, 16))\n  let _: generator.Generator(BitArray)  = generator.bit_array_utf8(range.constant(0, 8))\n  let _: generator.Generator(String)    = generator.string_alpha(range.constant(1, 8))\n  let _: generator.Generator(String)    = generator.string_alphanumeric(range.constant(1, 8))\n  let _: generator.Generator(String)    = generator.string_digit(range.constant(1, 4))\n  let _: generator.Generator(String)    = generator.string_printable_ascii(range.constant(0, 16))\n  Nil\n}\n```\n\nThese wrap `generator.int(range.linear(...))` etc. with sensible\ndefault ranges. Reach for the underlying `generator.int(...)` when you\nneed different bounds or shrink origins.\n\nFor single-character generators (`a-zA-Z`, `0-9`, etc.), see the\n`ascii_*` family in the [Modules](#modules) table:\n`ascii_lower`, `ascii_upper`, `ascii_letter`, `ascii_digit`,\n`ascii_alphanumeric`, `ascii_printable`. The `string_*` shortcuts\nabove wrap each of those with a length range.\n\n`bit_array_printable` constrains every byte to printable ASCII\n(`0x20`..`0x7E`) — useful when fuzzing parsers that take `BitArray`\nbut expect printable input (HTTP headers, MIME types, etc.).\n`bit_array_utf8` produces a `BitArray` that is guaranteed to decode\nback to a string; the `len` argument is the codepoint count, so the\nbyte length will be larger when the random string contains multi-byte\ncodepoints.\n\n`generator.string_unicode(len)` and `generator.unicode_codepoint()`\nproduce valid UTF-8 scalar values only. `generator.float(lo, hi)` is\nfinite-only. For genuinely-NaN / `±Infinity` inputs, use\n`generator.float_special()` or splice the special values via\n`with_examples(my_float_gen, generator.float_special_edges())`. See\n[doc/limitations.md](doc/limitations.md) for the full caveats around\nUTF-8 surrogates, Unicode normalisation, and BEAM vs JavaScript float\nbehaviour.\n\n### Building record-shaped values\n\nUse `map2` … `map8` (and the matching `tuple2` … `tuple8`) for\nrecord-shaped generators. Prefer applicative composition over nested\n`bind` so integrated shrinking applies on every component.\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\n\npub type User {\n  User(name: String, age: Int)\n}\n\npub fn user_age_in_bounds_test() {\n  let user_gen =\n    generator.map2(\n      generator.string_ascii(range.constant(1, 8)),\n      generator.int(range.constant(0, 120)),\n      User,\n    )\n  metamon.forall(user_gen, fn(u: User) { u.age \u003e= 0 \u0026\u0026 u.age \u003c= 120 })\n}\n```\n\n### `one_of`, `element_of`, `frequency`\n\n```gleam\nimport metamon\nimport metamon/generator\n\npub fn traffic_light_test() {\n  let traffic_light =\n    generator.frequency([\n      #(3, generator.return(\"green\")),\n      #(2, generator.return(\"yellow\")),\n      #(1, generator.return(\"red\")),\n    ])\n  metamon.forall(traffic_light, fn(colour) {\n    colour == \"green\" || colour == \"yellow\" || colour == \"red\"\n  })\n}\n```\n\n`one_of` picks uniformly from a list of generators. For the common\ncase of \"pick uniformly from a fixed set of values\", `element_of`\nskips the per-value `return` wrap:\n\n```gleam\nimport metamon\nimport metamon/generator\n\npub fn extension_is_known_test() {\n  metamon.forall(\n    generator.element_of([\"html\", \"json\", \"png\", \"pdf\"]),\n    fn(ext) {\n      ext == \"html\" || ext == \"json\" || ext == \"png\" || ext == \"pdf\"\n    },\n  )\n}\n```\n\n`element_of` panics when the list is empty (mirroring `one_of([])`).\nEvery value becomes an edge, so the runner tries each one before\nsampling.\n\n### `with_examples` — guarantee specific inputs are tried\n\nThe runner consumes `edges` first, before random generation. Use\n`with_examples` to add must-try inputs from past bug reports:\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\nimport gleam/string\n\npub fn trim_idempotent_with_examples_test() {\n  let trim_idempotent =\n    metamon.idempotency_of(\n      name: \"trim_idempotent_with_examples\",\n      of: string.trim,\n    )\n  metamon.forall_morph(\n    generator.string_ascii(range.constant(0, 8))\n      |\u003e generator.with_examples([\"\", \" \", \"  \", \"\\t\\n  hi  \\n\\t\"]),\n    trim_idempotent,\n    string.trim,\n  )\n}\n```\n\n### Recursive generators\n\n`recursive(base, step)` halves `size` on each recursion, so it always\nterminates. At `size = 0` only `base` is used.\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\n\npub type Tree {\n  Leaf(Int)\n  Node(Tree, Tree)\n}\n\npub fn tree_has_leaves_test() {\n  let tree_gen =\n    generator.recursive(\n      generator.map(generator.int(range.constant(0, 9)), Leaf),\n      fn(smaller) {\n        generator.map2(smaller, smaller, Node)\n      },\n    )\n  metamon.forall(tree_gen, fn(t) {\n    case count_leaves(t) {\n      n -\u003e n \u003e= 1\n    }\n  })\n}\n```\n\n## Transforms and relations\n\n### Composing transforms\n\n```gleam\nimport metamon/transform\nimport gleam/string\nimport gleeunit/should\n\npub fn lowercase_then_trim_test() {\n  let normalise =\n    transform.then(\n      transform.new(\"lowercase\", string.lowercase),\n      transform.new(\"trim\", string.trim),\n    )\n  should.equal(normalise.apply(\"  Hello  \"), \"hello\")\n  should.equal(normalise.name, \"lowercase |\u003e trim\")\n}\n```\n\n### Combining relations\n\n```gleam\nimport metamon/relation\nimport gleeunit/should\n\npub fn and_combination_test() {\n  let positive =\n    relation.new(\"positive_left\", fn(left: Int, _right: Int) { left \u003e 0 })\n  let nonzero_right =\n    relation.new(\"nonzero_right\", fn(_left: Int, right: Int) { right != 0 })\n  let combined = relation.and(positive, nonzero_right)\n  should.be_true(combined.holds(5, 3))\n  should.be_false(combined.holds(0, 3))\n}\n```\n\n`relation.or`, `relation.invert`, `relation.implies` complete the\nBoolean set. For the most common domain shapes, four shortcut\ncombinators skip the `and` / custom-`new` plumbing entirely:\n\n```gleam\nimport metamon/relation\nimport gleam/int\nimport gleeunit/should\n\npub fn approximately_test() {\n  // approximately(epsilon): Float equality with a tolerance.\n  let approx = relation.approximately(0.0001)\n  should.be_true(approx.holds(0.1 +. 0.2, 0.3))\n}\n\npub fn permutation_of_test() {\n  // permutation_of: two lists are equal as multisets.\n  let perm = relation.permutation_of()\n  should.be_true(perm.holds([3, 1, 2], [1, 2, 3]))\n}\n\npub fn subset_of_test() {\n  // subset_of: multiset subset — every element of left is matched\n  // against a *distinct* element of right (so [1, 1] is not a\n  // subset of [1] because the second 1 has no match left).\n  let sub = relation.subset_of()\n  should.be_true(sub.holds([2, 3], [1, 2, 3, 4]))\n  should.be_false(sub.holds([1, 1], [1]))\n  should.be_true(sub.holds([1, 1], [1, 1, 2]))\n}\n\npub fn set_subset_of_test() {\n  // set_subset_of: set subset — every element of left is contained\n  // somewhere in right, ignoring multiplicity. Reach for this when\n  // the lists are header-style (presence matters, count does not).\n  let sub = relation.set_subset_of()\n  should.be_true(sub.holds([1, 1], [1]))\n  should.be_true(sub.holds([2, 3], [1, 2, 3, 4]))\n  should.be_false(sub.holds([1, 4], [1, 2, 3]))\n}\n\npub fn monotone_test() {\n  // monotone(cmp): holds when cmp(left, right) is Lt or Eq. Useful\n  // for monotonic-by-construction functions (list.sort, list.scan,\n  // ...).\n  let mono = relation.monotone(int.compare)\n  should.be_true(mono.holds(3, 5))\n}\n```\n\n### `equivalent_under` — relation on a normalised view\n\n```gleam\nimport metamon/relation\nimport gleam/string\nimport gleeunit/should\n\npub fn case_insensitive_test() {\n  let r =\n    relation.equivalent_under(string.lowercase, \"case_insensitive\")\n  should.be_true(r.holds(\"Hello\", \"HELLO\"))\n  should.be_false(r.holds(\"Hello\", \"World\"))\n}\n```\n\n## Coverage and annotations\n\n### `cover` and `classify`\n\n`cover(target, label, condition)` asserts that the labelled hits\naccount for at least `target%` of all inputs. The property fails even\nwhen every individual run passed if coverage falls short:\n\n```gleam\nimport metamon\nimport metamon/coverage\nimport metamon/generator\nimport metamon/generator/range\nimport gleam/string\n\npub fn trim_never_grows_input_test() {\n  metamon.forall(\n    generator.string_ascii(range.constant(0, 8)),\n    fn(s) {\n      coverage.cover(5.0, \"non_empty\", string.length(s) \u003e 0)\n      coverage.classify(\"contains_space\", string.contains(s, \" \"))\n      string.length(string.trim(s)) \u003c= string.length(s)\n    },\n  )\n}\n```\n\n### `annotate` and `footnote`\n\nThese are silent on success and surface only on failure, so liberal\nuse is cheap:\n\n```gleam\nimport metamon\nimport metamon/annotate\nimport metamon/generator\nimport metamon/generator/range\nimport gleam/int\n\npub fn annotated_property_test() {\n  metamon.forall(\n    generator.int(range.constant(0, 100)),\n    fn(n) {\n      annotate.annotate(\"currently checking n = \" \u003c\u003e int.to_string(n))\n      annotate.annotate_value(\"doubled\", n * 2)\n      annotate.footnote(\"hint: n is non-negative by construction\")\n      n \u003e= 0\n    },\n  )\n}\n```\n\n## JSON output\n\nSet the output format on a per-test config to swap the human-readable\ntext for a single-line JSON object:\n\n```gleam\nimport metamon\nimport metamon/config\nimport metamon/generator\nimport metamon/generator/range\n\npub fn json_output_test() {\n  let cfg =\n    metamon.default_config()\n    |\u003e metamon.with_output_format(config.Json)\n  metamon.forall_with(\n    cfg,\n    generator.int(range.constant(0, 100)),\n    fn(n) { n \u003e= 0 },\n  )\n}\n```\n\nThe schema is stable. Top-level keys: `mr_name`, `test_name`,\n`config_seed`, `runs_done`, `runs_total`, `shrinks_done`,\n`shrink_capped`, `source`, `morph_mode`, `relation`, `source_input`,\n`followup_input`, `source_output`, `followup_output`, `annotations`,\n`footnotes`, `coverage`. Pipe to `jq`, post to GitHub Actions\nannotations, or feed into an LLM analysis step.\n\n## N-ary metamorphic relations\n\nWhen the relation must compare more than two outputs in one shot,\nhand `forall_morph_n` a list of input transforms and a `RelationN`:\n\n```gleam\nimport gleam/list\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\nimport metamon/relation\nimport metamon/transform/list as list_t\n\nfn list_sum(items: List(Int)) -\u003e Int {\n  list.fold(items, 0, fn(acc, n) { acc + n })\n}\n\npub fn sum_under_three_invariants_test() {\n  metamon.forall_morph_n(\n    generator.list_of(\n      generator.int(range.constant(0, 9)),\n      range.constant(0, 4),\n    ),\n    [list_t.reverse(), list_t.append(0)],\n    relation.all_equal(),\n    list_sum,\n  )\n}\n```\n\n`relation.all_equal()` asserts every output is structurally equal;\n`relation.pairwise(r)` lifts a binary relation to a chain check.\n\n## Stateful / model-based testing\n\nFor state machines, build a list of `Command(model, real)` and run it\nagainst a parallel `(model, real)` pair:\n\n```gleam\nimport gleam/dict\nimport gleeunit/should\nimport metamon/command\nimport metamon/stateful\n\ntype Model {\n  Model(value: Int)\n}\n\ntype Real {\n  Real(state: dict.Dict(String, Int))\n}\n\npub fn counter_increments_test() {\n  let increment =\n    command.always(\n      name: \"increment\",\n      next_model: fn(m: Model) { Model(value: m.value + 1) },\n      run: fn(_real: Real) { Ok(Nil) },\n    )\n  let initial_model = Model(value: 0)\n  let initial_real = Real(state: dict.from_list([#(\"counter\", 0)]))\n  let outcome =\n    stateful.run(initial_model, initial_real, [increment, increment])\n  case outcome {\n    stateful.Passed(final, _, _) -\u003e should.equal(final, Model(value: 2))\n    stateful.Failed(_, _, _, _) -\u003e should.fail()\n  }\n}\n```\n\n`command.no_precondition` (alias: `command.always`) skips the\nprecondition; use `command.new` to gate commands on the current model.\n\"No precondition\" is not the same as \"always runs\" — the command's\n`run` step can still return `Error(reason)`, which halts the sequence\nand reports `Failed`. `stateful.assert_passed` panics with a\nstructured failure message when that happens. Prefer the\n`no_precondition` name in new code; the `always` alias is kept for\nbackward compatibility.\n\n`stateful.run` requires at least one `Command`; passing `[]` is a\nprogramming error (vacuous test) and panics with a structured message.\nUse `forall(...)` if you need a non-stateful property instead.\n\n`stateful.assert_passed` also panics when every command's\n`precondition` returned `False` — i.e. the outcome is\n`Passed(_, ran: 0, skipped: N)` with `N \u003e 0`. The test never compared\nmodel and real, so silently passing would hide precondition or\ninitial-model bugs. Adjust the preconditions or initial model so at\nleast one command fires.\n\n## Case study: CRDT algebraic laws\n\nCRDTs (conflict-free replicated data types) are characterised by\nthree laws on their `merge` operator:\n\n| Law | Statement |\n|-----|-----------|\n| Idempotency | `merge(a, a) == a` |\n| Commutativity | `merge(a, b) == merge(b, a)` |\n| Associativity | `merge(merge(a, b), c) == merge(a, merge(b, c))` |\n\nA G-Counter (grow-only counter) is the simplest CRDT: each replica\nholds a per-node counter, and `merge` takes the per-key max. Here is\nthe implementation alongside the three laws expressed in metamon:\n\n```gleam\nimport gleam/dict.{type Dict}\nimport gleam/list\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\n\ntype GCounter =\n  Dict(String, Int)\n\nfn merge(left: GCounter, right: GCounter) -\u003e GCounter {\n  dict.fold(right, left, fn(acc, key, value) {\n    case dict.get(acc, key) {\n      Ok(current) if current \u003e= value -\u003e acc\n      _ -\u003e dict.insert(acc, key, value)\n    }\n  })\n}\n\nfn make_gcounter(pairs: List(#(String, Int))) -\u003e GCounter {\n  list.fold(pairs, dict.new(), fn(acc, pair) {\n    dict.insert(acc, pair.0, pair.1)\n  })\n}\n\npub fn readme_gcounter_idempotent_test() {\n  metamon.forall(generator.int(range.constant(0, 100)), fn(seed) {\n    let counter =\n      make_gcounter([\n        #(\"node-A\", seed),\n        #(\"node-B\", seed * 2),\n        #(\"node-C\", seed * 3),\n      ])\n    merge(counter, counter) == counter\n  })\n}\n\npub fn readme_gcounter_commutative_test() {\n  metamon.forall(generator.int(range.constant(0, 100)), fn(seed) {\n    let left = make_gcounter([#(\"node-A\", seed), #(\"node-B\", seed * 2)])\n    let right = make_gcounter([#(\"node-A\", seed + 1), #(\"node-C\", seed * 3)])\n    merge(left, right) == merge(right, left)\n  })\n}\n\npub fn readme_gcounter_associative_test() {\n  metamon.forall(generator.int(range.constant(0, 100)), fn(seed) {\n    let a = make_gcounter([#(\"X\", seed), #(\"Y\", seed + 1)])\n    let b = make_gcounter([#(\"X\", seed + 5), #(\"Z\", seed + 7)])\n    let c = make_gcounter([#(\"Y\", seed + 9), #(\"Z\", seed + 11)])\n    merge(merge(a, b), c) == merge(a, merge(b, c))\n  })\n}\n```\n\nThree things to note:\n\n1. **`forall` is the right primitive when the property is a binary or\n   ternary equation.** `idempotency_of` and `commutativity_of` are\n   metamorphic-relation templates over a unary `f: a -\u003e a`, so they\n   do not directly express `merge(a, a) == a` or `merge(a, b, c)`-style\n   laws. Falling back to `forall` is the idiomatic way to test n-ary\n   operators.\n2. **Per-replica state is generated indirectly via a seed.** The\n   generator produces an `Int`, and the test body uses it to derive\n   deterministic counter snapshots. This keeps the generator simple\n   while still exercising the laws across many shapes.\n3. **Failure of any one law is a Byzantine-style bug.** If\n   associativity fails, replicas that received the same operations\n   in different orders will diverge silently — exactly the class of\n   bug that property-based testing catches and unit testing misses.\n\nThe three test functions above are mirrored in\n`test/readme_test.gleam` so the example cannot drift from the actual\nAPI.\n\n## Configuration\n\nOverride the defaults via `with_*` builders. Validation errors return\n`Result(Config, ConfigError)` instead of silently falling back to a\ndefault.\n\n```gleam\nimport metamon\nimport metamon/generator\nimport metamon/generator/range\n\npub fn configured_property_test() {\n  let assert Ok(c) =\n    metamon.with_runs(\n      metamon.default_config()\n        |\u003e metamon.with_seed(metamon.seed(2026)),\n      30,\n    )\n  metamon.forall_with(\n    c,\n    generator.int(range.constant(-100, 100)),\n    fn(n) { n + 0 == n },\n  )\n}\n```\n\n`with_runs`, `with_max_size`, `with_shrink_limit`, `with_max_edges`,\n`with_regression_file` all return `Result(Config, ConfigError)`.\n`with_seed` and `with_diff_enabled` are total.\n\n## Reading a failure report\n\nFailures are panics whose message is structured for human reading.\nEvery block is optional; only the parts that apply to your test\nappear:\n\n```\n× metamorphic relation `\u003cmr.name\u003e` failed\n  test:        \u003cgleeunit test name\u003e\n  source:      edge(\u003ci\u003e) | random(seed=\u003cn\u003e, size=\u003cn\u003e)\n  config seed: \u003cinteger\u003e\n  runs:        \u003ci\u003e / \u003ctotal\u003e\n  shrinks:     \u003ccount\u003e | \u003ccount\u003e+ (limit reached)\n\n  transform:   `\u003cinput transform name\u003e`\n  output:      `\u003coutput transform name\u003e`     ; equivariant only\n  relation:    `\u003crelation name\u003e`\n\n  source input  (shrunk):\n    \u003cpretty-printed input\u003e\n  follow-up input  (= transform(source)):\n    \u003cpretty-printed input\u003e\n  source output:\n    \u003cpretty-printed output\u003e\n  follow-up output:\n    \u003cpretty-printed output\u003e\n\n  diff (source_output vs follow-up_output):\n    \u003cstructural diff\u003e\n\n  annotations:\n    - \u003cannotate calls in registration order\u003e\n\n  coverage:\n    \u003clabel\u003e: \u003chits\u003e/\u003ctotal\u003e (\u003cpct\u003e%) target≥\u003ctarget\u003e%\n\n  footnotes:\n    - \u003cfootnote calls\u003e\n\n  reproduce (paste into a test):\n    // The MR failed for this input. To pin it as a regression,\n    // call assert_morph with the shrunk input and the same MR.\n    let input = \u003cpretty-printed shrunk input\u003e\n    metamon.assert_morph(input, mr, f)\n```\n\nThe `reproduce` block paired with `metamon.with_regression_file(...)`\ngives you two ways to keep failing inputs around:\n\n- Reproduce block (in-test): paste the shrunk input directly into a\n  Gleam test as a literal. Survives regardless of the runner state.\n- Regression file (`with_regression_file(path)`): the runner appends\n  each failure to a TOML file and re-runs every entry on startup\n  before any random generation. Useful when you want past failures\n  rerun on every CI build without changing the test source.\n\n## Choosing PBT vs MT vs `assert_morph`\n\n| You want to test | Reach for |\n|---|---|\n| \"for every input the answer satisfies P\" | `metamon.forall` |\n| \"transforming the input in this way preserves the output\" | `metamon.forall_morph` with `invariant_under` or `idempotency_of` |\n| \"transforming the input in this way changes the output in this way\" | `metamon.forall_morph` with `equivariant_under` or a hand-built MR |\n| \"this one specific input must always pass this MR\" | `metamon.assert_morph` |\n| \"all of these MRs must hold for the same `f`\" | `metamon.forall_morphs` |\n\n`forall_morphs` requires ≥ 1 MR; passing `[]` panics with a structured\n\"empty MR list (vacuous test)\" message — use `forall(...)` for the\nno-MR case. Same applies to `forall_morph_n` and `forall_morph_n_with`\nwith an empty `transforms` list.\n\n## Modules\n\n| Module | Responsibility |\n|---|---|\n| `metamon` | Top-level API: `forall`, `forall_with`, `forall_observable`, `forall_observable_with`, `forall_morph`, `forall_morph_with`, `forall_morph_n`, `forall_morph_n_with`, `assert_morph`, `forall_morphs`, `forall_round_trip`, `forall_round_trip_with`, `forall_round_trip_partial`, `forall_round_trip_partial_with`, `forall_round_trip_under`, `forall_round_trip_under_with`, `Mr` (opaque), `mr`, `mr_equivariant`, `name_of`, `idempotency_of`, `invariant_under`, `equivariant_under`, `commutativity_of`, `OutputFormat`, `with_output_format`, `seed`, `random_seed`, `default_config` and all `with_*` re-exports |\n| `metamon/config` | `Config`, `ConfigError`, `default_config`, `with_runs`, `with_seed`, `with_max_size`, `with_shrink_limit`, `with_max_edges`, `with_regression_file`, `with_diff_enabled` |\n| `metamon/generator` | `Generator(a)` (opaque), `generate`, `sample`, `statistics`, `with_examples`, `add_edges`, `no_edges`, `return`, `map`, `bind`, `map2`..`map8`, `tuple2`..`tuple8`, `one_of`, `element_of`, `frequency`, `sized`, `resize`, `scale`, `filter`, `recursive`, `int`, `float`, `float_special`, `float_special_edges`, `bool`, `non_negative_int`, `positive_int`, `negative_int`, `byte`, `bit_array`, `bit_array_printable`, `bit_array_utf8`, `ascii_*`, `unicode_codepoint`, `string`, `string_ascii`, `string_alpha`, `string_alphanumeric`, `string_digit`, `string_printable_ascii`, `string_unicode`, `list_of`, `non_empty_list_of`, `dict_of`, `set_of`, `option_of`, `result_of` |\n| `metamon/generator/seed` | xorshift32-based `Seed` with `split` (target-portable; identical streams on BEAM and JS) |\n| `metamon/generator/tree` | Lazy rose tree used as the integrated shrink representation |\n| `metamon/generator/range` | `singleton`, `constant`, `linear`, `linear_from`, `exponential` (Hedgehog-style ranges) |\n| `metamon/transform` | `Transform(a)`, `new`, `identity`, `constant`, `then`, `repeat`, `rename` |\n| `metamon/transform/list`   | `reverse`, `dedupe`, `prepend`, `append`, `shuffle` |\n| `metamon/transform/string` | `reverse`, `lowercase`, `uppercase`, `trim`, `prepend`, `append` |\n| `metamon/transform/dict`   | `insert`, `remove`, `shuffle_keys` |\n| `metamon/relation` | `Relation(b)`, `new`, `equal`, `not_equal`, `equivalent_under`, `approximately`, `permutation_of`, `subset_of` (multiset), `set_subset_of` (set), `monotone`, `implies`, `and`, `or`, `invert`, `rename`, `RelationN(b)`, `n_new`, `all_equal`, `pairwise` (N-ary relations for `forall_morph_n`) |\n| `metamon/diff` | Structural diff used in failure reports: `diff`, `diff_string`, `render`, `Same`/`Differ`/`ListDiff`/`TupleDiff`/`StringDiff` |\n| `metamon/annotate` | `annotate`, `annotate_value`, `footnote`, `reset`, `current_annotations`, `current_footnotes` |\n| `metamon/coverage` | `classify`, `cover`, `cover_at_least`, `classify_in_bucket`, `collect`, `snapshot`, `shortfalls`, `actual_pct`, `target_pct_of`, `requirements_of`, `collected_of`, `hits_for`, `first_shortfall`, `Pct`/`Count` requirement kinds |\n| `metamon/command` | `Command(model, real)`, `new`, `no_precondition`, `always` (alias of `no_precondition`), `name_of` (model-based testing primitive) |\n| `metamon/stateful` | `run(initial_model, initial_real, commands)`, `assert_passed`, `Outcome` (model-based test runner) |\n\n## Compatibility\n\n- Gleam 1.15+\n- BEAM target: Erlang/OTP 27 or later (CI covers OTP 27 and 28).\n- JavaScript target: Node.js 22 or later (CI covers Node 22 and 24).\n\nSee [doc/targets.md](doc/targets.md) for the full target story,\nruntime dependency footprint, and behavioural differences between\nBEAM and JavaScript.\n\n## Further reading\n\n- [doc/limitations.md](doc/limitations.md): known scope cuts and\n  workarounds (`bind` shrinking, `recursive` branch-swap, `filter`\n  retry limit, JS parallel runners, UTF-8 surrogate exclusion, float\n  special-value asymmetry).\n- [doc/targets.md](doc/targets.md): supported targets, runtime\n  requirements, dependency footprint.\n\n## Development\n\nThis project uses [mise](https://mise.jdx.dev/) to manage Gleam and\nErlang versions, and [just](https://just.systems/) as a task runner.\n\n```sh\nmise install    # install Gleam and Erlang\njust ci         # download deps and run all checks\njust test       # gleam test\njust format     # gleam format\njust check      # all checks without deps download\n```\n\n## Contributing\n\nContributions are welcome. See\n[CONTRIBUTING.md](https://github.com/nao1215/metamon/blob/main/CONTRIBUTING.md)\nfor details.\n\n## License\n\n[MIT](https://github.com/nao1215/metamon/blob/main/LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnao1215%2Fmetamon","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fnao1215%2Fmetamon","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnao1215%2Fmetamon/lists"}