<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P168-data-anonymizer/ | updated: 2026-10-19 -->

# P168 Data anonymizer

200 made-up patient records released with names, emails and phones removed: postcode, birth date and sex still single out every one, and a log mask of one asterisk per letter publishes each name's length.

EML-P project `projects/data-anonymizer` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/data-anonymizer` runs it and `eml project verify projects/data-anonymizer` 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: the-numbers-were-anonymised-and-the-combination-was-unique (https://efficientnewlanguage.org/cases/698-the-numbers-were-anonymised-and-the-combination-was-unique/), the-mask-removed-the-value-and-kept-the-length (https://efficientnewlanguage.org/cases/549-the-mask-removed-the-value-and-kept-the-length/).

## Sessions

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

Input:

```text
0
x
4

4
0
4
201
4
x
4
1.5
4
99
5
```

Screen:

```text
== Data anonymizer ==
A study release with every direct identifier removed, and what the rest still identifies.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 0
Pick a number from 1 to 5.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> x
Pick a number from 1 to 5.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 4
record (1 to 200)> 
Cancelled.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 4
record (1 to 200)> 0
Type a whole number from 1 to 200.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 4
record (1 to 200)> 201
Type a whole number from 1 to 200.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 4
record (1 to 200)> x
Type a whole number from 1 to 200.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 4
record (1 to 200)> 1.5
Type a whole number from 1 to 200.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 4
record (1 to 200)> 99
Record 99: LS7 7, born 1965-02-10, F. Other records that share what each release keeps:
  postcode, birth date, sex        0
  postcode, birth year, sex        0
  district, birth year, sex        0
  district, birth decade, sex      2
In the masked log it shares its two lengths and city with 6 others.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 5
Bye.
```

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

Input:

```text
1
2
3
4
17
4
1
4
200
5
```

Screen:

```text
== Data anonymizer ==
A study release with every direct identifier removed, and what the rest still identifies.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 1
A study gets 200 patient records. Name, email and phone were removed, and a check of every column proved them gone.
Kept for the study: postcode, birth date and sex, with the measurements. The first six records as released:
    1  LS11 5  1974-11-12  M
    2  LS2 5   1940-07-03  F
    3  LS2 8   1996-10-12  M
    4  LS11 2  1996-03-11  F
    5  LS7 8   1966-07-04  F
    6  LS6 9   1957-07-02  F

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 2
What each release keeps, and how many records are alone on it:
  kept                           alone  smallest group  groups
  postcode, birth date, sex        200               1     200
  postcode, birth year, sex        196               1     198
  district, birth year, sex        165               1     182
  district, birth decade, sex       23               1      76
200 of 200 records are the only one with their postcode, birth date and sex. A voter roll lists the same three against a name, and the join puts a name on each of them.
Kept as the district and the decade of birth, 23 are still alone. A release hides a person only where the smallest group is more than 1, and no level here gets there.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 3
The order log masks a customer's name and email name one asterisk per letter; the city is not personal data:
  order 1001: ***** (email *******) in Wakefield
  order 1002: ********* (email ***********) in Bradford
  order 1003: ****** (email *********) in York
One asterisk per letter: 38 of 200 customers are alone on their two lengths and city.
Eight asterisks for every name: 0 alone; the city is all a line tells apart.
The build's check that no line holds a customer's name passes on both logs. It asks about the letters, not about the length.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 4
record (1 to 200)> 17
Record 17: LS11 6, born 1997-12-24, F. Other records that share what each release keeps:
  postcode, birth date, sex        0
  postcode, birth year, sex        1
  district, birth year, sex        1
  district, birth decade, sex      4
In the masked log it shares its two lengths and city with 1 other.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 4
record (1 to 200)> 1
Record 1: LS11 5, born 1974-11-12, M. Other records that share what each release keeps:
  postcode, birth date, sex        0
  postcode, birth year, sex        0
  district, birth year, sex        0
  district, birth decade, sex      3
In the masked log it shares its two lengths and city with 5 others.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 4
record (1 to 200)> 200
Record 200: LS2 4, born 1949-10-20, F. Other records that share what each release keeps:
  postcode, birth date, sex        0
  postcode, birth year, sex        0
  district, birth year, sex        0
  district, birth decade, sex      4
In the masked log it shares its two lengths and city with 1 other.

1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit
choice> 5
Bye.
```

## Modules

### main.eml

```eml
# P168 data anonymizer: a study release with the direct identifiers
# removed - the release, how many records are alone on what was kept, the
# masked order log, and one record looked up.
import anon

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 pad(s, n):
    s => out
    while len(out) < n:
        out + " " => out
    return out

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

def digits(t):
    for c in t:
        if c < "0" or c > "9":
            return False
    return len(t) > 0

def stars(n):
    "" => out
    while len(out) < n:
        out + "*" => out
    return out

def levels():
    return ["postcode, birth date, sex", "postcode, birth year, sex", "district, birth year, sex", "district, birth decade, sex"]

def keys(rs, level):
    [] => out
    for r in rs:
        out + [anon.key(r, level)] => out
    return out

def log_keys(rs, mask):
    [] => out
    for r in rs:
        out + [anon.log_key(r, mask)] => out
    return out

def row(r):
    return pad(r[2] + " " + str(r[3]), 8) + str(r[4]) + "-" + anon.two(r[5]) + "-" + anon.two(r[6]) + "  " + r[7]

def show_release():
    anon.records() => rs
    ("A study gets " + str(len(rs)) + " patient records. Name, email and phone were removed, and a check of every column proved them gone.") ^0
    "Kept for the study: postcode, birth date and sex, with the measurements. The first six records as released:" ^0
    0 => i
    while i < 6:
        ("  " + lpad(str(i + 1), 3) + "  " + row(rs[i])) ^0
        i + 1 => i

def show_alone():
    anon.records() => rs
    "What each release keeps, and how many records are alone on it:" ^0
    ("  " + pad("kept", 30) + lpad("alone", 6) + lpad("smallest group", 16) + lpad("groups", 8)) ^0
    [] => st
    0 => level
    while level < 4:
        anon.stats(keys(rs, level)) => s
        st + [s] => st
        ("  " + pad(levels()[level], 30) + lpad(str(s[0]), 6) + lpad(str(s[1]), 16) + lpad(str(s[2]), 8)) ^0
        level + 1 => level
    (str(st[0][0]) + " of " + str(len(rs)) + " records are the only one with their postcode, birth date and sex. A voter roll lists the same three against a name, and the join puts a name on each of them.") ^0
    ("Kept as the district and the decade of birth, " + str(st[3][0]) + " are still alone. A release hides a person only where the smallest group is more than 1, and no level here gets there.") ^0

def show_log():
    anon.records() => rs
    "The order log masks a customer's name and email name one asterisk per letter; the city is not personal data:" ^0
    0 => i
    while i < 3:
        ("  order " + str(1001 + i) + ": " + stars(len(rs[i][0])) + " (email " + stars(rs[i][1]) + ") in " + rs[i][8]) ^0
        i + 1 => i
    anon.stats(log_keys(rs, "per letter")) => a
    anon.stats(log_keys(rs, "fixed")) => b
    ("One asterisk per letter: " + str(a[0]) + " of " + str(len(rs)) + " customers are alone on their two lengths and city.") ^0
    ("Eight asterisks for every name: " + str(b[0]) + " alone; the city is all a line tells apart.") ^0
    "The build's check that no line holds a customer's name passes on both logs. It asks about the letters, not about the length." ^0

def show_record():
    anon.records() => rs
    plain(input("record (1 to 200)> ")) => t
    if t == "":
        "Cancelled." ^0
        return
    if len(t) > 3 or not digits(t) or int(t) < 1 or int(t) > 200:
        "Type a whole number from 1 to 200." ^0
        return
    rs[int(t) - 1] => r
    ("Record " + t + ": " + r[2] + " " + str(r[3]) + ", born " + str(r[4]) + "-" + anon.two(r[5]) + "-" + anon.two(r[6]) + ", " + r[7] + ". Other records that share what each release keeps:") ^0
    0 => level
    while level < 4:
        anon.key(r, level) => k
        anon.groups(keys(rs, level))[k] - 1 => n
        ("  " + pad(levels()[level], 30) + lpad(str(n), 4)) ^0
        level + 1 => level
    anon.groups(log_keys(rs, "per letter"))[anon.log_key(r, "per letter")] - 1 => m
    " others" => word
    if m == 1:
        " other" => word
    ("In the masked log it shares its two lengths and city with " + str(m) + word + ".") ^0

"== Data anonymizer ==" ^0
"A study release with every direct identifier removed, and what the rest still identifies." ^0
True => running
while running:
    "" ^0
    "1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit" ^0
    plain(input("choice> ")) => choice
    if choice == "1":
        show_release()
    elif choice == "2":
        show_alone()
    elif choice == "3":
        show_log()
    elif choice == "4":
        show_record()
    elif choice == "5":
        False => running
    else:
        "Pick a number from 1 to 5." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import anon

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 pad(s, n):
    out = s
    while len(out) < n:
        out = out + " "
    return out

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

def digits(t):
    for c in t:
        if c < "0" or c > "9":
            return False
    return len(t) > 0

def stars(n):
    out = ""
    while len(out) < n:
        out = out + "*"
    return out

def levels():
    return ["postcode, birth date, sex", "postcode, birth year, sex", "district, birth year, sex", "district, birth decade, sex"]

def keys(rs, level):
    out = []
    for r in rs:
        out = out + [anon.key(r, level)]
    return out

def log_keys(rs, mask):
    out = []
    for r in rs:
        out = out + [anon.log_key(r, mask)]
    return out

def row(r):
    return pad(r[2] + " " + str(r[3]), 8) + str(r[4]) + "-" + anon.two(r[5]) + "-" + anon.two(r[6]) + "  " + r[7]

def show_release():
    rs = anon.records()
    print("A study gets " + str(len(rs)) + " patient records. Name, email and phone were removed, and a check of every column proved them gone.")
    print("Kept for the study: postcode, birth date and sex, with the measurements. The first six records as released:")
    i = 0
    while i < 6:
        print("  " + lpad(str(i + 1), 3) + "  " + row(rs[i]))
        i = i + 1

def show_alone():
    rs = anon.records()
    print("What each release keeps, and how many records are alone on it:")
    print("  " + pad("kept", 30) + lpad("alone", 6) + lpad("smallest group", 16) + lpad("groups", 8))
    st = []
    level = 0
    while level < 4:
        s = anon.stats(keys(rs, level))
        st = st + [s]
        print("  " + pad(levels()[level], 30) + lpad(str(s[0]), 6) + lpad(str(s[1]), 16) + lpad(str(s[2]), 8))
        level = level + 1
    print(str(st[0][0]) + " of " + str(len(rs)) + " records are the only one with their postcode, birth date and sex. A voter roll lists the same three against a name, and the join puts a name on each of them.")
    print("Kept as the district and the decade of birth, " + str(st[3][0]) + " are still alone. A release hides a person only where the smallest group is more than 1, and no level here gets there.")

def show_log():
    rs = anon.records()
    print("The order log masks a customer's name and email name one asterisk per letter; the city is not personal data:")
    i = 0
    while i < 3:
        print("  order " + str(1001 + i) + ": " + stars(len(rs[i][0])) + " (email " + stars(rs[i][1]) + ") in " + rs[i][8])
        i = i + 1
    a = anon.stats(log_keys(rs, "per letter"))
    b = anon.stats(log_keys(rs, "fixed"))
    print("One asterisk per letter: " + str(a[0]) + " of " + str(len(rs)) + " customers are alone on their two lengths and city.")
    print("Eight asterisks for every name: " + str(b[0]) + " alone; the city is all a line tells apart.")
    print("The build's check that no line holds a customer's name passes on both logs. It asks about the letters, not about the length.")

def show_record():
    rs = anon.records()
    t = plain(input("record (1 to 200)> "))
    if t == "":
        print("Cancelled.")
        return
    if len(t) > 3 or not digits(t) or int(t) < 1 or int(t) > 200:
        print("Type a whole number from 1 to 200.")
        return
    r = rs[int(t) - 1]
    print("Record " + t + ": " + r[2] + " " + str(r[3]) + ", born " + str(r[4]) + "-" + anon.two(r[5]) + "-" + anon.two(r[6]) + ", " + r[7] + ". Other records that share what each release keeps:")
    level = 0
    while level < 4:
        k = anon.key(r, level)
        n = anon.groups(keys(rs, level))[k] - 1
        print("  " + pad(levels()[level], 30) + lpad(str(n), 4))
        level = level + 1
    m = anon.groups(log_keys(rs, "per letter"))[anon.log_key(r, "per letter")] - 1
    word = " others"
    if m == 1:
        word = " other"
    print("In the masked log it shares its two lengths and city with " + str(m) + word + ".")

print("== Data anonymizer ==")
print("A study release with every direct identifier removed, and what the rest still identifies.")
running = True
while running:
    print("")
    print("1) the release  2) alone on what was kept  3) the masked log  4) one record  5) quit")
    choice = plain(input("choice> "))
    if choice == "1":
        show_release()
    elif choice == "2":
        show_alone()
    elif choice == "3":
        show_log()
    elif choice == "4":
        show_record()
    elif choice == "5":
        running = False
    else:
        print("Pick a number from 1 to 5.")
print("Bye.")
```

### anon.eml

```eml
# P168 data anonymizer - 200 made-up patient records released for a study
# with the names, emails and phone numbers removed. As in the corpus case
# the-numbers-were-anonymised-and-the-combination-was-unique, what was kept,
# postcode, birth date and sex, is worthless alone and together names one
# record nearly every time. And as in
# the-mask-removed-the-value-and-kept-the-length, a log that masks a name
# with one asterisk per letter publishes its length.

def surnames():
    return ["Ng", "Li", "Wu", "Shah", "Khan", "Ross", "Hall", "Wood", "Patel", "Evans",
            "Ahmed", "Moore", "Clark", "Lewis", "Hughes", "Turner", "Begum", "Foster", "Morgan", "Kowalski",
            "Fernandez", "Oyelaran", "Bennett", "Robinson", "Thompson", "Lindqvist", "Fitzgerald", "Okonkwo", "Hargreaves", "Abernathy"]

def districts():
    return ["LS1", "LS2", "LS6", "LS7", "LS8", "LS11"]

def cities():
    return ["Leeds", "York", "Hull", "Bradford", "Wakefield"]

def step(x):
    # the next number from a seed, 1 to 2147483646
    return (x * 48271) % 2147483647

def records():
    # [surname, email name length, district, sector, year, month, day, sex, city]
    [0] * 200 => out
    1019202711 => x
    0 => k
    while k < 200:
        step(x) => x
        surnames()[x % 30] => name
        step(x) => x
        len(name) + 2 + x % 3 => email
        step(x) => x
        districts()[x % 6] => d
        step(x) => x
        1 + x % 9 => sector
        step(x) => x
        1940 + x % 66 => year
        step(x) => x
        1 + x % 12 => month
        step(x) => x
        1 + x % 28 => day
        step(x) => x
        "F" => sex
        if x % 2 == 1:
            "M" => sex
        step(x) => x
        cities()[x % 5] => city
        [name, email, d, sector, year, month, day, sex, city] => out[k]
        k + 1 => k
    return out

def two(n):
    if n < 10:
        return "0" + str(n)
    return str(n)

def key(r, level):
    # what a release keeps of a record, at a level of coarsening:
    # 0 postcode, birth date, sex; 1 postcode, birth year, sex;
    # 2 district, birth year, sex; 3 district, birth decade, sex
    if level == 0:
        return r[2] + " " + str(r[3]) + "|" + str(r[4]) + "-" + two(r[5]) + "-" + two(r[6]) + "|" + r[7]
    if level == 1:
        return r[2] + " " + str(r[3]) + "|" + str(r[4]) + "|" + r[7]
    if level == 2:
        return r[2] + "|" + str(r[4]) + "|" + r[7]
    return r[2] + "|" + str(r[4] - r[4] % 10) + "s|" + r[7]

def log_key(r, mask):
    # what a log line shows of a customer: per letter, each name's length
    # and the city; fixed, eight asterisks and the city
    if mask == "per letter":
        return str(len(r[0])) + "|" + str(r[1]) + "|" + r[8]
    return r[8]

def groups(keys):
    # how many records share each key
    {} => counts
    for k in keys:
        if k in counts:
            counts[k] + 1 => counts[k]
        else:
            1 => counts[k]
    return counts

def stats(keys):
    # [records alone on their key, the smallest group, the number of groups]
    groups(keys) => counts
    0 => alone
    0 => smallest
    0 => n
    for k in counts:
        n + 1 => n
        if counts[k] == 1:
            alone + 1 => alone
        if smallest == 0 or counts[k] < smallest:
            counts[k] => smallest
    return [alone, smallest, n]
```

Python projection of anon.eml:

```python
def surnames():
    return ["Ng", "Li", "Wu", "Shah", "Khan", "Ross", "Hall", "Wood", "Patel", "Evans", "Ahmed", "Moore", "Clark", "Lewis", "Hughes", "Turner", "Begum", "Foster", "Morgan", "Kowalski", "Fernandez", "Oyelaran", "Bennett", "Robinson", "Thompson", "Lindqvist", "Fitzgerald", "Okonkwo", "Hargreaves", "Abernathy"]

def districts():
    return ["LS1", "LS2", "LS6", "LS7", "LS8", "LS11"]

def cities():
    return ["Leeds", "York", "Hull", "Bradford", "Wakefield"]

def step(x):
    return x * 48271 % 2147483647

def records():
    out = [0] * 200
    x = 1019202711
    k = 0
    while k < 200:
        x = step(x)
        name = surnames()[x % 30]
        x = step(x)
        email = len(name) + 2 + x % 3
        x = step(x)
        d = districts()[x % 6]
        x = step(x)
        sector = 1 + x % 9
        x = step(x)
        year = 1940 + x % 66
        x = step(x)
        month = 1 + x % 12
        x = step(x)
        day = 1 + x % 28
        x = step(x)
        sex = "F"
        if x % 2 == 1:
            sex = "M"
        x = step(x)
        city = cities()[x % 5]
        out[k] = [name, email, d, sector, year, month, day, sex, city]
        k = k + 1
    return out

def two(n):
    if n < 10:
        return "0" + str(n)
    return str(n)

def key(r, level):
    if level == 0:
        return r[2] + " " + str(r[3]) + "|" + str(r[4]) + "-" + two(r[5]) + "-" + two(r[6]) + "|" + r[7]
    if level == 1:
        return r[2] + " " + str(r[3]) + "|" + str(r[4]) + "|" + r[7]
    if level == 2:
        return r[2] + "|" + str(r[4]) + "|" + r[7]
    return r[2] + "|" + str(r[4] - r[4] % 10) + "s|" + r[7]

def log_key(r, mask):
    if mask == "per letter":
        return str(len(r[0])) + "|" + str(r[1]) + "|" + r[8]
    return r[8]

def groups(keys):
    counts = {}
    for k in keys:
        if k in counts:
            counts[k] = counts[k] + 1
        else:
            counts[k] = 1
    return counts

def stats(keys):
    counts = groups(keys)
    alone = 0
    smallest = 0
    n = 0
    for k in counts:
        n = n + 1
        if counts[k] == 1:
            alone = alone + 1
        if smallest == 0 or counts[k] < smallest:
            smallest = counts[k]
    return [alone, smallest, n]
```

## README

# P168 - Data anonymizer

A study release of 200 made-up patient records with every direct
identifier removed, and what the fields that were kept still identify.

- `main.eml` - the menu, the release, how many records are alone on what
  was kept, the masked order log, and one record looked up
- `anon.eml` - the records, drawn from a fixed seed; what each release
  keeps of a record; what a masked log line shows; and counting groups

How each part works:

- Name, email and phone are removed and a check of every column proves it.
  Postcode, birth date and sex are kept, because the study needs them.
- As in the corpus case `the-numbers-were-anonymised-and-the-combination-was-unique`,
  all 200 records are the only one with their postcode, birth date and
  sex: a voter roll that lists the same three against a name puts a name
  on each. Kept as postcode and birth year, 196 are alone; as district and
  year, 165; as district and decade, still 23. No level here makes the
  smallest group bigger than 1.
- As in `the-mask-removed-the-value-and-kept-the-length`, the order log
  masks a name and an email name one asterisk per letter, and keeps the
  city: 38 of the 200 customers are alone on the two lengths and the city.
  Eight asterisks for every name leave the city alone to tell lines apart,
  and no customer alone on it. The build's check that no line holds a
  name passes on both logs.

What is checked: menu choices 1 to 5; a record from 1 to 200. An empty
answer cancels.

Sessions: `sessions/basic.in` gives:
- the release, the counts of records alone and the masked log;
- records 17, 1 and 200.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- records: a cancel, 0, 201, x and 1.5;
- record 99.

Built on the verified corpus cases `the-numbers-were-anonymised-and-the-combination-was-unique`
(a release with direct identifiers removed and postcode, birth date and
sex kept) and `the-mask-removed-the-value-and-kept-the-length` (a log
mask of one asterisk per character).
