<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P106-date-reader/ | updated: 2026-10-16 -->

# P106 Date reader

A date in three parts read as day/month/year, month/day/year and year/month/day, each reading kept or refused with its reason and the real ones set apart in days; how many days of a year read as another date the other way; and a date written so it cannot be misread.

EML-P project `projects/date-reader` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/date-reader` runs it and `eml project verify projects/date-reader` 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: date-parse-ambiguity (https://efficientnewlanguage.org/cases/250-date-parse-ambiguity/).

## Sessions

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

Input:

```text
0
x
1

1
2026
1
1/2/3/4
1
3 Foo 2026
1
3 Ma 2026
1
32/13/2026
1
0/0/0
1
1/1/123
1
1/1/0999
1
2026 3 Apr
2

2
99
2
abcd
2
0999
3

3
04/03/2026
3
2026-02-30
3
2026-13-01
4
```

Screen:

```text
== Date reader ==
A date in three parts can be read as day/month/year, month/day/year or year/month/day; this tries all three.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 0
Pick a number from 1 to 4.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> x
Pick a number from 1 to 4.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 
Cancelled.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 2026
Type a date of three parts, such as 03/04/05, 2026-04-03 or 3 Apr 2026.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 1/2/3/4
Type a date of three parts, such as 03/04/05, 2026-04-03 or 3 Apr 2026.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 3 Foo 2026
  day/month/year   no: Foo is not a month
  month/day/year   no: Foo is not a day
  year/month/day   no: Foo is not a month
No reading gives a date.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 3 Ma 2026
  day/month/year   no: Ma is not a month
  month/day/year   no: Ma is not a day
  year/month/day   no: Ma is not a month
No reading gives a date.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 32/13/2026
  day/month/year   no: there is no month 13
  month/day/year   no: there is no month 32
  year/month/day   no: there is no month 13
No reading gives a date.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 0/0/0
  day/month/year   no: there is no month 0
  month/day/year   no: there is no month 0
  year/month/day   no: there is no month 0
No reading gives a date.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 1/1/123
  day/month/year   no: 123 is not a year (1 or 2 digits, or 4 from 1000)
  month/day/year   no: 123 is not a year (1 or 2 digits, or 4 from 1000)
  year/month/day   no: 123 is not a day
No reading gives a date.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 1/1/0999
  day/month/year   no: 0999 is not a year (1 or 2 digits, or 4 from 1000)
  month/day/year   no: 0999 is not a year (1 or 2 digits, or 4 from 1000)
  year/month/day   no: 0999 is not a day
No reading gives a date.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 2026 3 Apr
  day/month/year   no: 2026 is not a day
  month/day/year   no: 2026 is not a month
  year/month/day   no: the month name Apr is not where the month goes
No reading gives a date.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 2
year (1000 to 9999)> 
Cancelled.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 2
year (1000 to 9999)> 99
Type a year from 1000 to 9999.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 2
year (1000 to 9999)> abcd
Type a year from 1000 to 9999.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 2
year (1000 to 9999)> 0999
Type a year from 1000 to 9999.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 3
date as year-month-day, such as 2026-04-03> 
Cancelled.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 3
date as year-month-day, such as 2026-04-03> 04/03/2026
Type the year first, in four digits, then the month and the day.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 3
date as year-month-day, such as 2026-04-03> 2026-02-30
Not a date: February 2026 has no day 30.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 3
date as year-month-day, such as 2026-04-03> 2026-13-01
Not a date: there is no month 13.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 4
Bye.
```

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

Input:

```text
1
03/04/2026
1
12/01/2026
1
03/04/05
1
25/12/2026
1
31/04/2026
1
2026-04-03
1
3 Apr 2026
1
April 3, 2026
1
05 Apr 03
1
29/02/2027
1
07/07/2026
2
2026
2
2024
3
2026-04-03
3
1999-12-31
4
```

Screen:

```text
== Date reader ==
A date in three parts can be read as day/month/year, month/day/year or year/month/day; this tries all three.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 03/04/2026
  day/month/year   2026-04-03  Friday
  month/day/year   2026-03-04  Wednesday
  year/month/day   no: 2026 is not a day
2 different dates, up to 30 days apart. Every one of them is a well-formed date, so no check of the string can say which was meant.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 12/01/2026
  day/month/year   2026-01-12  Monday
  month/day/year   2026-12-01  Tuesday
  year/month/day   no: 2026 is not a day
2 different dates, up to 323 days apart. Every one of them is a well-formed date, so no check of the string can say which was meant.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 03/04/05
  day/month/year   2005-04-03  Sunday
  month/day/year   2005-03-04  Friday
  year/month/day   2003-04-05  Saturday
  (a one- or two-digit year: 00 to 68 mean 2000 to 2068, 69 to 99 mean 1969 to 1999)
3 different dates, up to 729 days apart. Every one of them is a well-formed date, so no check of the string can say which was meant.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 25/12/2026
  day/month/year   2026-12-25  Friday
  month/day/year   no: there is no month 25
  year/month/day   no: 2026 is not a day
One date only: 25 December 2026.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 31/04/2026
  day/month/year   no: April 2026 has no day 31
  month/day/year   no: there is no month 31
  year/month/day   no: 2026 is not a day
No reading gives a date.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 2026-04-03
  day/month/year   no: 2026 is not a day
  month/day/year   no: 2026 is not a month
  year/month/day   2026-04-03  Friday
One date only: 3 April 2026.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 3 Apr 2026
  day/month/year   2026-04-03  Friday
  month/day/year   no: the month name Apr is not where the month goes
  year/month/day   no: 2026 is not a day
One date only: 3 April 2026.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> April 3, 2026
  day/month/year   no: the month name April is not where the month goes
  month/day/year   2026-04-03  Friday
  year/month/day   no: the month name April is not where the month goes
One date only: 3 April 2026.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 05 Apr 03
  day/month/year   2003-04-05  Saturday
  month/day/year   no: the month name Apr is not where the month goes
  year/month/day   2005-04-03  Sunday
  (a one- or two-digit year: 00 to 68 mean 2000 to 2068, 69 to 99 mean 1969 to 1999)
2 different dates, up to 729 days apart. Every one of them is a well-formed date, so no check of the string can say which was meant.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 29/02/2027
  day/month/year   no: February 2027 has no day 29
  month/day/year   no: there is no month 29
  year/month/day   no: 2027 is not a day
No reading gives a date.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 1
date> 07/07/2026
  day/month/year   2026-07-07  Tuesday
  month/day/year   2026-07-07  Tuesday
  year/month/day   no: 2026 is not a day
One date only: 7 July 2026.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 2
year (1000 to 9999)> 2026
Of the 365 days of 2026, written dd/mm:
  221 have a day above 12, so mm/dd cannot read them at all;
  12 have the day equal to the month (01/01, 02/02 ...), the same date either way;
  132 also read as a different, valid date mm/dd.
A reader who takes them as mm/dd gets an error on the 221, the right date on the 12, and on the other 132 a wrong date with no error at all.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 2
year (1000 to 9999)> 2024
Of the 366 days of 2024, written dd/mm:
  222 have a day above 12, so mm/dd cannot read them at all;
  12 have the day equal to the month (01/01, 02/02 ...), the same date either way;
  132 also read as a different, valid date mm/dd.
A reader who takes them as mm/dd gets an error on the 222, the right date on the 12, and on the other 132 a wrong date with no error at all.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 3
date as year-month-day, such as 2026-04-03> 2026-04-03
ISO 8601: 2026-04-03. In words: Friday, 3 April 2026.
A four-digit year first, then the month and the day, or the month as a word, leaves nothing to guess.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 3
date as year-month-day, such as 2026-04-03> 1999-12-31
ISO 8601: 1999-12-31. In words: Friday, 31 December 1999.
A four-digit year first, then the month and the day, or the month as a word, leaves nothing to guess.

1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit
choice> 4
Bye.
```

## Modules

### main.eml

```eml
# P106 date reader: every way a written date can be read, how far apart
# the readings fall, how many days of a year are ambiguous, and the date
# written so that it cannot be misread.
import readings

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

def grouped(n):
    str(n) => s
    "" => out
    0 => k
    for i in [0:len(s) - 1]:
        s[len(s) - 1 - i] + out => out
        k + 1 => k
        if k % 3 == 0 and i < len(s) - 1:
            "," + out => out
    return out

def read_date():
    plain(input("date> ")) => t
    if t == "":
        "Cancelled." ^0
        return
    readings.tokens(t) => f
    if len(f) != 3:
        "Type a date of three parts, such as 03/04/05, 2026-04-03 or 3 Apr 2026." ^0
        return
    ["DMY", "MDY", "YMD"] => orders
    ["day/month/year", "month/day/year", "year/month/day"] => names
    [] => good
    False => short_year
    for k in [0:2]:
        readings.reading(f, orders[k]) => r
        if r[0]:
            if r[4]:
                True => short_year
            ("  " + pad(names[k], 17) + readings.iso(r[1], r[2], r[3]) + "  " + readings.weekday(r[1], r[2], r[3])) ^0
            True => dup
            for g in good:
                if g[0] == r[1] and g[1] == r[2] and g[2] == r[3]:
                    False => dup
            if dup:
                good + [[r[1], r[2], r[3]]] => good
        else:
            ("  " + pad(names[k], 17) + "no: " + r[1]) ^0
    if short_year:
        "  (a one- or two-digit year: 00 to 68 mean 2000 to 2068, 69 to 99 mean 1969 to 1999)" ^0
    if len(good) == 0:
        "No reading gives a date." ^0
    elif len(good) == 1:
        ("One date only: " + readings.words(good[0][0], good[0][1], good[0][2]) + ".") ^0
    else:
        0 => far
        for a in good:
            for b in good:
                readings.day_number(a[0], a[1], a[2]) - readings.day_number(b[0], b[1], b[2]) => x
                if x > far:
                    x => far
        (str(len(good)) + " different dates, up to " + grouped(far) + " days apart. Every one of them is a well-formed date, so no check of the string can say which was meant.") ^0

def ambiguous():
    plain(input("year (1000 to 9999)> ")) => t
    if t == "":
        "Cancelled." ^0
        return
    if not readings.is_digits(t) or len(t) != 4 or t[0] == "0":
        "Type a year from 1000 to 9999." ^0
        return
    int(t) => y
    readings.ambiguous_days(y) => a
    a[0] - a[1] - a[2] => one
    ("Of the " + str(a[0]) + " days of " + t + ", written dd/mm:") ^0
    ("  " + str(one) + " have a day above 12, so mm/dd cannot read them at all;") ^0
    ("  " + str(a[2]) + " have the day equal to the month (01/01, 02/02 ...), the same date either way;") ^0
    ("  " + str(a[1]) + " also read as a different, valid date mm/dd.") ^0
    ("A reader who takes them as mm/dd gets an error on the " + str(one) + ", the right date on the " + str(a[2]) + ", and on the other " + str(a[1]) + " a wrong date with no error at all.") ^0

def unambiguous():
    plain(input("date as year-month-day, such as 2026-04-03> ")) => t
    if t == "":
        "Cancelled." ^0
        return
    readings.tokens(t) => f
    if len(f) != 3 or len(f[0]) != 4:
        "Type the year first, in four digits, then the month and the day." ^0
        return
    readings.reading(f, "YMD") => r
    if not r[0]:
        ("Not a date: " + r[1] + ".") ^0
        return
    ("ISO 8601: " + readings.iso(r[1], r[2], r[3]) + ". In words: " + readings.weekday(r[1], r[2], r[3]) + ", " + readings.words(r[1], r[2], r[3]) + ".") ^0
    "A four-digit year first, then the month and the day, or the month as a word, leaves nothing to guess." ^0

"== Date reader ==" ^0
"A date in three parts can be read as day/month/year, month/day/year or year/month/day; this tries all three." ^0
True => running
while running:
    "" ^0
    "1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit" ^0
    plain(input("choice> ")) => choice
    if choice == "1":
        read_date()
    elif choice == "2":
        ambiguous()
    elif choice == "3":
        unambiguous()
    elif choice == "4":
        False => running
    else:
        "Pick a number from 1 to 4." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import readings

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

def grouped(n):
    s = str(n)
    out = ""
    k = 0
    for i in range(0, len(s)):
        out = s[len(s) - 1 - i] + out
        k = k + 1
        if k % 3 == 0 and i < len(s) - 1:
            out = "," + out
    return out

def read_date():
    t = plain(input("date> "))
    if t == "":
        print("Cancelled.")
        return
    f = readings.tokens(t)
    if len(f) != 3:
        print("Type a date of three parts, such as 03/04/05, 2026-04-03 or 3 Apr 2026.")
        return
    orders = ["DMY", "MDY", "YMD"]
    names = ["day/month/year", "month/day/year", "year/month/day"]
    good = []
    short_year = False
    for k in range(0, 3):
        r = readings.reading(f, orders[k])
        if r[0]:
            if r[4]:
                short_year = True
            print("  " + pad(names[k], 17) + readings.iso(r[1], r[2], r[3]) + "  " + readings.weekday(r[1], r[2], r[3]))
            dup = True
            for g in good:
                if g[0] == r[1] and g[1] == r[2] and g[2] == r[3]:
                    dup = False
            if dup:
                good = good + [[r[1], r[2], r[3]]]
        else:
            print("  " + pad(names[k], 17) + "no: " + r[1])
    if short_year:
        print("  (a one- or two-digit year: 00 to 68 mean 2000 to 2068, 69 to 99 mean 1969 to 1999)")
    if len(good) == 0:
        print("No reading gives a date.")
    elif len(good) == 1:
        print("One date only: " + readings.words(good[0][0], good[0][1], good[0][2]) + ".")
    else:
        far = 0
        for a in good:
            for b in good:
                x = readings.day_number(a[0], a[1], a[2]) - readings.day_number(b[0], b[1], b[2])
                if x > far:
                    far = x
        print(str(len(good)) + " different dates, up to " + grouped(far) + " days apart. Every one of them is a well-formed date, so no check of the string can say which was meant.")

def ambiguous():
    t = plain(input("year (1000 to 9999)> "))
    if t == "":
        print("Cancelled.")
        return
    if not readings.is_digits(t) or len(t) != 4 or t[0] == "0":
        print("Type a year from 1000 to 9999.")
        return
    y = int(t)
    a = readings.ambiguous_days(y)
    one = a[0] - a[1] - a[2]
    print("Of the " + str(a[0]) + " days of " + t + ", written dd/mm:")
    print("  " + str(one) + " have a day above 12, so mm/dd cannot read them at all;")
    print("  " + str(a[2]) + " have the day equal to the month (01/01, 02/02 ...), the same date either way;")
    print("  " + str(a[1]) + " also read as a different, valid date mm/dd.")
    print("A reader who takes them as mm/dd gets an error on the " + str(one) + ", the right date on the " + str(a[2]) + ", and on the other " + str(a[1]) + " a wrong date with no error at all.")

def unambiguous():
    t = plain(input("date as year-month-day, such as 2026-04-03> "))
    if t == "":
        print("Cancelled.")
        return
    f = readings.tokens(t)
    if len(f) != 3 or len(f[0]) != 4:
        print("Type the year first, in four digits, then the month and the day.")
        return
    r = readings.reading(f, "YMD")
    if not r[0]:
        print("Not a date: " + r[1] + ".")
        return
    print("ISO 8601: " + readings.iso(r[1], r[2], r[3]) + ". In words: " + readings.weekday(r[1], r[2], r[3]) + ", " + readings.words(r[1], r[2], r[3]) + ".")
    print("A four-digit year first, then the month and the day, or the month as a word, leaves nothing to guess.")

print("== Date reader ==")
print("A date in three parts can be read as day/month/year, month/day/year or year/month/day; this tries all three.")
running = True
while running:
    print("")
    print("1) read a date  2) ambiguous days in a year  3) write a date unmistakably  4) quit")
    choice = plain(input("choice> "))
    if choice == "1":
        read_date()
    elif choice == "2":
        ambiguous()
    elif choice == "3":
        unambiguous()
    elif choice == "4":
        running = False
    else:
        print("Pick a number from 1 to 4.")
print("Bye.")
```

### readings.eml

```eml
# P106 date reader - a date written as three fields can mean different
# days, and nothing in the string says which (the corpus case
# date-parse-ambiguity). Every reading of three fields - day/month/year,
# month/day/year, year/month/day - is tried and kept or refused with its
# reason. Two-digit years follow strptime's %y: 00-68 are 2000-2068 and
# 69-99 are 1969-1999. A month can also be written as a name.

def month_names():
    return ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]

def lower(s):
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ" => up
    "abcdefghijklmnopqrstuvwxyz" => low
    "" => out
    for c in s:
        c => x
        for i in [0:25]:
            if c == up[i]:
                low[i] => x
        out + x => out
    return out

def is_digits(s):
    if len(s) == 0:
        return False
    for c in s:
        if not (c >= "0" and c <= "9"):
            return False
    return True

def month_of(t):
    # 1-12 for a month name or its first three letters or more, else 0
    lower(t) => s
    if len(s) < 3:
        return 0
    month_names() => names
    for i in [0:11]:
        if len(s) <= len(names[i]) and names[i][0:len(s)] == s:
            return i + 1
    return 0

def tokens(text):
    # the fields between / - . , and spaces
    [] => out
    "" => w
    for c in text + " ":
        if c == "/" or c == "-" or c == "." or c == "," or c == " ":
            if w != "":
                out + [w] => out
            "" => w
        else:
            w + c => w
    return out

def leap(y):
    return (y % 4 == 0 and y % 100 != 0) or y % 400 == 0

def month_days(y, m):
    [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] => d
    if m == 2 and leap(y):
        return 29
    return d[m - 1]

def year_of(t):
    # [year] from 1-2 digits (%y) or 4 digits (1000-9999), else []
    if not is_digits(t):
        return []
    if len(t) <= 2:
        int(t) => y
        if y < 69:
            return [2000 + y]
        return [1900 + y]
    if len(t) == 4 and t[0] != "0":
        return [int(t)]
    return []

def reading(fields, order):
    # order "DMY", "MDY" or "YMD": [True, y, m, d, short year] or
    # [False, why]
    False => short
    -1 => y
    -1 => m
    -1 => d
    for k in [0:2]:
        fields[k] => t
        order[k] => role
        if role == "M":
            if is_digits(t) and len(t) <= 2:
                int(t) => m
                if m < 1 or m > 12:
                    return [False, "there is no month " + t]
            else:
                month_of(t) => m
                if m == 0:
                    return [False, t + " is not a month"]
        elif month_of(t) > 0 and not is_digits(t):
            return [False, "the month name " + t + " is not where the month goes"]
        elif role == "D":
            if not is_digits(t) or len(t) > 2:
                return [False, t + " is not a day"]
            int(t) => d
        else:
            year_of(t) => yy
            if len(yy) == 0:
                return [False, t + " is not a year (1 or 2 digits, or 4 from 1000)"]
            yy[0] => y
            (len(t) <= 2) => short
    if d < 1 or d > month_days(y, m):
        return [False, title(month_names()[m - 1]) + " " + str(y) + " has no day " + str(d)]
    return [True, y, m, d, short]

def day_number(y, m, d):
    # days from 1970-01-01, proleptic Gregorian (Hinnant's days_from_civil)
    y => yy
    if m <= 2:
        yy - 1 => yy
    yy % 400 => yoe
    int((yy - yoe) / 400) => era
    m + 9 => mp
    if m > 2:
        m - 3 => mp
    int((153 * mp + 2 - (153 * mp + 2) % 5) / 5) + d - 1 => doy
    yoe * 365 + int((yoe - yoe % 4) / 4) - int((yoe - yoe % 100) / 100) + doy => doe
    return era * 146097 + doe - 719468

def weekday(y, m, d):
    ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] => names
    return names[(day_number(y, m, d) + 4) % 7]

def iso(y, m, d):
    str(y) => ys
    while len(ys) < 4:
        "0" + ys => ys
    str(m) => ms
    if m < 10:
        "0" + ms => ms
    str(d) => ds
    if d < 10:
        "0" + ds => ds
    return ys + "-" + ms + "-" + ds

def title(n):
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ" => up
    "abcdefghijklmnopqrstuvwxyz" => low
    for i in [0:25]:
        if n[0] == low[i]:
            return up[i] + n[1:len(n)]
    return n

def words(y, m, d):
    return str(d) + " " + title(month_names()[m - 1]) + " " + str(y)

def ambiguous_days(y):
    # [days in the year, days dd/mm reads as another valid date mm/dd,
    #  days that read the same both ways]
    0 => total
    0 => other
    0 => same
    for m in [1:12]:
        for d in [1:month_days(y, m)]:
            total + 1 => total
            if d <= 12:
                if d == m:
                    same + 1 => same
                elif m <= month_days(y, d):
                    other + 1 => other
    return [total, other, same]
```

Python projection of readings.eml:

```python
def month_names():
    return ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]

def lower(s):
    up = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    low = "abcdefghijklmnopqrstuvwxyz"
    out = ""
    for c in s:
        x = c
        for i in range(0, 26):
            if c == up[i]:
                x = low[i]
        out = out + x
    return out

def is_digits(s):
    if len(s) == 0:
        return False
    for c in s:
        if not (c >= "0" and c <= "9"):
            return False
    return True

def month_of(t):
    s = lower(t)
    if len(s) < 3:
        return 0
    names = month_names()
    for i in range(0, 12):
        if len(s) <= len(names[i]) and names[i][0:len(s)] == s:
            return i + 1
    return 0

def tokens(text):
    out = []
    w = ""
    for c in text + " ":
        if c == "/" or c == "-" or c == "." or c == "," or c == " ":
            if w != "":
                out = out + [w]
            w = ""
        else:
            w = w + c
    return out

def leap(y):
    return y % 4 == 0 and y % 100 != 0 or y % 400 == 0

def month_days(y, m):
    d = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    if m == 2 and leap(y):
        return 29
    return d[m - 1]

def year_of(t):
    if not is_digits(t):
        return []
    if len(t) <= 2:
        y = int(t)
        if y < 69:
            return [2000 + y]
        return [1900 + y]
    if len(t) == 4 and t[0] != "0":
        return [int(t)]
    return []

def reading(fields, order):
    short = False
    y = -1
    m = -1
    d = -1
    for k in range(0, 3):
        t = fields[k]
        role = order[k]
        if role == "M":
            if is_digits(t) and len(t) <= 2:
                m = int(t)
                if m < 1 or m > 12:
                    return [False, "there is no month " + t]
            else:
                m = month_of(t)
                if m == 0:
                    return [False, t + " is not a month"]
        elif month_of(t) > 0 and not is_digits(t):
            return [False, "the month name " + t + " is not where the month goes"]
        elif role == "D":
            if not is_digits(t) or len(t) > 2:
                return [False, t + " is not a day"]
            d = int(t)
        else:
            yy = year_of(t)
            if len(yy) == 0:
                return [False, t + " is not a year (1 or 2 digits, or 4 from 1000)"]
            y = yy[0]
            short = len(t) <= 2
    if d < 1 or d > month_days(y, m):
        return [False, title(month_names()[m - 1]) + " " + str(y) + " has no day " + str(d)]
    return [True, y, m, d, short]

def day_number(y, m, d):
    yy = y
    if m <= 2:
        yy = yy - 1
    yoe = yy % 400
    era = int((yy - yoe) / 400)
    mp = m + 9
    if m > 2:
        mp = m - 3
    doy = int((153 * mp + 2 - (153 * mp + 2) % 5) / 5) + d - 1
    doe = yoe * 365 + int((yoe - yoe % 4) / 4) - int((yoe - yoe % 100) / 100) + doy
    return era * 146097 + doe - 719468

def weekday(y, m, d):
    names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
    return names[(day_number(y, m, d) + 4) % 7]

def iso(y, m, d):
    ys = str(y)
    while len(ys) < 4:
        ys = "0" + ys
    ms = str(m)
    if m < 10:
        ms = "0" + ms
    ds = str(d)
    if d < 10:
        ds = "0" + ds
    return ys + "-" + ms + "-" + ds

def title(n):
    up = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    low = "abcdefghijklmnopqrstuvwxyz"
    for i in range(0, 26):
        if n[0] == low[i]:
            return up[i] + n[1:len(n)]
    return n

def words(y, m, d):
    return str(d) + " " + title(month_names()[m - 1]) + " " + str(y)

def ambiguous_days(y):
    total = 0
    other = 0
    same = 0
    for m in range(1, 13):
        for d in range(1, month_days(y, m)+1):
            total = total + 1
            if d <= 12:
                if d == m:
                    same = same + 1
                elif m <= month_days(y, d):
                    other = other + 1
    return [total, other, same]
```

## README

# P106 - Date reader

A date written in three parts can be read as day/month/year,
month/day/year or year/month/day. This program tries all three and shows
which readings give a real date and how far apart they fall. It also
counts how many days of a year are ambiguous, and writes a date so that
it cannot be misread.

- `main.eml` - the menu, the readings table, the yearly count and the
  unambiguous form
- `readings.eml` - splitting the fields, month names, years, each
  reading, day numbers, weekdays and the yearly count

How each part works:

- Fields are separated by /, -, ., commas or spaces. A month can be a
  number or a name, or its first three letters or more.
- A one- or two-digit year follows strptime's %y: 00 to 68 are 2000 to
  2068, and 69 to 99 are 1969 to 1999. A four-digit year runs from 1000 to
  9999.
- Each reading is kept or refused with its reason: no month 13, no day 31
  in April, no 29 February in 2027, or a month name where the day or the
  year goes.
- As in the corpus case `date-parse-ambiguity`, two readings can both be
  real dates: 03/04/2026 is 3 April or 4 March, 30 days apart, and
  03/04/05 has three readings, up to 729 days apart. Nothing in the
  string says which was meant.
- Day numbers count from 1970-01-01 in the Gregorian calendar (Howard
  Hinnant's days_from_civil), and give the weekday and the distances.
- The yearly count takes each day written dd/mm. A day above 12 cannot be
  read as mm/dd at all, so a reader who takes it as mm/dd gets an error.
  When the day equals the month, the date is the same either way. On the
  other 132 days the reader gets a different, valid date and no error.

What is checked: menu choices 1 to 4; three fields; each field in its
place as described; a year from 1000 to 9999 for the yearly count; the
year first, in four digits, for the unambiguous form. An empty answer
cancels.

Sessions: `sessions/basic.in` gives:
- 03/04/2026 and 12/01/2026, read two ways;
- 03/04/05, read three ways;
- 25/12/2026, read one way;
- 31/04/2026, read no way;
- 2026-04-03, 3 Apr 2026, April 3, 2026, and 05 Apr 03;
- 29/02/2027, and 07/07/2026, which reads the same both ways;
- the yearly counts for 2026 and 2024;
- 2026-04-03 and 1999-12-31 written unmistakably.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- a cancel, one field, four fields;
- a month Foo, a month Ma (too short to tell March from May), 32/13/2026,
  0/0/0, years of three digits and with a leading zero, and a month name
  last;
- yearly counts with a cancel, 99, abcd and 0999;
- the unambiguous form with a cancel, a date not year-first, 30 February
  and month 13.

Built on the verified corpus case `date-parse-ambiguity` (the order of
day and month is information that is not in the string).
