The Big Book of Small EK9 Programs

Adapted from Al Sweigart's The Big Book of Small Python Projects: 81 Easy Practice Programs (No Starch Press, 2021; copyright © 2021 Al Sweigart), which he makes freely available under a Creative Commons Attribution-NonCommercial-ShareAlike licence at inventwithpython.com/bigbookpython. Changes were made: each program was ported from Python to EK9 and remodelled in EK9's idioms.

Project 2 — Birthday Paradox

Birthday Paradox is a probability demonstration dressed up as a program. It asks how many people should be in an imaginary room, generates that many random birthdays, shows them, and says whether any two happen to fall on the same day. Then it runs the same experiment one hundred thousand times and reports what fraction of those rooms turned up a shared birthday — which, for surprisingly small groups, is a lot higher than most people guess.

Adapted from project 2 of Al Sweigart's The Big Book of Small Python Projects (No Starch Press, 2021), under CC BY-NC-SA 4.0.

A sample run

The birthdays are random, so the golden run fixes them by setting BIGBOOK_SEED=2 (from seed.txt) before answering the one prompt from input.txt, a group size of 23:

23
BIGBOOK_SEED=2 ek9 birthdayparadox.ek9 < input.txt

The typed group size is never echoed back; the program's own lines are everything below:

Birthday Paradox, by Al Sweigart
How many birthdays shall I generate? (Max 100)
Here are 23 birthdays:
Apr 4, Nov 19, Oct 29, Dec 12, Aug 29, May 11, Aug 19, Nov 4, Feb 9, Sep 19, Jul 25, Apr 14, Oct 6, Dec 22, Mar 19, May 16, Feb 19, Jan 15, Jun 24, Jul 13, Jun 25, May 28, May 28
In this simulation, multiple people have a birthday on May 28
Generating 23 random birthdays 100000 times...
In 100000 simulations of 23 people, a matching birthday happened 50744 times.
That means 23 people have a 50.74 % chance of a shared birthday.

The program

#!ek9
defines module bigbook.birthdayparadox

  defines constant

    Trials <- 100000
    MaxPeople <- 100
    LastDay <- 364
    StartOfYear <- 2001-01-01
    PercentagePlaces <- 2

  defines type

    List of Date

  defines class

    //THE input idiom of this book, copied unchanged from lib/LineInput.ek9 (the reasons are
    //there): read with next(), and every input loop also stops when ended() is true.
    LineInput
      stdin as Stdin: Stdin()
      inputEnded as Boolean: false

      next()
        <- rtn as String: stdin.next()
        if not rtn?
          inputEnded: true

      ended() as pure
        <- rtn as Boolean: Boolean(inputEnded)

      override operator ? as pure
        <- rtn as Boolean: true

    //THE INPUT AND OUTPUT: everything the player sees or types goes through here, worded by Say.
    Console
      stdout as Stdout: Stdout()
      input as LineInput: LineInput()
      say as Say: Say("en")

      Console()
        -> words as Say
        say: words

      showIntroduction()
        stdout.println(say.introduction())

      //Asks until the answer is a group size; unset only if the input ends first.
      nextGroupSize()
        <- rtn as Integer: Integer()
        stdout.println(say.askGroupSize())
        while not rtn? and not input.ended()
          entry <- Integer(input.next())
          if isGroupSize(entry)
            rtn: entry
          else
            stdout.println(say.notAGroupSize())

      showBirthdays()
        -> birthdays as List of Date
        stdout.println(say.hereAre(length birthdays))
        labels <- List() of String
        for birthday in birthdays
          labels += labelOf(birthday)
        line <- cat labels | join with withComma | collect as String
        stdout.println(line)

      showShared()
        -> birthday as Date
        stdout.println(say.shared(labelOf(birthday)))

      showNoneShared()
        stdout.println(say.noneShared())

      showSimulating()
        -> count as Integer
        stdout.println(say.simulating(count))

      showResults()
        ->
          count as Integer
          matchCount as Integer
          chance as Float

        stdout.println(say.results(count, matchCount, chance))

      private labelOf()
        -> birthday as Date
        <- rtn as String: say.birthday(monthAbbreviation(birthday), birthday.day())

      override operator ? as pure
        <- rtn as Boolean: true

  defines function

    //THE GAME: the experiment, with no input or output.

    isGroupSize() as pure
      -> entry as Integer
      <- rtn as Boolean: false
      if entry?
        rtn: entry >= 1 and entry <= MaxPeople

    //The year does not matter as long as every birthday uses the same one, so a birthday
    //is a real Date: the start of the year plus a random number of days, as in the book.
    //The Duration is built from its text, P<n>D, which keeps it as n days: P1D * n is
    //normalised into 30-day months today and lands on the wrong date (P345D is Dec 12,
    //P1D * 345 is Dec 16).
    randomBirthdays()
      ->
        count as Integer
        rng as Random
      <- rtn as List of Date: List() of Date

      for person in 1 ... count
        rtn += StartOfYear + Duration(`P${rng.next(0, LastDay)}D`)

    //The month names are data, as the book's MONTHS tuple is.
    monthAbbreviation() as pure
      -> birthday as Date
      <- rtn as String: String()
      months <- ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
      rtn: months.getOrDefault(birthday.month() - 1, "")

    //uniq drops repeated dates, so a group with a shared birthday comes out shorter.
    hasSharedBirthday() as pure
      -> birthdays as List of Date
      <- rtn as Boolean: length distinctBirthdays(birthdays) < length birthdays

    distinctBirthdays() as pure
      -> birthdays as List of Date
      <- rtn as List of Date: cat birthdays | uniq | collect as List of Date

    //The book's getMatch: the earliest birthday that occurs again later in the list, or
    //unset when every birthday is different.
    firstSharedBirthday()
      -> birthdays as List of Date
      <- rtn as Date: Date()
      later <- List() of Date
      later += birthdays
      for birthday in birthdays
        later -= birthday
        if not rtn? and later contains birthday
          rtn: birthday

    matchingGroups()
      ->
        count as Integer
        rng as Random
      <- rtn as Integer: 0

      for trial in 1 ... Trials
        if hasSharedBirthday(randomBirthdays(count, rng))
          rtn += 1

    //The percentage of the groups that shared a birthday, to two places.
    sharedChance() as pure
      -> matchCount as Integer
      <- rtn as Float: Float()
      percentage <- Float(matchCount) * 100.0 / Float(Trials)
      rtn: percentage.round(PercentagePlaces)

  defines function

    withComma() as pure
      ->
        left as String
        right as String
      <- rtn as String: `${left}, ${right}`

    //THE source of randomness, copied unchanged from lib/RandomSource.ek9: SystemRandom(), or
    //SeededRandom(seed) when BIGBOOK_SEED holds a number (run-and-diff.sh sets it from seed.txt).
    randomSource()
      <- rtn as Random: SeededRandom(Integer(EnvVars().get("BIGBOOK_SEED"))) ?: SystemRandom()

    //THE language of the words, copied unchanged from lib/Language.ek9: BIGBOOK_LANG when it
    //is set (fr, zh, ...), otherwise en.
    language()
      <- rtn as String: EnvVars().get("BIGBOOK_LANG") ?: "en"

  //THE WORDS: every word the player sees.
  defines text for "en"

    Say

      introduction()
        "Birthday Paradox, by Al Sweigart"

      askGroupSize()
        `How many birthdays shall I generate? (Max ${MaxPeople})`

      notAGroupSize()
        `Enter a number between 1 and ${MaxPeople}.`

      hereAre()
        -> count as Integer
        `Here are ${count} birthdays:`

      birthday()
        ->
          month as String
          day as Integer
        `${month} ${day}`

      shared()
        -> birthday as String
        `In this simulation, multiple people have a birthday on ${birthday}`

      noneShared()
        "In this simulation, there are no matching birthdays."

      simulating()
        -> count as Integer
        `Generating ${count} random birthdays ${Trials} times...`

      results()
        ->
          count as Integer
          matchCount as Integer
          chance as Float
        `In ${Trials} simulations of ${count} people, a matching birthday happened ${matchCount} times.
That means ${count} people have a ${chance} % chance of a shared birthday.`

  defines program

    //Birthday Paradox (Big Book project 2). A Monte Carlo estimate of how likely a group of
    //people is to share a birthday. The game decides; the Console asks and tells, in the player's
    //words. The golden replays a seeded game (seed.txt).
    BirthdayParadox()
      //A language with no words of its own is unset: ?: falls back to English.
      console <- Console(Say(language()) ?: Say("en"))
      rng <- randomSource()

      console.showIntroduction()
      if count <- console.nextGroupSize()
        birthdays <- randomBirthdays(count, rng)
        console.showBirthdays(birthdays)
        if shared <- firstSharedBirthday(birthdays)
          console.showShared(shared)
        else
          console.showNoneShared()

        console.showSimulating(count)
        matchCount <- matchingGroups(count, rng)
        console.showResults(count, matchCount, sharedChance(matchCount))

How the port works

The original's main() asks, simulates, words and prints in the same lines. The port keeps the three apart, as every port in this book does: