<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P137-cohort-retention/ | updated: 2026-10-17 -->

# P137 Cohort retention

Users who signed up together leave as a product gets worse; the mean tolerance of those still here rises every period while the whole cohort's never moves, because the product chose who is left to be measured.

EML-P project `projects/cohort-retention` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/cohort-retention` runs it and `eml project verify projects/cohort-retention` replays every session under CPython (two hash seeds) and in the interpreter; the site build replays every session in the interpreter again and publishes a session only if its screen matches.

Built on verified corpus cases: survivorship-in-cohorts (https://efficientnewlanguage.org/cases/271-survivorship-in-cohorts/).

## Sessions

### bad-input - interpreter: byte-equal to the golden

Input:

```text
0
x
3

3
9
3
201
3
x
4

4
0
4
4
4
2

4
2
0
4
2
13
3
10
1
4
3
12
2
5
```

Screen:

```text
== Cohort retention ==
Users who signed up together, a product that gets a little worse every period, and averages over those who stayed.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 0
Pick a number from 1 to 5.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> x
Pick a number from 1 to 5.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 3
users (10 to 200)> 
Cancelled.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 3
users (10 to 200)> 9
Type a whole number from 10 to 200.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 3
users (10 to 200)> 201
Type a whole number from 10 to 200.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 3
users (10 to 200)> x
Type a whole number from 10 to 200.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 4
annoyance added each period (1 to 3)> 
Cancelled.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 4
annoyance added each period (1 to 3)> 0
Type a whole number from 1 to 3.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 4
annoyance added each period (1 to 3)> 4
Type a whole number from 1 to 3.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 4
annoyance added each period (1 to 3)> 2
periods (1 to 12)> 
Cancelled.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 4
annoyance added each period (1 to 3)> 2
periods (1 to 12)> 0
Type a whole number from 1 to 12.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 4
annoyance added each period (1 to 3)> 2
periods (1 to 12)> 13
Type a whole number from 1 to 12.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 3
users (10 to 200)> 10
10 users now.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 1
10 users signed up together; how many have each tolerance:
  1: 2  2: 0  3: 1  4: 1  5: 0  6: 2  7: 1  8: 1  9: 2  10: 0
Each period the product annoys a little more; a user whose tolerance it passes leaves.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 4
annoyance added each period (1 to 3)> 3
periods (1 to 12)> 12
The annoyance now rises by 3 a period, over 12 periods.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 2
  period  annoyance   here   tolerance, here   tolerance, all   headroom, here   leavers
       1          0     10               5.4              5.4              5.4         -
       2          3      8               6.5              5.4              3.5       1.0
       3          6      6               7.5              5.4              1.5       3.5
       4          9      2               9.0              5.4              0.0       6.8
       5         12      0                 -              5.4                -       9.0
       6         15      0                 -              5.4                -         -
       7         18      0                 -              5.4                -         -
       8         21      0                 -              5.4                -         -
       9         24      0                 -              5.4                -         -
      10         27      0                 -              5.4                -         -
      11         30      0                 -              5.4                -         -
      12         33      0                 -              5.4                -         -
The mean tolerance of those still here rose in 3 periods and fell in 0; over the whole cohort it stayed at 5.4, since nobody's tolerance changed.
Everyone who left had less tolerance than everyone who stayed: the product chose who is left to be measured.
Headroom, tolerance less annoyance, rose in 0 periods and fell in 3: the annoyance pulls it down, and the leavers going pulls it up.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 5
Bye.
```

### basic - interpreter: byte-equal to the golden

Input:

```text
1
2
4
2
6
2
5
```

Screen:

```text
== Cohort retention ==
Users who signed up together, a product that gets a little worse every period, and averages over those who stayed.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 1
40 users signed up together; how many have each tolerance:
  1: 3  2: 3  3: 3  4: 4  5: 6  6: 6  7: 5  8: 6  9: 2  10: 2
Each period the product annoys a little more; a user whose tolerance it passes leaves.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 2
  period  annoyance   here   tolerance, here   tolerance, all   headroom, here   leavers
       1          0     40               5.5              5.5              5.5         -
       2          1     40               5.5              5.5              4.5         -
       3          2     37               5.9              5.5              3.9       1.0
       4          3     34               6.2              5.5              3.2       2.0
       5          4     31               6.5              5.5              2.5       3.0
       6          5     27               6.9              5.5              1.9       4.0
       7          6     21               7.5              5.5              1.5       5.0
       8          7     15               8.1              5.5              1.1       6.0
       9          8     10               8.6              5.5              0.6       7.0
      10          9      4               9.5              5.5              0.5       8.0
The mean tolerance of those still here rose in 8 periods and fell in 0; over the whole cohort it stayed at 5.5, since nobody's tolerance changed.
Everyone who left had less tolerance than everyone who stayed: the product chose who is left to be measured.
Headroom, tolerance less annoyance, rose in 0 periods and fell in 9: the annoyance pulls it down, and the leavers going pulls it up.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 4
annoyance added each period (1 to 3)> 2
periods (1 to 12)> 6
The annoyance now rises by 2 a period, over 6 periods.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 2
  period  annoyance   here   tolerance, here   tolerance, all   headroom, here   leavers
       1          0     40               5.5              5.5              5.5         -
       2          2     37               5.9              5.5              3.9       1.0
       3          4     31               6.5              5.5              2.5       2.5
       4          6     21               7.5              5.5              1.5       4.6
       5          8     10               8.6              5.5              0.6       6.5
       6         10      2              10.0              5.5              0.0       8.3
The mean tolerance of those still here rose in 5 periods and fell in 0; over the whole cohort it stayed at 5.5, since nobody's tolerance changed.
Everyone who left had less tolerance than everyone who stayed: the product chose who is left to be measured.
Headroom, tolerance less annoyance, rose in 0 periods and fell in 5: the annoyance pulls it down, and the leavers going pulls it up.

1) the cohort  2) period by period  3) users  4) annoyance  5) quit
choice> 5
Bye.
```

## Modules

### main.eml

```eml
# P137 cohort retention: the cohort, period by period with means over
# those still here and over everyone, and the settings.
import cohort

def plain(s):
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def lpad(s, n):
    s => out
    while len(out) < n:
        " " + out => out
    return out

def t_text(t):
    if t < 0:
        return "-"
    return str(int(t / 10)) + "." + str(t % 10)

def show(users):
    (str(len(users)) + " users signed up together; how many have each tolerance:") ^0
    "" => line
    for v in [1:10]:
        0 => c
        for t in users:
            if t == v:
                c + 1 => c
        if line != "":
            line + "  " => line
        line + str(v) + ": " + str(c) => line
    ("  " + line) ^0
    "Each period the product annoys a little more; a user whose tolerance it passes leaves." ^0

def table(users, step, count):
    cohort.periods(users, step, count) => rows
    "  period  annoyance   here   tolerance, here   tolerance, all   headroom, here   leavers" ^0
    for p in [0:len(rows) - 1]:
        rows[p] => r
        ("  " + lpad(str(p + 1), 6) + lpad(str(r[0]), 11) + lpad(str(r[1]), 7) + lpad(t_text(r[2]), 18) + lpad(t_text(r[3]), 17) + lpad(t_text(r[4]), 17) + lpad(t_text(r[5]), 10)) ^0
    0 => rose
    0 => fell
    0 => hup
    0 => hdown
    for p in [1:len(rows) - 1]:
        if rows[p][1] > 0 and rows[p - 1][1] > 0:
            if rows[p][2] > rows[p - 1][2]:
                rose + 1 => rose
            if rows[p][2] < rows[p - 1][2]:
                fell + 1 => fell
            if rows[p][4] > rows[p - 1][4]:
                hup + 1 => hup
            if rows[p][4] < rows[p - 1][4]:
                hdown + 1 => hdown
    ("The mean tolerance of those still here rose in " + str(rose) + " periods and fell in " + str(fell) + "; over the whole cohort it stayed at " + t_text(rows[0][3]) + ", since nobody's tolerance changed.") ^0
    "Everyone who left had less tolerance than everyone who stayed: the product chose who is left to be measured." ^0
    ("Headroom, tolerance less annoyance, rose in " + str(hup) + " periods and fell in " + str(hdown) + ": the annoyance pulls it down, and the leavers going pulls it up.") ^0

def ask_int(prompt, low, high):
    plain(input(prompt)) => t
    if t == "":
        "Cancelled." ^0
        return 0 - 1
    if len(t) > 3:
        ("Type a whole number from " + str(low) + " to " + str(high) + ".") ^0
        return 0 - 1
    for c in t:
        if c < "0" or c > "9":
            ("Type a whole number from " + str(low) + " to " + str(high) + ".") ^0
            return 0 - 1
    if int(t) < low or int(t) > high:
        ("Type a whole number from " + str(low) + " to " + str(high) + ".") ^0
        return 0 - 1
    return int(t)

40 => n
1 => step
10 => count
cohort.cohort(n) => users

"== Cohort retention ==" ^0
"Users who signed up together, a product that gets a little worse every period, and averages over those who stayed." ^0
True => running
while running:
    "" ^0
    "1) the cohort  2) period by period  3) users  4) annoyance  5) quit" ^0
    plain(input("choice> ")) => choice
    if choice == "1":
        show(users)
    elif choice == "2":
        table(users, step, count)
    elif choice == "3":
        ask_int("users (10 to 200)> ", 10, 200) => x
        if x > 0:
            x => n
            cohort.cohort(n) => users
            (str(n) + " users now.") ^0
    elif choice == "4":
        ask_int("annoyance added each period (1 to 3)> ", 1, 3) => a
        if a > 0:
            ask_int("periods (1 to 12)> ", 1, 12) => b
            if b > 0:
                a => step
                b => count
                ("The annoyance now rises by " + str(step) + " a period, over " + str(count) + " periods.") ^0
    elif choice == "5":
        False => running
    else:
        "Pick a number from 1 to 5." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import cohort

def plain(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def lpad(s, n):
    out = s
    while len(out) < n:
        out = " " + out
    return out

def t_text(t):
    if t < 0:
        return "-"
    return str(int(t / 10)) + "." + str(t % 10)

def show(users):
    print(str(len(users)) + " users signed up together; how many have each tolerance:")
    line = ""
    for v in range(1, 11):
        c = 0
        for t in users:
            if t == v:
                c = c + 1
        if line != "":
            line = line + "  "
        line = line + str(v) + ": " + str(c)
    print("  " + line)
    print("Each period the product annoys a little more; a user whose tolerance it passes leaves.")

def table(users, step, count):
    rows = cohort.periods(users, step, count)
    print("  period  annoyance   here   tolerance, here   tolerance, all   headroom, here   leavers")
    for p in range(0, len(rows)):
        r = rows[p]
        print("  " + lpad(str(p + 1), 6) + lpad(str(r[0]), 11) + lpad(str(r[1]), 7) + lpad(t_text(r[2]), 18) + lpad(t_text(r[3]), 17) + lpad(t_text(r[4]), 17) + lpad(t_text(r[5]), 10))
    rose = 0
    fell = 0
    hup = 0
    hdown = 0
    for p in range(1, len(rows)):
        if rows[p][1] > 0 and rows[p - 1][1] > 0:
            if rows[p][2] > rows[p - 1][2]:
                rose = rose + 1
            if rows[p][2] < rows[p - 1][2]:
                fell = fell + 1
            if rows[p][4] > rows[p - 1][4]:
                hup = hup + 1
            if rows[p][4] < rows[p - 1][4]:
                hdown = hdown + 1
    print("The mean tolerance of those still here rose in " + str(rose) + " periods and fell in " + str(fell) + "; over the whole cohort it stayed at " + t_text(rows[0][3]) + ", since nobody's tolerance changed.")
    print("Everyone who left had less tolerance than everyone who stayed: the product chose who is left to be measured.")
    print("Headroom, tolerance less annoyance, rose in " + str(hup) + " periods and fell in " + str(hdown) + ": the annoyance pulls it down, and the leavers going pulls it up.")

def ask_int(prompt, low, high):
    t = plain(input(prompt))
    if t == "":
        print("Cancelled.")
        return 0 - 1
    if len(t) > 3:
        print("Type a whole number from " + str(low) + " to " + str(high) + ".")
        return 0 - 1
    for c in t:
        if c < "0" or c > "9":
            print("Type a whole number from " + str(low) + " to " + str(high) + ".")
            return 0 - 1
    if int(t) < low or int(t) > high:
        print("Type a whole number from " + str(low) + " to " + str(high) + ".")
        return 0 - 1
    return int(t)

n = 40
step = 1
count = 10
users = cohort.cohort(n)
print("== Cohort retention ==")
print("Users who signed up together, a product that gets a little worse every period, and averages over those who stayed.")
running = True
while running:
    print("")
    print("1) the cohort  2) period by period  3) users  4) annoyance  5) quit")
    choice = plain(input("choice> "))
    if choice == "1":
        show(users)
    elif choice == "2":
        table(users, step, count)
    elif choice == "3":
        x = ask_int("users (10 to 200)> ", 10, 200)
        if x > 0:
            n = x
            users = cohort.cohort(n)
            print(str(n) + " users now.")
    elif choice == "4":
        a = ask_int("annoyance added each period (1 to 3)> ", 1, 3)
        if a > 0:
            b = ask_int("periods (1 to 12)> ", 1, 12)
            if b > 0:
                step = a
                count = b
                print("The annoyance now rises by " + str(step) + " a period, over " + str(count) + " periods.")
    elif choice == "5":
        running = False
    else:
        print("Pick a number from 1 to 5.")
print("Bye.")
```

### cohort.eml

```eml
# P137 cohort retention - users who signed up together, each with a
# tolerance from 1 to 10, and a product that annoys them a little more every
# period. A user leaves once the annoyance passes their tolerance. As in the
# corpus case survivorship-in-cohorts, a number worked out over the users
# who are still here is worked out over a sample the product picked: the
# mean tolerance of the survivors rises while the product gets worse, and
# over the whole cohort it does not move.

def mix(i, salt):
    i * 7919 + salt * 104729 + 12345 => x
    for k in [1:3]:
        (x * x + 7) % 1000000007 => x
    return x % 1000000

def cohort(n):
    # tolerances from 1 to 10
    [] => out
    for i in [1:n]:
        out + [1 + mix(i, 1) % 10] => out
    return out

def tenths(p, q):
    # p / q in tenths, rounded half up, for q > 0
    int((20 * p + q) / (2 * q)) => t
    while t * 2 * q > 20 * p + q:
        t - 1 => t
    while (t + 1) * 2 * q <= 20 * p + q:
        t + 1 => t
    return t

def periods(users, step, count):
    # per period [annoyance, still here, mean tolerance of those here (in
    # tenths, -1 if nobody), mean of the whole cohort, mean headroom of
    # those here (tolerance - annoyance, tenths), leavers' mean tolerance
    # (tenths, -1 if none left this period)]
    [] => out
    0 => s_all
    for t in users:
        s_all + t => s_all
    [] => here
    for t in users:
        here + [t] => here
    for p in [0:count - 1]:
        step * p => annoy
        [] => stay
        [] => gone
        for t in here:
            if t >= annoy:
                stay + [t] => stay
            else:
                gone + [t] => gone
        stay => here
        0 => s
        for t in here:
            s + t => s
        0 - 1 => mean
        0 - 1 => head
        if len(here) > 0:
            tenths(s, len(here)) => mean
            tenths(s - annoy * len(here), len(here)) => head
        0 - 1 => left
        if len(gone) > 0:
            0 => g
            for t in gone:
                g + t => g
            tenths(g, len(gone)) => left
        out + [[annoy, len(here), mean, tenths(s_all, len(users)), head, left]] => out
    return out
```

Python projection of cohort.eml:

```python
def mix(i, salt):
    x = i * 7919 + salt * 104729 + 12345
    for k in range(1, 4):
        x = (x * x + 7) % 1000000007
    return x % 1000000

def cohort(n):
    out = []
    for i in range(1, n+1):
        out = out + [1 + mix(i, 1) % 10]
    return out

def tenths(p, q):
    t = int((20 * p + q) / (2 * q))
    while t * 2 * q > 20 * p + q:
        t = t - 1
    while (t + 1) * 2 * q <= 20 * p + q:
        t = t + 1
    return t

def periods(users, step, count):
    out = []
    s_all = 0
    for t in users:
        s_all = s_all + t
    here = []
    for t in users:
        here = here + [t]
    for p in range(0, count):
        annoy = step * p
        stay = []
        gone = []
        for t in here:
            if t >= annoy:
                stay = stay + [t]
            else:
                gone = gone + [t]
        here = stay
        s = 0
        for t in here:
            s = s + t
        mean = 0 - 1
        head = 0 - 1
        if len(here) > 0:
            mean = tenths(s, len(here))
            head = tenths(s - annoy * len(here), len(here))
        left = 0 - 1
        if len(gone) > 0:
            g = 0
            for t in gone:
                g = g + t
            left = tenths(g, len(gone))
        out = out + [[annoy, len(here), mean, tenths(s_all, len(users)), head, left]]
    return out
```

## README

# P137 - Cohort retention

Users who signed up together, each with a tolerance from 1 to 10, and a
product that annoys them a little more every period. A user leaves once
the annoyance passes their tolerance.

- `main.eml` - the menu, the cohort, period by period, and the settings
- `cohort.eml` - the users, the means rounded to a tenth, and the periods

How each part works:

- Each period the annoyance rises by a set step. Those whose tolerance it
  has passed leave; the rest stay. Means are worked out in whole numbers
  and rounded half up to a tenth.
- As in the corpus case `survivorship-in-cohorts`, a mean over the users
  still here is a mean over a sample the product picked. With 40 users and
  a step of 1, the mean tolerance of those still here rises from 5.5 to
  9.5 while the product gets worse every period, and over the whole
  cohort it stays at 5.5: nobody's tolerance changed.
- It never falls, at any setting: every user who leaves has less
  tolerance than every user who stays.
- Headroom, tolerance less annoyance, has two pulls on it: the annoyance
  pulls it down, the leavers going pulls it up. Here it falls every
  period; the program counts the periods each way rather than assuming.

What is checked: menu choices 1 to 5; 10 to 200 users; an annoyance step
from 1 to 3 and 1 to 12 periods. An empty answer cancels.

Sessions: `sessions/basic.in` gives:
- the cohort, and 10 periods with a step of 1;
- a step of 2 over 6 periods.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- users: a cancel, 9, 201 and x; steps: a cancel, 0 and 4, then 2 with a
  cancel, 0 and 13 periods;
- 10 users with a step of 3 over 12 periods, everyone gone by period 5.

Built on the verified corpus case `survivorship-in-cohorts` (a statistic
over the users still here rises as the product gets worse).
