Project 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.

2 modules · 2 recorded sessionstext-menu UI in the terminalupdated 2026-10-19

Every screen below was recorded under CPython. When this page was built, the EML interpreter replayed each session from the same input and printed the same bytes.

About

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).

Recorded sessions

What the screen shows while someone uses the program. Each typed line appears after its prompt, the way a terminal shows it.

bad-input

interpreter: byte-equal
== 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.
What was typed (15 lines)
0
x
4

4
0
4
201
4
x
4
1.5
4
99
5

basic

interpreter: byte-equal
== 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.
What was typed (10 lines)
1
2
3
4
17
4
1
4
200
5

Modules

The program as written, entry module first. Each module transpiles to its own Python file, which is what eml project run executes.

main.eml(entry)

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 (main.py)
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 (anon.py)
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]

Built on these corpus cases

ΣEML 2026

Efficient New Language — a high-density semantic overlay for humans and AI agents.

This site’s demos run on the real EML toolchain, in your browser.

© 2026 EveMissLab (一言諾科技有限公司) / Neo.K. All rights reserved.