<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P112-survey-analyzer/ | updated: 2026-10-16 -->

# P112 Survey analyzer

A small survey: each question's exact mean, median and spread; the Net Promoter Score and the scores that would move it; the top respondents under strict, dense and exclude policies for ties; and a split by satisfaction.

EML-P project `projects/survey-analyzer` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/survey-analyzer` runs it and `eml project verify projects/survey-analyzer` 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: survey-score-analyzer (https://efficientnewlanguage.org/cases/044-survey-score-analyzer/), top-n-with-ties (https://efficientnewlanguage.org/cases/273-top-n-with-ties/).

## Sessions

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

Input:

```text
0
x
4

4
0
4
13
4
x
6

6
zed 8 4 4
6
ana 8 4 4 4
6
Zed 8 4 4 4
6
zedzedz 8 4 4 4
6
zed 11 4 4 4
6
zed 8 6 4 4
6
zed x 4 4 4
7
```

Screen:

```text
== Survey analyzer ==
A survey: how likely each person is to recommend us (0 to 10), and three questions on a scale of 1 to 5.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 0
Pick a number from 1 to 7.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> x
Pick a number from 1 to 7.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 4
how many at the top (1 to 12)> 
Cancelled.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 4
how many at the top (1 to 12)> 0
Type a whole number from 1 to 12.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 4
how many at the top (1 to 12)> 13
Type a whole number from 1 to 12.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 4
how many at the top (1 to 12)> x
Type a whole number from 1 to 12.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 6
A response as name, recommend (0 to 10) and q1 to q3 (1 to 5), such as zed 8 4 4 3.
response> 
Cancelled.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 6
A response as name, recommend (0 to 10) and q1 to q3 (1 to 5), such as zed 8 4 4 3.
response> zed 8 4 4
Type a name and four numbers.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 6
A response as name, recommend (0 to 10) and q1 to q3 (1 to 5), such as zed 8 4 4 3.
response> ana 8 4 4 4
ana has answered already.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 6
A response as name, recommend (0 to 10) and q1 to q3 (1 to 5), such as zed 8 4 4 3.
response> Zed 8 4 4 4
A name is lower-case letters.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 6
A response as name, recommend (0 to 10) and q1 to q3 (1 to 5), such as zed 8 4 4 3.
response> zedzedz 8 4 4 4
Keep a name to 6 letters.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 6
A response as name, recommend (0 to 10) and q1 to q3 (1 to 5), such as zed 8 4 4 3.
response> zed 11 4 4 4
The recommend score is a whole number from 0 to 10.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 6
A response as name, recommend (0 to 10) and q1 to q3 (1 to 5), such as zed 8 4 4 3.
response> zed 8 6 4 4
q1 to q3 are whole numbers from 1 to 5.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 6
A response as name, recommend (0 to 10) and q1 to q3 (1 to 5), such as zed 8 4 4 3.
response> zed x 4 4 4
The recommend score is a whole number from 0 to 10.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 7
Bye.
```

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

Input:

```text
1
2
3
4
3
4
4
4
2
5
6
zed 6 5 5 5
3
4
4
7
```

Screen:

```text
== Survey analyzer ==
A survey: how likely each person is to recommend us (0 to 10), and three questions on a scale of 1 to 5.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 1
  name   recommend  group      q1 q2 q3  total
  ana           10  promoter   5  4  5     14
  bo             9  promoter   4  4  4     12
  cy             8  passive    4  3  4     11
  dee            7  passive    3  4  3     10
  eli            6  detractor  3  3  2      8
  fay            9  promoter   5  5  4     14
  gus            3  detractor  2  1  2      5
  hal           10  promoter   5  5  5     15
  ivy            8  passive    4  4  3     11
  jo             5  detractor  2  3  3      8
  kai            9  promoter   4  4  4     12
  lu             7  passive    4  3  4     11
12 responses.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 2
  recommend mean 7.58 (91/12), median 8   3:1  5:1  6:1  7:2  8:2  9:3  10:2
  q1        mean 3.75 (15/4), median 4   2:2  3:2  4:5  5:3
            answered 4 or 5: 8 of 12, 66.67%
  q2        mean 3.58 (43/12), median 4   1:1  3:4  4:5  5:2
            answered 4 or 5: 7 of 12, 58.33%
  q3        mean 3.58 (43/12), median 4   2:2  3:3  4:5  5:2
            answered 4 or 5: 7 of 12, 58.33%

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 3
Promoters (9-10): 5, passives (7-8): 4, detractors (0-6): 3, of 12.
Net Promoter Score: 16.67 (50/3), on a scale from -100 to +100.
One point more would change the score only for cy 8 to 9, eli 6 to 7, ivy 8 to 9: a recommend score counts only when it crosses 6 to 7 or 8 to 9.
7 others would raise the mean recommend score and leave the Net Promoter Score as it is.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 4
how many at the top (1 to 12)> 3
Top 3 by total of q1 to q3:
  strict  3: hal 15, ana 14, fay 14
  dense   3: hal 15, ana 14, fay 14
  exclude 3: hal 15, ana 14, fay 14
Strict gives exactly the number asked for, but when the cut falls inside a tie it keeps whoever came first. Dense keeps the whole tie, exclude drops it.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 4
how many at the top (1 to 12)> 4
Top 4 by total of q1 to q3:
  strict  4: hal 15, ana 14, fay 14, bo 12
          with the responses in the reverse order: hal 15, fay 14, ana 14, kai 12
  dense   5: hal 15, ana 14, fay 14, bo 12, kai 12
  exclude 3: hal 15, ana 14, fay 14
Strict gives exactly the number asked for, but when the cut falls inside a tie it keeps whoever came first. Dense keeps the whole tie, exclude drops it.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 4
how many at the top (1 to 12)> 2
Top 2 by total of q1 to q3:
  strict  2: hal 15, ana 14
          with the responses in the reverse order: hal 15, fay 14
  dense   3: hal 15, ana 14, fay 14
  exclude 1: hal 15
Strict gives exactly the number asked for, but when the cut falls inside a tie it keeps whoever came first. Dense keeps the whole tie, exclude drops it.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 5
  recommend 7 or more  9, mean total of q1 to q3 12.22
  recommend 6 or less  3, mean total of q1 to q3 7.00

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 6
A response as name, recommend (0 to 10) and q1 to q3 (1 to 5), such as zed 8 4 4 3.
response> zed 6 5 5 5
zed added.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 3
Promoters (9-10): 5, passives (7-8): 4, detractors (0-6): 4, of 13.
Net Promoter Score: 7.69 (100/13), on a scale from -100 to +100.
One point more would change the score only for cy 8 to 9, eli 6 to 7, ivy 8 to 9, zed 6 to 7: a recommend score counts only when it crosses 6 to 7 or 8 to 9.
7 others would raise the mean recommend score and leave the Net Promoter Score as it is.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 4
how many at the top (1 to 13)> 4
Top 4 by total of q1 to q3:
  strict  4: hal 15, zed 15, ana 14, fay 14
  dense   4: hal 15, zed 15, ana 14, fay 14
  exclude 4: hal 15, zed 15, ana 14, fay 14
Strict gives exactly the number asked for, but when the cut falls inside a tie it keeps whoever came first. Dense keeps the whole tie, exclude drops it.

1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit
choice> 7
Bye.
```

## Modules

### main.eml

```eml
# P112 survey analyzer: responses, each question's average and spread,
# the Net Promoter Score and what moves it, and the top respondents when
# scores tie.
import survey

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 number(s, most):
    if len(s) == 0 or len(s) > most:
        return -1
    0 => n
    for c in s:
        if not (c >= "0" and c <= "9"):
            return -1
        n * 10 + int(c) => n
    return n

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

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

def names(rows):
    "" => out
    for r in rows:
        if out != "":
            out + ", " => out
        out + r[0] + " " + str(survey.total(r)) => out
    if out == "":
        return "(nobody)"
    return out

def show_rows(rows):
    "  name   recommend  group      q1 q2 q3  total" ^0
    for r in rows:
        ("  " + pad(r[0], 7) + lpad(str(r[1]), 9) + "  " + pad(survey.group(r[1]), 10) + lpad(str(r[2]), 2) + lpad(str(r[3]), 3) + lpad(str(r[4]), 3) + lpad(str(survey.total(r)), 7)) ^0
    (str(len(rows)) + " responses.") ^0

def questions(rows):
    ["recommend", "q1", "q2", "q3"] => labels
    for k in [1:4]:
        survey.column(rows, k) => xs
        0 => s
        for x in xs:
            s + x => s
        survey.median_twice(xs) => m2
        str(int(m2 / 2)) => med
        if m2 % 2 == 1:
            str(int((m2 - 1) / 2)) + ".5" => med
        0 => low
        10 => high
        if k > 1:
            1 => low
            5 => high
        "" => dist
        for v in [low:high]:
            0 => c
            for x in xs:
                if x == v:
                    c + 1 => c
            if c > 0:
                if dist != "":
                    dist + "  " => dist
                dist + str(v) + ":" + str(c) => dist
        ("  " + pad(labels[k - 1], 10) + "mean " + survey.two_places(s, len(xs)) + " (" + survey.fraction_text(s, len(xs)) + "), median " + med + "   " + dist) ^0
        if k > 1:
            0 => box
            for x in xs:
                if x >= 4:
                    box + 1 => box
            ("  " + pad("", 10) + "answered 4 or 5: " + str(box) + " of " + str(len(xs)) + ", " + survey.two_places(box * 100, len(xs)) + "%") ^0

def show_nps(rows):
    survey.nps(rows) => g
    len(rows) => n
    ("Promoters (9-10): " + str(g[0]) + ", passives (7-8): " + str(g[1]) + ", detractors (0-6): " + str(g[2]) + ", of " + str(n) + ".") ^0
    ("Net Promoter Score: " + survey.two_places((g[0] - g[2]) * 100, n) + " (" + survey.fraction_text((g[0] - g[2]) * 100, n) + "), on a scale from -100 to +100.") ^0
    [] => moves
    [] => still
    for r in rows:
        if r[1] == 6 or r[1] == 8:
            moves + [r[0] + " " + str(r[1]) + " to " + str(r[1] + 1)] => moves
        elif r[1] < 10:
            still + [r[0]] => still
    "" => a
    for x in moves:
        if a != "":
            a + ", " => a
        a + x => a
    if a == "":
        "nobody" => a
    ("One point more would change the score only for " + a + ": a recommend score counts only when it crosses 6 to 7 or 8 to 9.") ^0
    (str(len(still)) + " others would raise the mean recommend score and leave the Net Promoter Score as it is.") ^0

def show_top(rows):
    plain(input("how many at the top (1 to " + str(len(rows)) + ")> ")) => t
    if t == "":
        "Cancelled." ^0
        return
    number(t, 2) => n
    if n < 1 or n > len(rows):
        ("Type a whole number from 1 to " + str(len(rows)) + ".") ^0
        return
    ("Top " + str(n) + " by total of q1 to q3:") ^0
    for p in ["strict", "dense", "exclude"]:
        survey.top(rows, n, p) => a
        survey.top(survey.reversed_rows(rows), n, p) => b
        ("  " + pad(p, 8) + str(len(a)) + ": " + names(a)) ^0
        True => same
        if len(a) != len(b):
            False => same
        else:
            for x in a:
                False => found
                for y in b:
                    if y[0] == x[0]:
                        True => found
                if not found:
                    False => same
        if not same:
            ("  " + pad("", 8) + "with the responses in the reverse order: " + names(b)) ^0
    "Strict gives exactly the number asked for, but when the cut falls inside a tie it keeps whoever came first. Dense keeps the whole tie, exclude drops it." ^0

def split(rows):
    [] => happy
    [] => unhappy
    for r in rows:
        if r[1] >= 7:
            happy + [r] => happy
        else:
            unhappy + [r] => unhappy
    for part in [["recommend 7 or more", happy], ["recommend 6 or less", unhappy]]:
        part[1] => g
        if len(g) == 0:
            ("  " + pad(part[0], 21) + "nobody") ^0
        else:
            0 => s
            for r in g:
                s + survey.total(r) => s
            ("  " + pad(part[0], 21) + str(len(g)) + ", mean total of q1 to q3 " + survey.two_places(s, len(g))) ^0

def add(rows):
    "A response as name, recommend (0 to 10) and q1 to q3 (1 to 5), such as zed 8 4 4 3." ^0
    plain(input("response> ")) => t
    if t == "":
        "Cancelled." ^0
        return rows
    [] => w
    "" => cur
    for c in t + " ":
        if c == " ":
            if cur != "":
                w + [cur] => w
            "" => cur
        else:
            cur + c => cur
    if len(w) != 5:
        "Type a name and four numbers." ^0
        return rows
    for r in rows:
        if r[0] == w[0]:
            (w[0] + " has answered already.") ^0
            return rows
    if len(w[0]) > 6:
        "Keep a name to 6 letters." ^0
        return rows
    for c in w[0]:
        if not (c >= "a" and c <= "z"):
            "A name is lower-case letters." ^0
            return rows
    number(w[1], 2) => rec
    if rec < 0 or rec > 10:
        "The recommend score is a whole number from 0 to 10." ^0
        return rows
    [] => q
    for k in [2:4]:
        number(w[k], 1) => v
        if v < 1 or v > 5:
            "q1 to q3 are whole numbers from 1 to 5." ^0
            return rows
        q + [v] => q
    if len(rows) >= 30:
        "The survey holds 30 responses." ^0
        return rows
    (w[0] + " added.") ^0
    return rows + [[w[0], rec, q[0], q[1], q[2]]]

[["ana", 10, 5, 4, 5], ["bo", 9, 4, 4, 4], ["cy", 8, 4, 3, 4], ["dee", 7, 3, 4, 3], ["eli", 6, 3, 3, 2], ["fay", 9, 5, 5, 4], ["gus", 3, 2, 1, 2], ["hal", 10, 5, 5, 5], ["ivy", 8, 4, 4, 3], ["jo", 5, 2, 3, 3], ["kai", 9, 4, 4, 4], ["lu", 7, 4, 3, 4]] => rows

"== Survey analyzer ==" ^0
"A survey: how likely each person is to recommend us (0 to 10), and three questions on a scale of 1 to 5." ^0
True => running
while running:
    "" ^0
    "1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit" ^0
    plain(input("choice> ")) => choice
    if choice == "1":
        show_rows(rows)
    elif choice == "2":
        questions(rows)
    elif choice == "3":
        show_nps(rows)
    elif choice == "4":
        show_top(rows)
    elif choice == "5":
        split(rows)
    elif choice == "6":
        add(rows) => rows
    elif choice == "7":
        False => running
    else:
        "Pick a number from 1 to 7." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import survey

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 number(s, most):
    if len(s) == 0 or len(s) > most:
        return -1
    n = 0
    for c in s:
        if not (c >= "0" and c <= "9"):
            return -1
        n = n * 10 + int(c)
    return n

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

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

def names(rows):
    out = ""
    for r in rows:
        if out != "":
            out = out + ", "
        out = out + r[0] + " " + str(survey.total(r))
    if out == "":
        return "(nobody)"
    return out

def show_rows(rows):
    print("  name   recommend  group      q1 q2 q3  total")
    for r in rows:
        print("  " + pad(r[0], 7) + lpad(str(r[1]), 9) + "  " + pad(survey.group(r[1]), 10) + lpad(str(r[2]), 2) + lpad(str(r[3]), 3) + lpad(str(r[4]), 3) + lpad(str(survey.total(r)), 7))
    print(str(len(rows)) + " responses.")

def questions(rows):
    labels = ["recommend", "q1", "q2", "q3"]
    for k in range(1, 5):
        xs = survey.column(rows, k)
        s = 0
        for x in xs:
            s = s + x
        m2 = survey.median_twice(xs)
        med = str(int(m2 / 2))
        if m2 % 2 == 1:
            med = str(int((m2 - 1) / 2)) + ".5"
        low = 0
        high = 10
        if k > 1:
            low = 1
            high = 5
        dist = ""
        for v in range(low, high+1):
            c = 0
            for x in xs:
                if x == v:
                    c = c + 1
            if c > 0:
                if dist != "":
                    dist = dist + "  "
                dist = dist + str(v) + ":" + str(c)
        print("  " + pad(labels[k - 1], 10) + "mean " + survey.two_places(s, len(xs)) + " (" + survey.fraction_text(s, len(xs)) + "), median " + med + "   " + dist)
        if k > 1:
            box = 0
            for x in xs:
                if x >= 4:
                    box = box + 1
            print("  " + pad("", 10) + "answered 4 or 5: " + str(box) + " of " + str(len(xs)) + ", " + survey.two_places(box * 100, len(xs)) + "%")

def show_nps(rows):
    g = survey.nps(rows)
    n = len(rows)
    print("Promoters (9-10): " + str(g[0]) + ", passives (7-8): " + str(g[1]) + ", detractors (0-6): " + str(g[2]) + ", of " + str(n) + ".")
    print("Net Promoter Score: " + survey.two_places((g[0] - g[2]) * 100, n) + " (" + survey.fraction_text((g[0] - g[2]) * 100, n) + "), on a scale from -100 to +100.")
    moves = []
    still = []
    for r in rows:
        if r[1] == 6 or r[1] == 8:
            moves = moves + [r[0] + " " + str(r[1]) + " to " + str(r[1] + 1)]
        elif r[1] < 10:
            still = still + [r[0]]
    a = ""
    for x in moves:
        if a != "":
            a = a + ", "
        a = a + x
    if a == "":
        a = "nobody"
    print("One point more would change the score only for " + a + ": a recommend score counts only when it crosses 6 to 7 or 8 to 9.")
    print(str(len(still)) + " others would raise the mean recommend score and leave the Net Promoter Score as it is.")

def show_top(rows):
    t = plain(input("how many at the top (1 to " + str(len(rows)) + ")> "))
    if t == "":
        print("Cancelled.")
        return
    n = number(t, 2)
    if n < 1 or n > len(rows):
        print("Type a whole number from 1 to " + str(len(rows)) + ".")
        return
    print("Top " + str(n) + " by total of q1 to q3:")
    for p in ["strict", "dense", "exclude"]:
        a = survey.top(rows, n, p)
        b = survey.top(survey.reversed_rows(rows), n, p)
        print("  " + pad(p, 8) + str(len(a)) + ": " + names(a))
        same = True
        if len(a) != len(b):
            same = False
        else:
            for x in a:
                found = False
                for y in b:
                    if y[0] == x[0]:
                        found = True
                if not found:
                    same = False
        if not same:
            print("  " + pad("", 8) + "with the responses in the reverse order: " + names(b))
    print("Strict gives exactly the number asked for, but when the cut falls inside a tie it keeps whoever came first. Dense keeps the whole tie, exclude drops it.")

def split(rows):
    happy = []
    unhappy = []
    for r in rows:
        if r[1] >= 7:
            happy = happy + [r]
        else:
            unhappy = unhappy + [r]
    for part in [["recommend 7 or more", happy], ["recommend 6 or less", unhappy]]:
        g = part[1]
        if len(g) == 0:
            print("  " + pad(part[0], 21) + "nobody")
        else:
            s = 0
            for r in g:
                s = s + survey.total(r)
            print("  " + pad(part[0], 21) + str(len(g)) + ", mean total of q1 to q3 " + survey.two_places(s, len(g)))

def add(rows):
    print("A response as name, recommend (0 to 10) and q1 to q3 (1 to 5), such as zed 8 4 4 3.")
    t = plain(input("response> "))
    if t == "":
        print("Cancelled.")
        return rows
    w = []
    cur = ""
    for c in t + " ":
        if c == " ":
            if cur != "":
                w = w + [cur]
            cur = ""
        else:
            cur = cur + c
    if len(w) != 5:
        print("Type a name and four numbers.")
        return rows
    for r in rows:
        if r[0] == w[0]:
            print(w[0] + " has answered already.")
            return rows
    if len(w[0]) > 6:
        print("Keep a name to 6 letters.")
        return rows
    for c in w[0]:
        if not (c >= "a" and c <= "z"):
            print("A name is lower-case letters.")
            return rows
    rec = number(w[1], 2)
    if rec < 0 or rec > 10:
        print("The recommend score is a whole number from 0 to 10.")
        return rows
    q = []
    for k in range(2, 5):
        v = number(w[k], 1)
        if v < 1 or v > 5:
            print("q1 to q3 are whole numbers from 1 to 5.")
            return rows
        q = q + [v]
    if len(rows) >= 30:
        print("The survey holds 30 responses.")
        return rows
    print(w[0] + " added.")
    return rows + [[w[0], rec, q[0], q[1], q[2]]]

rows = [["ana", 10, 5, 4, 5], ["bo", 9, 4, 4, 4], ["cy", 8, 4, 3, 4], ["dee", 7, 3, 4, 3], ["eli", 6, 3, 3, 2], ["fay", 9, 5, 5, 4], ["gus", 3, 2, 1, 2], ["hal", 10, 5, 5, 5], ["ivy", 8, 4, 4, 3], ["jo", 5, 2, 3, 3], ["kai", 9, 4, 4, 4], ["lu", 7, 4, 3, 4]]
print("== Survey analyzer ==")
print("A survey: how likely each person is to recommend us (0 to 10), and three questions on a scale of 1 to 5.")
running = True
while running:
    print("")
    print("1) responses  2) questions  3) Net Promoter Score  4) top respondents  5) satisfied or not  6) add a response  7) quit")
    choice = plain(input("choice> "))
    if choice == "1":
        show_rows(rows)
    elif choice == "2":
        questions(rows)
    elif choice == "3":
        show_nps(rows)
    elif choice == "4":
        show_top(rows)
    elif choice == "5":
        split(rows)
    elif choice == "6":
        rows = add(rows)
    elif choice == "7":
        running = False
    else:
        print("Pick a number from 1 to 7.")
print("Bye.")
```

### survey.eml

```eml
# P112 survey analyzer - each response is [name, recommend 0-10, q1, q2,
# q3] with q1-q3 on a 1-5 scale. Averages are exact fractions; the Net
# Promoter Score is the share of promoters (9-10) minus the share of
# detractors (0-6). As in the corpus case survey-score-analyzer,
# respondents split at a threshold; as in the corpus case top-n-with-ties,
# "the top N" on scores with ties depends on a policy - strict, dense or
# exclude - and strict's answer depends on the order of the input.

def gcd(a, b):
    while b != 0:
        a % b => r
        b => a
        r => b
    return a

def fraction_text(num, den):
    # "49/12", "4" or "-1/3"
    "" => sign
    num => a
    if num < 0:
        "-" => sign
        0 - num => a
    gcd(a, den) => g
    if g == 0:
        1 => g
    int(a / g) => a
    int(den / g) => d
    if d == 1:
        return sign + str(a)
    return sign + str(a) + "/" + str(d)

def two_places(num, den):
    # num / den to two decimals, halves away from zero
    "" => sign
    num => a
    if num < 0:
        "-" => sign
        0 - num => a
    a * 100 => x
    x % den => r
    int((x - r) / den) => q
    if 2 * r >= den:
        q + 1 => q
    q % 100 => c
    int((q - c) / 100) => w
    if c < 10:
        return sign + str(w) + ".0" + str(c)
    return sign + str(w) + "." + str(c)

def column(rows, k):
    [] => out
    for r in rows:
        out + [r[k]] => out
    return out

def sorted_ints(xs):
    [] => out
    for x in xs:
        out + [x] => out
    for i in [1:len(out) - 1]:
        i => j
        while j > 0 and out[j] < out[j - 1]:
            out[j] => t
            out[j - 1] => out[j]
            t => out[j - 1]
            j - 1 => j
    return out

def median_twice(xs):
    # twice the median, so it stays whole
    sorted_ints(xs) => s
    len(s) => n
    if n % 2 == 1:
        return 2 * s[int((n - 1) / 2)]
    return s[int(n / 2) - 1] + s[int(n / 2)]

def nps(rows):
    # [promoters, passives, detractors]
    0 => p
    0 => m
    0 => d
    for r in rows:
        if r[1] >= 9:
            p + 1 => p
        elif r[1] >= 7:
            m + 1 => m
        else:
            d + 1 => d
    return [p, m, d]

def group(score):
    if score >= 9:
        return "promoter"
    if score >= 7:
        return "passive"
    return "detractor"

def total(r):
    return r[2] + r[3] + r[4]

def ranked(rows):
    # by total, highest first; equal totals keep their order (stable)
    [] => out
    for r in rows:
        out + [r] => out
    for i in [1:len(out) - 1]:
        i => j
        while j > 0 and total(out[j]) > total(out[j - 1]):
            out[j] => t
            out[j - 1] => out[j]
            t => out[j - 1]
            j - 1 => j
    return out

def top(rows, n, policy):
    # strict: exactly n, the first n of the ranking; dense: everyone with a
    # total at least the n-th; exclude: everyone with a total above the
    # n-th's when the n-th is tied with the next one, else the first n
    ranked(rows) => r
    if n >= len(r):
        return r
    total(r[n - 1]) => cut
    [] => out
    if policy == "strict":
        for i in [0:n - 1]:
            out + [r[i]] => out
        return out
    if policy == "dense":
        for x in r:
            if total(x) >= cut:
                out + [x] => out
        return out
    if total(r[n]) != cut:
        for i in [0:n - 1]:
            out + [r[i]] => out
        return out
    for x in r:
        if total(x) > cut:
            out + [x] => out
    return out

def reversed_rows(rows):
    [] => out
    for r in rows:
        [r] + out => out
    return out
```

Python projection of survey.eml:

```python
def gcd(a, b):
    while b != 0:
        r = a % b
        a = b
        b = r
    return a

def fraction_text(num, den):
    sign = ""
    a = num
    if num < 0:
        sign = "-"
        a = 0 - num
    g = gcd(a, den)
    if g == 0:
        g = 1
    a = int(a / g)
    d = int(den / g)
    if d == 1:
        return sign + str(a)
    return sign + str(a) + "/" + str(d)

def two_places(num, den):
    sign = ""
    a = num
    if num < 0:
        sign = "-"
        a = 0 - num
    x = a * 100
    r = x % den
    q = int((x - r) / den)
    if 2 * r >= den:
        q = q + 1
    c = q % 100
    w = int((q - c) / 100)
    if c < 10:
        return sign + str(w) + ".0" + str(c)
    return sign + str(w) + "." + str(c)

def column(rows, k):
    out = []
    for r in rows:
        out = out + [r[k]]
    return out

def sorted_ints(xs):
    out = []
    for x in xs:
        out = out + [x]
    for i in range(1, len(out)):
        j = i
        while j > 0 and out[j] < out[j - 1]:
            t = out[j]
            out[j] = out[j - 1]
            out[j - 1] = t
            j = j - 1
    return out

def median_twice(xs):
    s = sorted_ints(xs)
    n = len(s)
    if n % 2 == 1:
        return 2 * s[int((n - 1) / 2)]
    return s[int(n / 2) - 1] + s[int(n / 2)]

def nps(rows):
    p = 0
    m = 0
    d = 0
    for r in rows:
        if r[1] >= 9:
            p = p + 1
        elif r[1] >= 7:
            m = m + 1
        else:
            d = d + 1
    return [p, m, d]

def group(score):
    if score >= 9:
        return "promoter"
    if score >= 7:
        return "passive"
    return "detractor"

def total(r):
    return r[2] + r[3] + r[4]

def ranked(rows):
    out = []
    for r in rows:
        out = out + [r]
    for i in range(1, len(out)):
        j = i
        while j > 0 and total(out[j]) > total(out[j - 1]):
            t = out[j]
            out[j] = out[j - 1]
            out[j - 1] = t
            j = j - 1
    return out

def top(rows, n, policy):
    r = ranked(rows)
    if n >= len(r):
        return r
    cut = total(r[n - 1])
    out = []
    if policy == "strict":
        for i in range(0, n):
            out = out + [r[i]]
        return out
    if policy == "dense":
        for x in r:
            if total(x) >= cut:
                out = out + [x]
        return out
    if total(r[n]) != cut:
        for i in range(0, n):
            out = out + [r[i]]
        return out
    for x in r:
        if total(x) > cut:
            out = out + [x]
    return out

def reversed_rows(rows):
    out = []
    for r in rows:
        out = [r] + out
    return out
```

## README

# P112 - Survey analyzer

A small survey: how likely each person is to recommend us (0 to 10), and
three questions on a scale of 1 to 5. The program shows the responses,
each question's average and spread, the Net Promoter Score and what
moves it, the top respondents when scores tie, and a split by
satisfaction.

- `main.eml` - the menu, the tables and the reports, and adding a
  response
- `survey.eml` - exact fractions, medians, the promoter groups, totals,
  the ranking and the three top-N policies

How each part works:

- Averages are exact: the mean is shown as a fraction and to two decimals.
  The median of an even count is the mean of the middle two. "4 or 5"
  counts the top two answers.
- The Net Promoter Score is the share of promoters (9 or 10) minus the
  share of detractors (0 to 6), from -100 to +100. A recommend score counts
  only when it crosses from 6 to 7 or from 8 to 9. The program names who
  would change the score with one point more, and how many others would
  move only the mean.
- The top respondents by the total of q1 to q3, as in the corpus case
  `top-n-with-ties`:
  - strict: exactly N, the first N of a ranking that keeps equal totals in
    their order;
  - dense: everyone with a total at least the N-th's;
  - exclude: when the N-th is tied with the next one, only those above the
    tie.

  Strict depends on the order of the responses, so the program also shows
  the reverse order whenever that gives other people.
- Satisfied or not, as in the corpus case `survey-score-analyzer`: a
  recommend score of 7 or more, against 6 or less.

What is checked: menu choices 1 to 7; a top-N count from 1 to the number
of responses; a new response as a name of up to 6 lower-case letters,
not yet in the survey, a recommend score from 0 to 10 and three answers
from 1 to 5; at most 30 responses. An empty answer cancels.

Sessions: `sessions/basic.in` gives:
- the 12 responses, each question, and the Net Promoter Score of 16.67;
- the top 3, then the top 4 (strict takes bo, or kai in reverse order;
  dense keeps both; exclude drops both), then the top 2 (strict takes ana,
  or fay in reverse order);
- the split by satisfaction;
- zed added as a detractor with top answers: the score falls to 7.69, and
  the top 4 again.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- top-N counts: a cancel, 0, 13 and x;
- new responses: a cancel, three numbers, a name already in, upper case,
  a name too long, a recommend score of 11, an answer of 6, and a letter
  for a number.

Built on the verified corpus cases `survey-score-analyzer` (respondents
split by a threshold and averaged) and `top-n-with-ties` (the top N is
not defined until a policy for ties is).
