{
  "id": "P025",
  "slug": "run-length-compressor",
  "key": "P025-run-length-compressor",
  "title": "Run-length compressor",
  "summary": "Compresses a line of text by writing each long run of one character as ~count~character - only where that is shorter - with ~~ for a ~ in the text, so digits need no escaping; expands it back with the position and reason of any error, gives the compression ratio, and checks that every compressed text expands to the original.",
  "entry": "main.eml",
  "ui": "terminal",
  "readme": "# P025 - Run-length compressor\n\nCompresses a line of text by writing each long run of one character as a\ncount, expands such text back, and shows what the compression saved. Every\ncompressed text is expanded again on the spot and compared with the original.\n\n- `main.eml` - the menu, the questions and their checks, and what the screen\n  shows\n- `rle.eml` - the format: runs, compressing, and expanding with errors\n- `text.eml` - trimming, spaces, a percentage rounded half up, lists in words\n\nThe format:\n\n- A run of one character is written `~count~character` when that is shorter\n  than writing it out: `aaaaaa` becomes `~6~a`. A run of 4 stays as it is\n  (`~4~a` is no shorter); a run of 5 or more becomes a count.\n- A `~` in the text is written `~~`, and a run of three or more `~` becomes a\n  count too: `~~~~~` is `~5~~`.\n- Everything else stays as it is. Digits need no escaping, because a count\n  only ever comes right after a `~`: `a3` stays `a3`, and `3333333` becomes\n  `~7~3`. A format that writes `a3b2` cannot tell a count from a digit in the\n  text; the `~` before every count removes that ambiguity.\n\nHow each part works:\n\n- Compressing splits the text into runs and writes each one the shorter way,\n  so no other way of writing the same runs in this format is shorter.\n- Expanding reads the text left to right. A `~` must be followed by a second\n  `~` or by a count of 1 to 9999 (no leading zeros), a closing `~` and the\n  character to repeat; anything else is an error, reported with its position\n  and a caret under it. An expansion longer than 5000 characters is refused\n  before it is built.\n- The ratio is the compressed length as a percentage of the original, halves\n  rounded up.\n\nWhat is checked: a text to compress is 1 to 300 characters, a text to expand\n1 to 600 (a compressed text is at most twice its original); a longer one is\nasked for again, and an empty line cancels. A menu choice other than 1 to 4\nis asked again.\n\nSessions: `sessions/basic.in` compresses the classic 67-character W/B line to\n25 characters (37%) and expands it back, leaves `Room 101, 2nd floor` as it is\n(100%), shows `a~b~c` growing to 140% because each `~` is written twice,\ncompresses runs of digits, `~` and `.`, shows the format, and expands\n`x~3~3y`; `sessions/bad-input.in` types menu choices that are not 1 to 4,\nempty texts that cancel, a text of 301 characters, and compressed texts with\na lone `~` at the end, a `~` before a letter, a count with no closing `~`,\ncounts of 0, 012 and 12345, a run with no character, an expansion of 19998\ncharacters, and `~~`.\n\nBuilt on the verified corpus cases `run-length-encoder` (runs counted while\nwalking the text), `run-length-encoding` (encoding, its inverse, and the round trip as the test) and\n`run-length-escaping` (why digits in the text break a plain count format, and\nwhat an explicit boundary fixes).\n",
  "modules": [
    {
      "name": "main.eml",
      "eml": "# P025 run-length compressor: runs of one character written as counts, with an\n# escape for the \"~\" the format uses, both ways, and a check that every\n# compressed text expands back to the original.\nimport rle\nimport text\n\n300 => longest_text\n5000 => longest_expansion\n\ndef ask_text(prompt, most):\n    # A line of at most `most` characters, kept as typed, spaces included;\n    # an empty line cancels and gives \"\".\n    while True:\n        input(prompt) => s\n        if s == \"\" or len(s) <= most:\n            return s\n        (\"That is \" + str(len(s)) + \" characters; at most \" + str(most) + \" fit here. Type it again, or nothing to cancel.\") ^0\n\ndef compress_one(s):\n    rle.compress(s) => c\n    (\"Compressed: \" + c) ^0\n    (text.plural(len(s), \"character\") + \" -> \" + str(len(c)) + \" (\" + str(text.percent(len(c), len(s))) + \"%).\") ^0\n    rle.counted_runs(s) => counted\n    if len(counted) == 0:\n        \"No run is long enough to write as a count.\" ^0\n    else:\n        [] => named\n        for r in counted:\n            if len(named) < 5:\n                named + [\"'\" + r[0] + \"' x \" + str(r[1])] => named\n        if len(counted) > 5:\n            named + [str(len(counted) - 5) + \" more\"] => named\n        (\"Written as counts: \" + text.listed(named) + \".\") ^0\n    if len(c) > len(s):\n        \"That is longer than the text: each '~' in it is written as '~~'.\" ^0\n    rle.expand(c, longest_expansion) => back\n    if back[0] == \"ok\" and back[1] == s:\n        \"Expanded again, it matches the original.\" ^0\n    else:\n        \"Expanded again, it does NOT match the original.\" ^0\n\ndef expand_one(s):\n    rle.expand(s, longest_expansion) => r\n    if r[0] == \"ok\":\n        (\"Expanded: \" + r[1]) ^0\n        (text.plural(len(s), \"character\") + \" -> \" + str(len(r[1])) + \".\") ^0\n    elif r[0] == \"long\":\n        (\"That would expand to more than \" + str(longest_expansion) + \" characters.\") ^0\n    else:\n        (\"Position \" + str(r[1]) + \": \" + r[2]) ^0\n        (\"  \" + s) ^0\n        (\"  \" + text.spaces(r[1] - 1) + \"^\") ^0\n\nTrue => running\nwhile running:\n    \"\" ^0\n    \"== Run-length compressor ==\" ^0\n    \"1) compress  2) expand  3) the format  4) quit\" ^0\n    text.trim(input(\"choice> \")) => choice\n    if choice == \"1\":\n        ask_text(\"text> \", longest_text) => s\n        if s == \"\":\n            \"Cancelled.\" ^0\n        else:\n            compress_one(s)\n    elif choice == \"2\":\n        ask_text(\"compressed> \", 2 * longest_text) => s\n        if s == \"\":\n            \"Cancelled.\" ^0\n        else:\n            expand_one(s)\n    elif choice == \"3\":\n        \"- A run of one character is written ~count~character when that is\" ^0\n        \"  shorter than writing it out: aaaaaa becomes ~6~a.\" ^0\n        \"- A ~ in the text is written ~~.\" ^0\n        \"- Everything else stays as it is. Digits need no escaping, because a\" ^0\n        \"  count only ever comes right after a ~: a3 stays a3, and 3333333\" ^0\n        \"  becomes ~7~3.\" ^0\n    elif choice == \"4\":\n        False => running\n    else:\n        \"Pick 1, 2, 3 or 4.\" ^0\n\"Bye.\" ^0\n",
      "python": "import rle\nimport text\nlongest_text = 300\nlongest_expansion = 5000\n\ndef ask_text(prompt, most):\n    while True:\n        s = input(prompt)\n        if s == \"\" or len(s) <= most:\n            return s\n        print(\"That is \" + str(len(s)) + \" characters; at most \" + str(most) + \" fit here. Type it again, or nothing to cancel.\")\n\ndef compress_one(s):\n    c = rle.compress(s)\n    print(\"Compressed: \" + c)\n    print(text.plural(len(s), \"character\") + \" -> \" + str(len(c)) + \" (\" + str(text.percent(len(c), len(s))) + \"%).\")\n    counted = rle.counted_runs(s)\n    if len(counted) == 0:\n        print(\"No run is long enough to write as a count.\")\n    else:\n        named = []\n        for r in counted:\n            if len(named) < 5:\n                named = named + [\"'\" + r[0] + \"' x \" + str(r[1])]\n        if len(counted) > 5:\n            named = named + [str(len(counted) - 5) + \" more\"]\n        print(\"Written as counts: \" + text.listed(named) + \".\")\n    if len(c) > len(s):\n        print(\"That is longer than the text: each '~' in it is written as '~~'.\")\n    back = rle.expand(c, longest_expansion)\n    if back[0] == \"ok\" and back[1] == s:\n        print(\"Expanded again, it matches the original.\")\n    else:\n        print(\"Expanded again, it does NOT match the original.\")\n\ndef expand_one(s):\n    r = rle.expand(s, longest_expansion)\n    if r[0] == \"ok\":\n        print(\"Expanded: \" + r[1])\n        print(text.plural(len(s), \"character\") + \" -> \" + str(len(r[1])) + \".\")\n    elif r[0] == \"long\":\n        print(\"That would expand to more than \" + str(longest_expansion) + \" characters.\")\n    else:\n        print(\"Position \" + str(r[1]) + \": \" + r[2])\n        print(\"  \" + s)\n        print(\"  \" + text.spaces(r[1] - 1) + \"^\")\n\nrunning = True\nwhile running:\n    print(\"\")\n    print(\"== Run-length compressor ==\")\n    print(\"1) compress  2) expand  3) the format  4) quit\")\n    choice = text.trim(input(\"choice> \"))\n    if choice == \"1\":\n        s = ask_text(\"text> \", longest_text)\n        if s == \"\":\n            print(\"Cancelled.\")\n        else:\n            compress_one(s)\n    elif choice == \"2\":\n        s = ask_text(\"compressed> \", 2 * longest_text)\n        if s == \"\":\n            print(\"Cancelled.\")\n        else:\n            expand_one(s)\n    elif choice == \"3\":\n        print(\"- A run of one character is written ~count~character when that is\")\n        print(\"  shorter than writing it out: aaaaaa becomes ~6~a.\")\n        print(\"- A ~ in the text is written ~~.\")\n        print(\"- Everything else stays as it is. Digits need no escaping, because a\")\n        print(\"  count only ever comes right after a ~: a3 stays a3, and 3333333\")\n        print(\"  becomes ~7~3.\")\n    elif choice == \"4\":\n        running = False\n    else:\n        print(\"Pick 1, 2, 3 or 4.\")\nprint(\"Bye.\")\n"
    },
    {
      "name": "rle.eml",
      "eml": "# P025 run-length compressor - the format, both ways.\n#\n# A run of one character is written ~count~character when that is shorter\n# than writing the run out; a \"~\" in the text is written \"~~\"; everything else\n# is written as it is. A count only ever comes right after a \"~\", so digits in\n# the text need no escaping - the ambiguity a plain \"a3b2\" format has with\n# digits does not arise.\n\n\"0123456789\" => digits\n\ndef runs(s):\n    # [[character, count], ...], one for each run of a single character.\n    [] => out\n    0 => i\n    while i < len(s):\n        s[i] => c\n        1 => n\n        while i + n < len(s) and s[i + n] == c:\n            n + 1 => n\n        out + [[c, n]] => out\n        i + n => i\n    return out\n\ndef as_count(c, n):\n    # True when ~n~c is shorter than the run written out (\"~\" is two each).\n    n => written\n    if c == \"~\":\n        2 * n => written\n    return len(str(n)) + 3 < written\n\ndef compress(s):\n    \"\" => out\n    for r in runs(s):\n        if as_count(r[0], r[1]):\n            out + \"~\" + str(r[1]) + \"~\" + r[0] => out\n        elif r[0] == \"~\":\n            out + \"~~\" * r[1] => out\n        else:\n            out + r[0] * r[1] => out\n    return out\n\ndef counted_runs(s):\n    # The runs compress() writes as counts.\n    [] => out\n    for r in runs(s):\n        if as_count(r[0], r[1]):\n            out + [r] => out\n    return out\n\ndef expand(s, limit):\n    # [\"ok\", text], or [\"error\", position, reason] with the position counted\n    # from 1, or [\"long\"] when the text would pass limit characters.\n    \"\" => out\n    0 => i\n    while i < len(s):\n        s[i] => c\n        if c != \"~\":\n            out + c => out\n            i + 1 => i\n        elif i + 1 >= len(s):\n            return [\"error\", i + 1, \"a '~' at the end needs a second '~' or a count after it.\"]\n        elif s[i + 1] == \"~\":\n            out + \"~\" => out\n            i + 2 => i\n        elif s[i + 1] in digits:\n            i + 1 => j\n            0 => count\n            while j < len(s) and s[j] in digits:\n                if j - i <= 5:\n                    count * 10 + int(s[j]) => count\n                j + 1 => j\n            if j >= len(s) or s[j] != \"~\":\n                return [\"error\", i + 1, \"the count after this '~' needs a closing '~'.\"]\n            if s[i + 1] == \"0\" or j - i - 1 > 4:\n                return [\"error\", i + 1, \"a count is a whole number from 1 to 9999, written without leading zeros.\"]\n            if j + 1 >= len(s):\n                return [\"error\", i + 1, \"the run that starts here has no character to repeat.\"]\n            if len(out) + count > limit:\n                return [\"long\"]\n            out + s[j + 1] * count => out\n            j + 2 => i\n        else:\n            return [\"error\", i + 1, \"a '~' must be followed by a second '~' or a count.\"]\n        if len(out) > limit:\n            return [\"long\"]\n    return [\"ok\", out]\n",
      "python": "digits = \"0123456789\"\n\ndef runs(s):\n    out = []\n    i = 0\n    while i < len(s):\n        c = s[i]\n        n = 1\n        while i + n < len(s) and s[i + n] == c:\n            n = n + 1\n        out = out + [[c, n]]\n        i = i + n\n    return out\n\ndef as_count(c, n):\n    written = n\n    if c == \"~\":\n        written = 2 * n\n    return len(str(n)) + 3 < written\n\ndef compress(s):\n    out = \"\"\n    for r in runs(s):\n        if as_count(r[0], r[1]):\n            out = out + \"~\" + str(r[1]) + \"~\" + r[0]\n        elif r[0] == \"~\":\n            out = out + \"~~\" * r[1]\n        else:\n            out = out + r[0] * r[1]\n    return out\n\ndef counted_runs(s):\n    out = []\n    for r in runs(s):\n        if as_count(r[0], r[1]):\n            out = out + [r]\n    return out\n\ndef expand(s, limit):\n    out = \"\"\n    i = 0\n    while i < len(s):\n        c = s[i]\n        if c != \"~\":\n            out = out + c\n            i = i + 1\n        elif i + 1 >= len(s):\n            return [\"error\", i + 1, \"a '~' at the end needs a second '~' or a count after it.\"]\n        elif s[i + 1] == \"~\":\n            out = out + \"~\"\n            i = i + 2\n        elif s[i + 1] in digits:\n            j = i + 1\n            count = 0\n            while j < len(s) and s[j] in digits:\n                if j - i <= 5:\n                    count = count * 10 + int(s[j])\n                j = j + 1\n            if j >= len(s) or s[j] != \"~\":\n                return [\"error\", i + 1, \"the count after this '~' needs a closing '~'.\"]\n            if s[i + 1] == \"0\" or j - i - 1 > 4:\n                return [\"error\", i + 1, \"a count is a whole number from 1 to 9999, written without leading zeros.\"]\n            if j + 1 >= len(s):\n                return [\"error\", i + 1, \"the run that starts here has no character to repeat.\"]\n            if len(out) + count > limit:\n                return [\"long\"]\n            out = out + s[j + 1] * count\n            i = j + 2\n        else:\n            return [\"error\", i + 1, \"a '~' must be followed by a second '~' or a count.\"]\n        if len(out) > limit:\n            return [\"long\"]\n    return [\"ok\", out]\n"
    },
    {
      "name": "text.eml",
      "eml": "# P025 run-length compressor - small text and number helpers. The interpreter\n# that checks every session does not run string methods yet, so these are\n# written out here.\n\ndef trim(s):\n    # s without the spaces at either end.\n    0 => i\n    len(s) => j\n    while i < j and s[i] == \" \":\n        i + 1 => i\n    while j > i and s[j - 1] == \" \":\n        j - 1 => j\n    return s[i:j]\n\ndef spaces(n):\n    return \" \" * n\n\ndef quotient(a, b):\n    # a divided by b, rounded down, for a >= 0 and b > 0; exact for the\n    # sizes used here.\n    return int((a - a % b) / b)\n\ndef percent(part, whole):\n    # part as a whole-number percentage of whole, halves rounded up.\n    return quotient(200 * part + whole, 2 * whole)\n\ndef listed(items):\n    # [\"a\", \"b\", \"c\"] as \"a, b and c\".\n    \"\" => out\n    0 => i\n    while i < len(items):\n        if i > 0 and i == len(items) - 1:\n            out + \" and \" => out\n        elif i > 0:\n            out + \", \" => out\n        out + items[i] => out\n        i + 1 => i\n    return out\n\ndef plural(n, word):\n    if n == 1:\n        return \"1 \" + word\n    return str(n) + \" \" + word + \"s\"\n",
      "python": "def trim(s):\n    i = 0\n    j = len(s)\n    while i < j and s[i] == \" \":\n        i = i + 1\n    while j > i and s[j - 1] == \" \":\n        j = j - 1\n    return s[i:j]\n\ndef spaces(n):\n    return \" \" * n\n\ndef quotient(a, b):\n    return int((a - a % b) / b)\n\ndef percent(part, whole):\n    return quotient(200 * part + whole, 2 * whole)\n\ndef listed(items):\n    out = \"\"\n    i = 0\n    while i < len(items):\n        if i > 0 and i == len(items) - 1:\n            out = out + \" and \"\n        elif i > 0:\n            out = out + \", \"\n        out = out + items[i]\n        i = i + 1\n    return out\n\ndef plural(n, word):\n    if n == 1:\n        return \"1 \" + word\n    return str(n) + \" \" + word + \"s\"\n"
    }
  ],
  "sessions": [
    {
      "name": "bad-input",
      "input": "x\n0\n5\n1\n\n2\n\n1\nababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababc\naaaaaaaaaa\n2\n~\n2\nab~\n2\na~b\n2\n~12c\n2\n~0~a\n2\n~012~a\n2\n~12345~a\n2\n~5~\n2\n~9999~a~9999~b\n2\n~~\n4\n",
      "screen": "\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> x\nPick 1, 2, 3 or 4.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 0\nPick 1, 2, 3 or 4.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 5\nPick 1, 2, 3 or 4.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 1\ntext> \nCancelled.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> \nCancelled.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 1\ntext> ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababc\nThat is 301 characters; at most 300 fit here. Type it again, or nothing to cancel.\ntext> aaaaaaaaaa\nCompressed: ~10~a\n10 characters -> 5 (50%).\nWritten as counts: 'a' x 10.\nExpanded again, it matches the original.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> ~\nPosition 1: a '~' at the end needs a second '~' or a count after it.\n  ~\n  ^\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> ab~\nPosition 3: a '~' at the end needs a second '~' or a count after it.\n  ab~\n    ^\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> a~b\nPosition 2: a '~' must be followed by a second '~' or a count.\n  a~b\n   ^\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> ~12c\nPosition 1: the count after this '~' needs a closing '~'.\n  ~12c\n  ^\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> ~0~a\nPosition 1: a count is a whole number from 1 to 9999, written without leading zeros.\n  ~0~a\n  ^\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> ~012~a\nPosition 1: a count is a whole number from 1 to 9999, written without leading zeros.\n  ~012~a\n  ^\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> ~12345~a\nPosition 1: a count is a whole number from 1 to 9999, written without leading zeros.\n  ~12345~a\n  ^\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> ~5~\nPosition 1: the run that starts here has no character to repeat.\n  ~5~\n  ^\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> ~9999~a~9999~b\nThat would expand to more than 5000 characters.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> ~~\nExpanded: ~\n2 characters -> 1.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 4\nBye.\n",
      "interpreter": "equal"
    },
    {
      "name": "basic",
      "input": "1\nWWWWWWWWWWWWBWWWWWWWWWWWWBBBWWWWWWWWWWWWWWWWWWWWWWWWBWWWWWWWWWWWWWW\n2\n~12~WB~12~WBBB~24~WB~14~W\n1\nRoom 101, 2nd floor\n1\na~b~c\n1\n3333333 and ~~~~~ and ......\n3\n2\nx~3~3y\n4\n",
      "screen": "\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 1\ntext> WWWWWWWWWWWWBWWWWWWWWWWWWBBBWWWWWWWWWWWWWWWWWWWWWWWWBWWWWWWWWWWWWWW\nCompressed: ~12~WB~12~WBBB~24~WB~14~W\n67 characters -> 25 (37%).\nWritten as counts: 'W' x 12, 'W' x 12, 'W' x 24 and 'W' x 14.\nExpanded again, it matches the original.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> ~12~WB~12~WBBB~24~WB~14~W\nExpanded: WWWWWWWWWWWWBWWWWWWWWWWWWBBBWWWWWWWWWWWWWWWWWWWWWWWWBWWWWWWWWWWWWWW\n25 characters -> 67.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 1\ntext> Room 101, 2nd floor\nCompressed: Room 101, 2nd floor\n19 characters -> 19 (100%).\nNo run is long enough to write as a count.\nExpanded again, it matches the original.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 1\ntext> a~b~c\nCompressed: a~~b~~c\n5 characters -> 7 (140%).\nNo run is long enough to write as a count.\nThat is longer than the text: each '~' in it is written as '~~'.\nExpanded again, it matches the original.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 1\ntext> 3333333 and ~~~~~ and ......\nCompressed: ~7~3 and ~5~~ and ~6~.\n28 characters -> 22 (79%).\nWritten as counts: '3' x 7, '~' x 5 and '.' x 6.\nExpanded again, it matches the original.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 3\n- A run of one character is written ~count~character when that is\n  shorter than writing it out: aaaaaa becomes ~6~a.\n- A ~ in the text is written ~~.\n- Everything else stays as it is. Digits need no escaping, because a\n  count only ever comes right after a ~: a3 stays a3, and 3333333\n  becomes ~7~3.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 2\ncompressed> x~3~3y\nExpanded: x333y\n6 characters -> 5.\n\n== Run-length compressor ==\n1) compress  2) expand  3) the format  4) quit\nchoice> 4\nBye.\n",
      "interpreter": "equal"
    }
  ],
  "builtOn": [
    {
      "slug": "run-length-encoder",
      "caseId": "036-run-length-encoder",
      "title": "Run-length encoder"
    },
    {
      "slug": "run-length-encoding",
      "caseId": "149-run-length-encoding",
      "title": "A round-trip property that found a broken format"
    },
    {
      "slug": "run-length-escaping",
      "caseId": "208-run-length-escaping",
      "title": "The fix that feels right and makes it worse"
    }
  ],
  "updated": "2026-10-05"
}
