Updates from Jim Randell Toggle Comment Threads | Keyboard Shortcuts

  • Unknown's avatar

    Jim Randell 6:58 am on 28 September 2025 Permalink | Reply
    Tags: by: Henry C Warburton   

    Teaser 3288: Todd’s password 

    From The Sunday Times, 28th September 2025 [link] [link]

    Todd privately tells each of four of his friends a letter of the password to his phone. All their letters have a different position in the password, but they do not know the position of their letter. He gives them all a list of 12 possible passwords, one of which is correct:

    STEW, BOYS, PANS, STIG, NILE, LEER, STEM, WERE, GEMS, STAB, TEST, SPOT.

    He asks them all in turn if they know the password, and they all say no. He repeats this and gets the same response a number of times until the first friend to be asked says yes, and the rest no. Upon announcing that the first friend’s letter is in the second half of the alphabet, they all say yes.

    What is Todd’s password?

    [teaser3288]

     
    • Jim Randell's avatar

      Jim Randell 8:06 am on 28 September 2025 Permalink | Reply

      I found the logic for this puzzle quite involved, in order to ensure all the given conditions are met.

      If the password contains a letter that appears in only one of the candidate words, then whoever is given that letter will be able to declare the password immediately. And if the password is declared, the other friends will know it is one of the candidates that contains a unique letter, and if the letter they have been given appears in only one of the uniquely identifiable words, then they too will be able to declare the password.

      This Python 3 program runs in 61ms. (Internal runtime is 192µs).

      from enigma import (defaultdict, irange, inf, multiset, singleton, diff, printf)
      
      # possible passwords
      passwords = [
        "STEW", "BOYS", "PANS", "STIG", "NILE", "LEER",
        "STEM", "WERE", "GEMS", "STAB", "TEST", "SPOT"
      ]
      
      # count the number of steps in the process
      for n in irange(1, inf):
      
        # collect passwords by letters involved
        d = defaultdict(list)
        for pw in passwords:
          for x in set(pw):
            d[x].append(pw)
      
        # collect candidates identified by a unique letter
        ks = set(vs[0] for vs in d.values() if len(vs) == 1)
        if not ks: break
        printf("[{n}] {ks} can be deduced")
      
        # there must be multiple passwords that can be deduced
        # (or everyone can declare); and the process is repeated
        # "a number of times", so lets say more than 2
        if len(ks) > 1 and n > 2:
      
          # consider possible passwords that can be declared
          ss = list()
          for k in ks:
            # just one friend can deduce the password
            j = singleton(j for (j, x) in enumerate(k) if len(d[x]) == 1)
            if j is not None:
              ss.append((k, j))
              printf("[{n}] only friend {j} can deduce {k!r}; friend was told {k[j]!r}")
      
          # letters other than that told to the declared friend must be ambiguous
          if len(ss) > 1:
            # collect how many passwords use the other letters
            m = multiset()
            for (k, j) in ss:
              m.update_from_seq(set(k).difference({k[j]}))
            # check all are ambiguous
            if all(v > 1 for v in m.values()):
              # knowing the letter told to the declared friend is in N-Z gives a unique solution
              r = singleton((k, j) for (k, j) in ss if k[j] > 'M')
              if r is not None:
                # output solution
                (k, j) = r
                printf("SOLUTION: password = {k!r} [friend {j} was told {k[j]!r}]")
      
        # remove the candidates
        passwords = diff(passwords, ks)
      

      Solution: Todd’s password is: STEW.


      A manual analysis:

      At step 1 BOYS can be uniquely identified by the letter Y. So if any friend had been told Y they would be able to deduce the password. And if they did declare they knew the password, then all the others could would also know the password, as it is the only candidate that can be declared at step 1.

      But this doesn’t happen, so BOYS can be removed from the list of candidate passwords.

      At step 2, from the remaining candidate passwords, both SPOT and STAB can be uniquely identified (by O and B respectively). And if one of the friends did declare they knew the password, then if it was SPOT, whoever had been told P would also be able to declare; and if it was STAB, whoever had been told A could declare. The friends that had been told S and T still would not know which it was. Although if they were told the first friend to declare had been told a letter in the second half of the alphabet, they could them both declare SPOT.

      But this doesn’t happen, so SPOT and STAB can be removed from the list.

      At step 3, PANS can be uniquely identified (by the letters P and A). So two friends could declare immediately, and the other two could then follow.

      But this doesn’t happen, so PANS can be removed from the list.

      At step 4, NILE can be identified (by the letter N). So one friend can declare immediately, and the other friends can then follow.

      But this doesn’t happen, so NILE can be removed from the list.

      At step 5, both STIG and LEER can be uniquely identified (by I and L respectively). And if one friend declares at this stage, the other friends were told (S, T, G) or (E, E, R). None of the letters are ambiguous between the choices, and so all the other friends could immediately follow.

      But this doesn’t happen, so STIG and LEER can be removed from the list.

      At step 6, both GEMS and WERE can be identified (by G and R respectively). And if one friend declares at this stage, the other friends were told (E, M, S) or (W, E, E). So the friends that were told a letter other than E can also declare. If this leads to two friends declaring, the one remaining friend (who was told E) can also declare for GEMS; but if only one additional friend declares, the two remaining friends (who were told E) can declare for WERE.

      But this doesn’t happen, so GEMS and WERE can be removed from the list.

      At step 7, the remaining candidates are: STEW, STEM, TEST. Both STEW and STEM can be uniquely identified (by W and M respectively). And if one friend declares, then the other friends must have been told (S, T, E) in either case, so there can be no further immediate declarations.

      This does match the situation described, and then Todd reveals that the friend who declared was told a letter in the second half of the alphabet, and so the password must be STEW, and the other friends can declare.

      So this is a viable situation, and leads to an answer to the puzzle.

      But if this doesn’t happen, STEW and STEM can be removed from the list.

      At step 8, the only remaining candidate is TEST, and so all friends can declare immediately.

      But this doesn’t happen.

      So the only viable solution is if the password is STEW and the first declaration occurs at step 7, by the friend who was told W.

      Like

    • Frits's avatar

      Frits 7:11 am on 4 October 2025 Permalink | Reply

      from itertools import combinations
       
      pws = {'STEW', 'BOYS', 'PANS', 'STIG', 'NILE', 'LEER', 
             'STEM', 'WERE', 'GEMS', 'STAB', 'TEST', 'SPOT'}       
      ltrs = sorted(set(''.join(pws)))
          
      rnd = 1      
      # while there are passwords with a unique letter
      while (uniq := [(x, y[0]) for x in ltrs if len(y := [pw for pw in pws 
                                                  if x in pw]) == 1]):
        uniq_ltrs, uniq_pws = [x for x, y in uniq], [y for x, y in uniq] 
        
        # the process is repeated "a number of times", so lets say more than 2
                        
        # we need at least 2 different passwords for people not to be sure 
        # only one missing letter in the second half identifies the password
        if (rnd > 3 and len(set(uniq_pws)) > 1 and 
           (uniq_ltrs[-2] < 'N' and uniq_ltrs[-1] > 'M')): 
          ans = uniq_pws[-1]
          # the answer may not contain more than one unique letter
          if uniq_pws.count(ans) > 1: continue
          oltrs = {x for x in ans if x != uniq_ltrs[-1]}
          opws = ''.join(uniq_pws[:-1])
          # all non-unique letters in answer must be found in other passwords
          if all(ltr in opws for ltr in oltrs):
            print("answer:", ans)
        
        print(f"eliminate {(ws := set(uniq_pws))}, remaining:", pws := pws - ws)  
        rnd += 1  
      

      Like

  • Unknown's avatar

    Jim Randell 10:15 am on 26 September 2025 Permalink | Reply
    Tags:   

    Teaser 2479: [The Sultan’s coins] 

    From The Sunday Times, 28th March 2010 [link]

    The Sultan of Aurica introduces five coins, each worth a different whole number of cents less than 100, with face values equal to the value of metal used. All are circular and of the same metal, but any coin worth an odd number of cents is twice as thick as the even-valued ones. The coin showing the sultan’s head can just cover two touching coins of equal value or seven smaller coins of equal value, with six arranged around the edge, one in the middle. All of that is also true of the coin showing the sultan’s palace.

    What are the five values?

    This puzzle was originally published with no title.

    [teaser2479]

     
    • Jim Randell's avatar

      Jim Randell 10:15 am on 26 September 2025 Permalink | Reply

      The following Python program runs in 61ms. (Internal runtime is 135µs).

      from enigma import (irange, div, subsets, union, printf)
      
      # collect radius^2 -> coin value
      d = dict()
      for v in irange(1, 99):
        k = (v if v % 2 == 1 else 2 * v)
        d[k] = v
      
      # now look for k values where k/9 and k/4 are also values
      ss = list()
      for (k, v) in d.items():
        (v3, v2) = (d.get(div(k, 9)), d.get(div(k, 4)))
        if v3 and v2:
          printf("[{v} covers 6x {v3} or 2x {v2}]")
          ss.append((v, v3, v2))
      
      # find 2 sets that use exactly 5 coins between them
      for (s1, s2) in subsets(ss, size=2):
        rs = union([s1, s2])
        if len(rs) == 5:
          printf("{s1} & {s2} -> {rs}", rs=sorted(rs))
      

      Solution: The coin values are: 2, 8, 9, 18, 72 cents.

      There are 4 candidate configurations of coins where the largest can exactly cover 6 and 2 of the smaller coins.

      18 cent covers 6× 2 cent or 2× 9 cent = (18, 2, 9)
      54 cent covers 6× 6 cent or 2× 27 cent = (54, 6, 27)
      72 cent covers 6× 8 cent or 2× 18 cent = (72, 8, 18)
      90 cent covers 6× 10 cent or 2× 45 cent = (90, 10, 45)

      And we need to find 5 coins that can make two of these sets (i.e. one of the coins is shared between sets).

      The solution comes from:

      (18, 2, 9) and (72, 8, 18)

      Like

  • Unknown's avatar

    Jim Randell 10:32 am on 23 September 2025 Permalink | Reply
    Tags:   

    Teaser 2506: [Domestic chores] 

    From The Sunday Times, 3rd October 2010 [link] [link]

    Three brothers share a house in a university city. Each is capable of undertaking two of the four domestic chores required. For instance, only one knows how to vacuum and only one can dust. They have recruited two friends who can do three chores each, so now any chore can be done by three people. Leo does not wash up; in fact, only one person can both wash up and do the laundry. There is one job both Leo and Neil can do, while Keith and Neil can do two jobs together. John can vacuum; Mike cannot. Neil is one of the brothers.

    Who are the other two brothers, and who does the dusting?

    This puzzle was originally published with no title.

    [teaser2506]

     
    • Jim Randell's avatar

      Jim Randell 10:34 am on 23 September 2025 Permalink | Reply

      We can think of filling out a 5×5 binary matrix, indicating whether a person is one of the brothers, and which of the 4 tasks he can do.

      Here is a solution using the [[ SubstitutedExpression ]] solver from the enigma.py library.

      It runs in 133ms. (Internal runtime is 439µs).

      #! python3 -m enigma -rr
      
      SubstitutedExpression
      
      #     bro  vcm  dst  wsh  lnd
      #  J:  J    A    F    Q    V
      #  K:  K    B    G    R    W
      #  L:  L    C    H    S    X
      #  M:  M    D    I    T    Y
      #  N:  N    E    P    U    Z
      
      --base=2
      --distinct=""
      
      # there are 3 brothers (and 2 friends)
      "J + K + L + M + N == 3"
      
      # each brother can do exactly 2 tasks (and each friend exactly 3 tasks) [rows]
      --code="person = lambda b, *ts: sum(ts) == (2 if b else 3)"
      "person(J, A, F, Q, V)"
      "person(K, B, G, R, W)"
      "person(L, C, H, S, X)"
      "person(M, D, I, T, Y)"
      "person(N, E, P, U, Z)"
      
      # each task can be done by exactly 3 people [cols]
      --code="task = lambda *ps: sum(ps) == 3"
      "task(A, B, C, D, E)"
      "task(F, G, H, I, P)"
      "task(Q, R, S, T, U)"
      "task(V, W, X, Y, Z)"
      
      # exactly 1 brother knows how to vacuum
      "dot([J, K, L, M, N], [A, B, C, D, E]) == 1"
      
      # exactly 1 brother knows how to dust
      "dot([J, K, L, M, N], [F, G, H, I, P]) == 1"
      
      # L does not wash-up
      "S == 0"
      
      # exactly 1 person can wash-up and do the laundry
      "[Q + V, R + W, S + X, T + Y, U + Z].count(2) == 1"
      
      # there is 1 job both L and N can do
      "[C + E, H + P, S + U, X + Z].count(2) == 1"
      
      # there are 2 jobs K and N can do
      "[B + E, G + P, R + U, W + Z].count(2) == 2"
      
      # J can vacuum
      "A == 1"
      
      # M cannot vacuum
      "D == 0"
      
      # N is one of the brothers
      "N == 1"
      
      --template="(J A F Q V) (K B G R W) (L C H S X) (M D I T Y) (N E P U Z)"
      --solution=""
      --denest=-1
      --verbose="1-W"
      

      The following program can be used to give a more friendly output to the answer:

      from enigma import (SubstitutedExpression, seq2str, printf)
      
      # load the puzzle
      p = SubstitutedExpression.from_file(["{dir}/teaser2506.run"])
      
      # names
      names = ["John", "Keith", "Leo", "Mike", "Neil"]
      tasks = ["brother", "vacuum", "dust", "wash-up", "laundry"]
      results = ["JAFQV", "KBGRW", "LCHSX", "MDITY", "NEPUZ"]
      
      # solve the puzzle and format the solutions
      for s in p.solve(verbose=0):
        # output solution
        for (n, rs) in zip(names, results):
          # collect tasks
          ts = list(t for (t, v) in zip(tasks, rs) if s[v])
          printf("{n}: {ts}", ts=seq2str(ts, sep=", ", enc=""))
        printf()
      

      Solution: The other two brothers are: John and Mike. Keith, Leo and Neil can do dusting.

      The complete solution is:

      Brothers:
      John: vacuuming, laundry
      Mike: washing-up, laundry
      Neil: dusting, washing-up

      Friends:
      Keith: vacuuming, dusting, washing-up
      Leo: vacuuming, dusting, laundry

      Like

      • Frits's avatar

        Frits 1:27 pm on 23 September 2025 Permalink | Reply

        The task function could also have been used instead of the person function.

        Like

  • Unknown's avatar

    Jim Randell 7:03 am on 21 September 2025 Permalink | Reply
    Tags:   

    Teaser 3287: Ferry route 

    From The Sunday Times, 21st September 2025 [link] [link]

    A large circular sea, whose diameter is less than 300km, is served by a ferry that makes a clockwise route around half of the sea, serving the ports of Ayton, Beaton, Seaton and Deaton in that order, then back to Ayton. Deaton is diametrically opposite Ayton. Each of the four legs of its route is a straight line, two of them being the same length. The lengths of all of the legs are whole numbers of km, and they all happen to be square numbers.

    In increasing order, what are the three different leg lengths?

    [teaser3287]

     
    • Jim Randell's avatar

      Jim Randell 7:18 am on 21 September 2025 Permalink | Reply

      See: Teaser 2549.

      If the diameter of the circle is x, and the lengths of the non-diameter legs are a, b, c, then we have:

      x³ − (a² + b² + c²)x − 2abc = 0

      The following Python program runs in 62ms. (Internal runtime is 643µs).

      from enigma import (powers, inf, first, lt, subsets, pi, cb, sumsq, printf)
      
      # squares less than 300
      sqs = list(first(powers(1, inf, 2), lt(300)))
      
      # choose 3 different lengths (x is the diameter)
      for (a, b, x) in subsets(sqs, size=3):
        # duplicate one of the short lengths
        for c in (a, b):
          # check for a viable quadrilateral
          if not (x < a + b + c < pi * x): continue
          if not (cb(x) - sumsq([a, b, c]) * x - 2 * a * b * c == 0): continue
          # output solution
          printf("{a}, {b}, {x} [and {c}]")
      

      Solution: The leg lengths are 36, 49, 81 km.

      The duplicate leg length is 36 km.

      The next smallest solution is at 4× these lengths, which brings the diameter to 324 km.

      Like

      • Jim Randell's avatar

        Jim Randell 8:49 am on 22 September 2025 Permalink | Reply

        We can suppose that the non-diameter sides are (a, b, a) (i.e. c = a, and it may be the case that a > b), and then the equation can be simplified to:

        2a² = x(x − b)

        So by choosing b and x we can then straightforwardly calculate the corresponding value for a, and check it is viable (i.e. a square):

        from enigma import (powers, inf, first, lt, subsets, is_square, div, ordered, printf)
        
        # squares less than 300
        sqs = list(first(powers(1, inf, 2), lt(300)))
        
        # choose 2 different lengths (x is the diameter)
        for (b, x) in subsets(sqs, size=2):
          # a is the duplicated side
          a = is_square(div(x * (x - b), 2))
          if a is None or a not in sqs: continue
          # output solution
          (p, q) = ordered(a, b)
          printf("{p}, {q}, {x} [and {a}]")
        

        Liked by 1 person

        • Frits's avatar

          Frits 2:29 pm on 22 September 2025 Permalink | Reply

          I had come up with similar equation. You can also deduce the parity of “a”.

          Like

    • Ruud's avatar

      Ruud 10:35 am on 21 September 2025 Permalink | Reply

      import math
      import itertools
      
      for ab, bs, sd, da in itertools.product((i * i for i in range(1, 18)), repeat=4):
          try:
              angle_ab = math.asin(ab / da) * 2
              angle_bs = math.asin(bs / da) * 2
              angle_sd = math.asin(sd / da) * 2
      
              if angle_ab + angle_bs + angle_sd == math.pi:
                  print(sorted({ab, bs, sd, da}))
          except ValueError:
              ...
      

      Like

      • Jim Randell's avatar

        Jim Randell 11:38 am on 21 September 2025 Permalink | Reply

        @Ruud: How does this code ensure that two of the leg lengths are the same?

        And it is probably better to use [[ math.isclose() ]] rather than testing floats for equality using ==.

        Like

        • Ruud's avatar

          Ruud 12:55 pm on 21 September 2025 Permalink | Reply

          I just checked manually by checking that there are 3 different length. Could have added a simple test, of course.

          Yes, math.isclose would have been better, but as the equality check gave already an answer, I thought I could just refrain from isclose.

          Like

        • Ruud's avatar

          Ruud 4:14 pm on 21 September 2025 Permalink | Reply

          @Jim
          I just checked manually that there are exactly three different lengths, but a test would be better.

          Yes, math.isclose would have beeen safer.

          Here’s my updated version:

          import math
          import itertools
          
          for ab, bs, sd, da in itertools.product((i * i for i in range(1, 18)), repeat=4):
              try:
                  angle_ab = math.asin(ab / da) * 2
                  angle_bs = math.asin(bs / da) * 2
                  angle_sd = math.asin(sd / da) * 2
          
                  if math.isclose(angle_ab + angle_bs + angle_sd, math.pi) and len(sol := {ab, bs, sd, da}) == 3:
                      print(sorted(sol))
              except ValueError:
                  ...
          
          peek("done")
          

          Like

    • Alex.T.Sutherland's avatar

      Alex.T.Sutherland 10:42 am on 28 September 2025 Permalink | Reply

      TEASER 3287.....FERRY ROUTE.
      Given:-		Cyclic Quadrilateral [A B C D] with lengths [a b c d], d = diameter; 
      		               All sides are of perfect square length.
      Method:-	Using Ptolemy's Theorem relating the diagonals to the sides
      		               of the quadrilateral and that right angles are created at B & C
      		              by the diagonals the following equation is derived:-
      
      		              (d^2 - a^2) * (d^2 - c^2) = (b*d + a*c)^2
      
      		              If 'c' is made equal to 'a' the following can be  derived:-
      
      Answer:-	2*a^2  = d*(d-b);
      
      		              Choose 3 squares from the given set to satisfy this equation.
      Side Line:-	The path could be 'abad' or'aabd' but the answer is the same for the lengths.
      		              (neglecting symmetry eg 'baa' is the same as 'aab')'
      		             For my answer the sum of the lengths 'aba' or 'aab' is a perfect square.
      

      Like

  • Unknown's avatar

    Jim Randell 9:05 am on 20 September 2025 Permalink | Reply
    Tags:   

    Teaser 2491: [Sharing sections] 

    From The Sunday Times, 20th June 2010 [link] [link]

    One Sunday, 11 friends — Burchy, Chop, Grubs, Hobnob, Lanky, Mouldy, Ruffan, Shultz, Tufty, Wotfor and Zebedee — shared out The Sunday Times to read. They each got one of the following sections: Appointments, Business, Culture, Home, InGear, Magazine, Money, News Review, Sport, Style and Travel. For each person, the title of their section contained no letter of the alphabet that was also in their name.

    Who read each of the sections? (With the sections in the above order, list the initials of their readers).

    This puzzle was originally published with no title.

    [teaser2491]

     
    • Jim Randell's avatar

      Jim Randell 9:06 am on 20 September 2025 Permalink | Reply

      This is the remaining puzzle from the list of Graham Smithers “grouping” style puzzles.

      Here is a solution using the [[ grouping ]] solver from the enigma.py library.

      It runs in 65ms. (Internal runtime is 3.1ms).

      from enigma import grouping
      
      # categories
      names = (
        "Burchy", "Chop", "Grubs", "Hobnob", "Lanky", "Mouldy",
        "Ruffian", "Schultz", "Tutfy", "Wotfor", "Zebedee",
      )
      sections = (
        "Appointments", "Business", "Culture", "Home", "InGear",
        "Magazine", "Money", "NewsReview", "Sport", "Style", "Travel",
      )
      
      grouping.solve([names, sections], grouping.share_letters(0))
      

      Solution: The initials of the readers (in section order) are: B, W, H, L, S, T, G, M, Z, R, C.

      Here is the full solution:

      Burchy, Appointments
      Chop, Travel
      Grubs, Money
      Hobnob, Culture
      Lanky, Home
      Mouldy, NewsReview
      Ruffian, Style
      Schultz, InGear
      Tutfy, Magazine
      Wotfor, Business
      Zebedee, Sport

      Like

  • Unknown's avatar

    Jim Randell 10:44 am on 19 September 2025 Permalink | Reply
    Tags:   

    Teaser 2460: [Birthdays] 

    From The Sunday Times, 15th November 2009 [link]

    Seven friends whose first names were Ben, Cecil, Dion, Echo, Flor, Gobbins and Hugh had surnames Jack, Kain, Lack, Mines, Neal, O’Hara and Platts in some order.

    They were all born on different days of the week. For each person, if you look at any two of their first name, their surname and the day of their birth, then there is just one letter of the alphabet that occurs in both.

    Who was born on Monday (first name and surname)?

    This puzzle was originally published with no title.

    [teaser2460]

     
    • Jim Randell's avatar

      Jim Randell 10:45 am on 19 September 2025 Permalink | Reply

      This is the earliest of Graham Smithers “grouping” style puzzles that I have found.

      Some time ago, I added the [[ grouping ]] functionality in enigma.py specifically to solve this kind of puzzle (see: Teaser 2816), and it can be used to solve this puzzle.

      This Python program runs in 65ms. (Internal runtime is 828µs).

      from enigma import grouping
      
      firsts = ["Ben", "Cecil", "Dion", "Echo", "Flor", "Gobbins", "Hugh"]
      lasts = ["Jack", "Kain", "Lack", "Mines", "Neal", "O'Hara", "Platts"]
      days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
      
      grouping.solve([firsts, lasts, days], grouping.share_letters(1))
      

      Solution: Flor Lack was born on Monday.

      The full list is:

      Ben Kain, Tuesday
      Cecil Jack, Wednesday
      Dion Neal, Saturday
      Echo Mines, Thursday
      Flor Lack, Monday
      Gobbins Platts, Friday
      Hugh O’Hara, Sunday


      The full list of “grouping” style puzzles I’ve found is (2009 – 2019):

      Teaser 2460, Teaser 2491, Teaser 2507, Teaser 2612, Teaser 2633, Teaser 2682, Teaser 2704, Teaser 2725, Teaser 2757, Teaser 2770, Teaser 2800, Teaser 2816, Teaser 2831, Teaser 2833, Teaser 2860, Teaser 2880, Teaser 2892, Teaser 2895, Teaser 2959

      Like

    • GeoffR's avatar

      GeoffR 3:52 pm on 27 September 2025 Permalink | Reply

      An interesting experience using AI for this teaser.
      ChatGPT Plus produced an initial solution for this teaser. Then I asked it to speed up the original solution, and it produced all the code in this posting to give a run-time of less than 1 millisecond! – apart from the Timer code from the enigma.py library. I the quizzed AI why this backtracking solution was so fast and about other solution techniques. ChatGPT then reverted to the basic AI model.

      AI response below:
      —————–
      Summary for This Teaser
      ———————–
      Problem type: Matching people (forename + surname) to days with constraints.

      Applicable techniques:
      ———————
      Backtracking (fastest)
      Forward checking / constraint propagation
      Heuristic ordering (most constrained first)
      Permutation filtering / brute force
      CSP solvers
      Manual logic deduction

      Why backtracking is fastest:
      —————————
      Incremental assignments with immediate constraint checks → prunes invalid branches early.

      Recursive structure matches problem naturally.

      Can combine heuristics to further reduce search space.

      Other viable methods: Brute force, CSP solvers, logical deduction — but backtracking is the simplest and fastest in Python for this teaser.

      # ST2460 ver 2 - ChatGPT Plus
      
      from enigma import Timer
      timer = Timer('ST2460', timer='E')
      timer.start()
      
      import itertools
      
      first_names = ["Ben", "Cecil", "Dion", "Echo", "Flor",
                     "Gobbins", "Hugh"]
      surnames = ["Jack", "Kain", "Lack", "Mines", "Neal",
                  "O'Hara", "Platts"]
      days = ["Monday", "Tuesday", "Wednesday", "Thursday",
              "Friday", "Saturday", "Sunday"]
      
      # Condition: For each person, any two of (first, surname, day)
      # must share exactly one common letter.
      def valid_person(first, surname, day):
          f, s, d = set(first.lower()), set(surname.lower()), set(day.lower())
          
      def one_common(a, b):
          return len(a & b) == 1
          return one_common(f, s) and one_common(f, d) and one_common(s, d)
      
      # Precompute valid surname - day pairs for each first name
      day_options = {}
      
      for f in first_names:
          options = []
          for s in surnames:
              for d in days:
                  if valid_person(f, s, d):
                      options.append((s, d))
          day_options[f] = options
      
      # Backtracking search
      def backtrack(idx, used_surnames, used_days, assignment):
          if idx == len(first_names):
              print("Solution found:")
              for f in first_names:
                  s, d = assignment[f]
                  print(f"  {f} {s} — {d}")
              return True
          
          f = first_names[idx]
          for s, d in day_options[f]:
              if s not in used_surnames and d not in used_days:
                  assignment[f] = (s, d)
                  if backtrack(idx + 1, used_surnames | {s}, \
                      used_days | {d}, assignment):
                      return True
                  del assignment[f]
          return False
      
      backtrack(0, set(), set(), {})
      
      timer.stop()      
      timer.report()
      
      '''
      Solution found:
        Ben Kain — Tuesday
        Cecil Jack — Wednesday
        Dion Neal — Saturday
        Echo Mines — Thursday
        Flor Lack — Monday
        Gobbins Platts — Friday
        Hugh O'Hara — Sunday
        
      [ST2460] total time: 0.0004500s (450.00us)
      '''
      
      
      

      Like

      • Jim Randell's avatar

        Jim Randell 10:46 pm on 27 September 2025 Permalink | Reply

        @Geoff: The posted code doesn’t work for me (no output). The valid_person() and one_common() functions don’t look right.

        Like

    • GeoffR's avatar

      GeoffR 10:59 pm on 27 September 2025 Permalink | Reply

      @ Jim: It worked OK on my laptop, but not when I tested it on my desktop.
      In view of your reservations, I think it best to remove my posting for now.
      Will have another look at the AI code.

      Like

      • GeoffR's avatar

        GeoffR 10:03 am on 28 September 2025 Permalink | Reply

        Maybe it was my error copying, reformatting, or posting the original code.

        Errors were found today by ChatGPT on re- submitting the code to it with a request to find any errors. Here is AI’s reply and the updated code:

        Mistakes:

        1) valid_person defines f, s, d but doesn’t actually return a result.

        2) The return inside one_common after the first return is unreachable.

        3)The logic for checking conditions belongs inside valid_person, not one_common.

        
        # ST 2460 - ChatGPT_update 28-9-25
        
        from enigma import Timer
        timer = Timer('ST2460', timer='E')
        timer.start()
        
        import itertools
        
        first_names = ["Ben", "Cecil", "Dion", "Echo", "Flor",
                       "Gobbins", "Hugh"]
        surnames = ["Jack", "Kain", "Lack", "Mines", "Neal",
                    "O'Hara", "Platts"]
        days = ["Monday", "Tuesday", "Wednesday", "Thursday",
                "Friday", "Saturday", "Sunday"]
        
        # Condition: For each person, any two of (first, surname, day)
        # must share exactly one common letter.
        def one_common(a, b):
            return len(a & b) == 1
        
        def valid_person(first, surname, day):
            f, s, d = set(first.lower()), set(surname.lower()), set(day.lower())
            return one_common(f, s) and one_common(f, d) and one_common(s, d)
        
        # Precompute valid surname - day pairs for each first name
        day_options = {}
        
        for f in first_names:
            options = []
            for s in surnames:
                for d in days:
                    if valid_person(f, s, d):
                        options.append((s, d))
            day_options[f] = options
        
        # Backtracking search
        def backtrack(idx, used_surnames, used_days, assignment):
            if idx == len(first_names):
                print("Solution found:")
                for f in first_names:
                    s, d = assignment[f]
                    print(f"  {f} {s} — {d}")
                return True
            
            f = first_names[idx]
            for s, d in day_options[f]:
                if s not in used_surnames and d not in used_days:
                    assignment[f] = (s, d)
                    if backtrack(idx + 1, used_surnames | {s}, \
                        used_days | {d}, assignment):
                        return True
                    del assignment[f]
            return False
        
        backtrack(0, set(), set(), {})
        
        timer.stop()      
        timer.report()
        
        '''
        Solution found:
          Ben Kain — Tuesday
          Cecil Jack — Wednesday
          Dion Neal — Saturday
          Echo Mines — Thursday
          Flor Lack — Monday
          Gobbins Platts — Friday
          Hugh O'Hara — Sunday
        [ST2460] total time: 0.1103383s (110.34ms)
        '''
        
        
        

        Like

  • Unknown's avatar

    Jim Randell 1:57 pm on 16 September 2025 Permalink | Reply
    Tags: ,   

    Teaser 2513: [Golf ball stacking] 

    From The Sunday Times, 21st November 2010 [link] [link]

    At the golf ball factory 10,000 balls are stored in a stack for some weeks to harden. The stack has several layers, the top layer being a single row of balls. The second layer has two rows, each with one ball more than the top row. The third layer has three rows each with two balls more than the top row, and so on.

    Of the several shapes of stack possible, the one used takes up the smallest floor area.

    How many balls are on the bottom layer?

    This puzzle was originally published with no title.

    [teaser2513]

     
    • Jim Randell's avatar

      Jim Randell 1:57 pm on 16 September 2025 Permalink | Reply

      If we consider a stack that is k layers deep and has a line of n balls along the top.

      First we see that the number of balls in the base layer is: A = k(n + k − 1).

      And we can chop a slice one ball thick from the end to get tri(k) balls, and we can do this n times (once for each ball on the top layer).

      The the number of “extra” ball in each layer is: 1×0, 2×1, 3×2, 4×3, …, k×(k − 1).

      So in an arrangement with k layers we can calculate a value of n that would give 10,000 balls in total, and if we get an integer out of the calculation, then this is a viable arrangement.

      This Python program runs in 68ms. (Internal runtime is 56µs).

      from enigma import (Accumulator, irange, inf, tri, printf)
      
      # total number of balls
      T = 10000
      
      # collect minimal base
      sol = Accumulator(fn=min)
      
      # consider increasing numbers of layers, k
      x = 0  # extra balls to complete the pile
      for k in irange(1, inf):
        # add in the extra balls
        x += k * (k - 1)
        # how many repeats of tri(k) can we make?
        (n, r) = divmod(T - x, tri(k))
        if n < 1: break
        if r != 0: continue
        # calculate area of base
        A = k * (n + k - 1)
        # output configuration
        printf("[{k} layers -> n={n} base={A}]")
        sol.accumulate(A)
      
      # output solution
      printf("minimal base = {sol.value}")
      

      Solution: There are 984 balls in the bottom layer.

      There are 24 layers:

      1: 1×18
      2: 2×19
      3: 3×20

      23: 23×40
      24: 24×41 = 984 balls

      We can check the total number of balls is 10,000, by adding the number of balls in each layer:

      >>> dot(irange(1, 24), irange(18, 41))
      10000
      

      We can determine a formula for the total number of balls T[n, k], starting with a line of n balls and constructing k layers:

      T[n, k] = n × tri(k) + (0×1 + 1×2 + … + (k − 1)×k)
      T[n, k] = n × k(k + 1)/2 + (k − 1)k(k + 1)/3
      T[n, k] = k(k + 1)(3n + 2k − 2)/6

      Like

  • Unknown's avatar

    Jim Randell 7:08 am on 14 September 2025 Permalink | Reply
    Tags:   

    Teaser 3286: Water stop 

    From The Sunday Times, 14th September 2025 [link] [link]

    Chuck and his brother live on a long straight road that runs from west to east through their ranches. Chuck’s ranch is 13 km west of his brother’s. The two brothers often go from one ranch to the other on horseback, but go via a nearby river so that their horses may be watered. The shortest route via the river consists of two straight sections, one being 11 km longer than the other. The point at which they reach the river is 6 km north of the road.

    What is the total length of the route between the ranches that goes via the river?

    [teaser3286]

     
    • Jim Randell's avatar

      Jim Randell 7:26 am on 14 September 2025 Permalink | Reply

      Solving a couple of simultaneous quadratic equations gives an unlikely looking answer:

      Suppose the the ranches are at C and B, and the water is at W, 6 km north of a point X on the road.

      (We can assume the distance BW is greater than CW, if it is not the situation is a reflection of this).

      Then we have two right angled triangles:

      CXW: horizontal = y; vertical = 6; hypotenuse = x
      BXW: horizontal = 13 − y; vertical = 6; hypotenuse = x + 11

      And the required answer is the sum of the two hypotenuses (= 2x + 11).

      This then gives rise to two equations:

      CXW: x² = y² + 6²
      BXW: (x + 11)² = (13 − y)² + 6²

      The equations can be solved manually, but here is a program that uses a numerical solver.

      This Python program runs in 70ms. (Internal runtime is 145µs).

      from enigma import (hypot, find_zero, printf)
      
      # calculate values for x from each triangle
      fx1 = lambda y: hypot(y, 6)
      fx2 = lambda y: hypot(13 - y, 6) - 11
      
      # calculate values of x from each triangle
      # and return the difference
      f = lambda y: fx1(y) - fx2(y)
      
      # find solutions when the difference is zero
      r = find_zero(f, -100, 100)
      y = r.v
      x = fx1(y)
      d = x + (x + 11)
      printf("d={d:.3f} [x={x:.3f} y={y:.3f}]")
      

      Solution: The total length of the route via the river is 26 km.

      The river is 7.5 km from one ranch, and 18.5 km from the other. So the total journey is twice as far as following the road.

      Or we can mirror the diagram (so that X is 4.5 km beyond B) to give a second scenario:


      I think the puzzle text would have been less confusing if the brothers had just met for a picnic at W. Each travelling via a straight track from their respective ranch; one travelling 11 km further than the other. What was the total distance to the picnic spot travelled by the brothers?

      Like

      • Jim Randell's avatar

        Jim Randell 10:22 am on 14 September 2025 Permalink | Reply

        Or a similar approach using 2D geometry routines from enigma.py:

        from enigma import (point_dist, find_values, printf)
        
        # positions for C and B
        (C, B) = ((0, 0), (13, 0))
        # and the river is at W = (x, 6)
        
        # calculate difference between distances CW, BW
        def f(x):
          W = (x, 6)
          return abs(point_dist(C, W) - point_dist(B, W))
        
        # find solutions when the difference is 11
        for r in find_values(f, 11, -100, 100):
          x = r.v
          W = (x, 6)
          (d1, d2) = (point_dist(C, W), point_dist(B, W))
          d = d1 + d2
          printf("d={d:.3f} [W=({x:.3f}, 6); d1={d1:.3f} d2={d2:.3f}]")
        

        Like

      • Jim Randell's avatar

        Jim Randell 1:20 pm on 14 September 2025 Permalink | Reply

        And here is an exact solution, that uses the [[ Polynomial ]] class from the enigma.py library, to derive a quadratic equation, which can then be solved to give the required answer.

        from enigma import (Rational, Polynomial, sq, printf)
        
        Q = Rational()
        q12 = Q(1, 2)  # q12 = 0.5 will also work
        
        # calculate area^2 of the triangle using: (1/2) base . height
        A2 = sq(q12 * 13 * 6)
        
        # sides of the triangle (as polynomials)
        (a, b, c) = (Polynomial("x"), Polynomial("x + 11"), 13)
        
        # polynomial for area^2 of the triangle using Heron's formula
        s = (a + b + c) * q12
        p = s * (s - a) * (s - b) * (s - c)
        
        # find solutions of p(x) = A2 for positive x
        for x in p.roots(A2, domain='F', include='+'):
          # calculate total distance
          d = x + (x + 11)
          printf("d={d} [x={x}]")
        

        The program determines that the required solution is derived from the positive solution of the following quadratic equation (which it then solves):

        12x² + 132x − 144 = 1521

        (2x − 15)(2x + 37) = 0

        x = 7.5

        Liked by 1 person

    • Ruud's avatar

      Ruud 10:11 am on 14 September 2025 Permalink | Reply

      @Jim
      I don’t know why you say that you get an unlikely answer.
      The negative y just means that the point W is west of C (or east of B). And then it all fits.

      I have the following solution:

      import sympy as sp
      
      l = sp.symbols("l", real=True)
      eq = sp.Eq(sp.sqrt(((l + 11) / 2) ** 2 - 36) - 13, sp.sqrt(((l - 11) / 2) ** 2 - 36))
      print(sp.solveset(eq, l, domain=sp.S.Reals))
      

      Liked by 1 person

      • Jim Randell's avatar

        Jim Randell 11:43 am on 14 September 2025 Permalink | Reply

        @ruud: I said it looks unlikely, because the puzzle text can be read to imply that the brothers do not follow the road because the horses would have to go for 13 km without water.

        Like

        • Ruud's avatar

          Ruud 11:59 am on 14 September 2025 Permalink | Reply

          I didn’t read it like that.

          Like

      • Ruud's avatar

        Ruud 3:41 pm on 14 September 2025 Permalink | Reply

        The advantage of sympy over enigma.find_zero is that sympy results in an exact answer, whereas find_zero is an approximation using a golden section search.
        But of courae, sympy can’t solve arbitary expressions.

        Like

        • Jim Randell's avatar

          Jim Randell 4:14 pm on 14 September 2025 Permalink | Reply

          @Ruud: True. Although if we substitute the answer found by my programs using [[ find_zero() ]] into the equations we see that they are indeed exact answers. And the code runs over 1000× faster than using SymPy.

          But I also wrote a program that gives an exact answer using the [[ Polynomial() ]] class. And it still runs many times faster than using SymPy. (It generates a quadratic equation, and then solves it using the quadratic formula).

          Like

          • Ruud's avatar

            Ruud 4:28 pm on 14 September 2025 Permalink | Reply

            Thanks for the clear explanation.

            Like

    • Alex T Sutherland's avatar

      Alex T Sutherland 3:45 pm on 17 September 2025 Permalink | Reply

      Problem Solution:-    Using Cosine Rule and Quadratic Equation. 
      Triangle:-            Obtuse. Sides 'a' , 'b' = 'a'+11, & 13.
      Method:-              Solve for side 'a' using the Cosine Rule.
                    ie      b^2 =      [a^2 + 13^2 - 2*a*13*cos(B)]; B =angle oppsite side 'b'.
                            (a+11)^2 = [    "                                                             ];.........(1)
      
                            -cos(B) is determined in terms of 'a' from the small 
                             right angled triangle.
      
                             -cos(B) = sqrt(1- (6/a)^2);                                 ;........(2)
                              
                             From (1) & (2) integer coefficients (m n p) can be
                             determined for the qudratic m*(a*a) + n*a + p = 0;
                             Solve the quadratic for 'a'. May use a roots function
                             or the standard equation.
      
      Problem Answer:-       2*a + 11;
      
      Time:-                 The following were those obtained  from a 15+ years old PC.  
                                         (Running Non Python).
      
      Elapsed time is 0.007039 seconds.    Jim Randell's initial solution.
      
      Elapsed time is 0.000216 seconds.    Above method using a roots solver.
      
      Elapsed time is 0.000102 seconds.    Above method using the standard equation. 
      
      Code length:-         1 line; Maybe 2 in specifing the coefficients.           
      
      SideLine:-            The sum of the digits in the coefficients is 1 more 
                                              than the problem answer.
      

      Like

  • Unknown's avatar

    Jim Randell 8:40 am on 12 September 2025 Permalink | Reply
    Tags:   

    Teaser 2507: [Favourite foods] 

    From The Sunday Times, 10th October 2010 [link] [link]

    Six friends have the first names Bubba, Chris, Derek, Emiren, Finsan and Gilson. They each have a different favourite food — apple, carrot, pear, pinto bean, raspberry and watermelon.

    No two friends have birthdays in the same month. For each friend, any two from their name, their favourite food and their month of birth have just one letter of the alphabet in common.

    In the order of names given above, what are their favourite foods?

    This puzzle was originally published with no title.

    [teaser2507]

     
    • Jim Randell's avatar

      Jim Randell 8:41 am on 12 September 2025 Permalink | Reply

      This is another puzzle that can be solved directly with the [[ grouping ]] solver from the enigma.py library.

      The following Python program runs in 66ms. (Internal runtime is 1.4ms).

      from enigma import grouping
      
      names = ["Bubba", "Chris", "Derek", "Emiren", "Finsan", "Gilson"]
      foods = ["apple", "carrot", "pear", "pinto bean", "raspberry", "watermelon"]
      months = [
        "January", "February", "March", "April", "May", "June", "July",
        "August", "September", "October", "November", "December",
      ]
      
      grouping.solve([names, foods, months], grouping.share_letters(1))
      

      Solution: The favourite foods are: watermelon, pear, pinto bean, carrot, apple, raspberry.

      The complete solution is:

      Bubba, watermelon, July
      Chris, pear, August
      Derek, pinto bean, March
      Emiren, carrot, May
      Finsan, apple, November
      Gilson, raspberry, June

      Like

    • Frits's avatar

      Frits 2:30 pm on 12 September 2025 Permalink | Reply

      names = ["Bubba", "Chris", "Derek", "Emiren", "Finsan", "Gilson"]
      ns = [x.lower() for x in names] 
      foods = ["apple", "carrot", "pear", "pinto bean", "raspberry", "watermelon"]
      fs = [x.strip() for x in foods]
      months = [
        "January", "February", "March", "April", "May", "June", "July",
        "August", "September", "October", "November", "December",
      ]
      ms = [x.lower() for x in months] 
       
      # does sequence <x> share exactly one item with all sequences in <g>
      share1 = lambda x, g: all(len(set(x).intersection(z)) == 1 for z in g)
      
      # pick one value from each entry of a <k>-dimensional dictionary <dct>
      # so that all elements in the <k> values are different
      def pickOneFromEach(k, dct, seen=set(), ss=[]):
        if k == 0:
           yield ss
        else:
          for vs in dct[k - 1]:
            # values in vs may not have been used before
            if seen.isdisjoint(vs):
              yield from pickOneFromEach(k - 1, dct, seen | set(vs), ss + [vs])
      
      # dictionary of food, month per name index
      d = {i: [(fo, mo) for fo in fs if share1(fo, [na]) for mo in ms
               if share1(mo, [na, fo])] for i, na in enumerate(ns)}
      
      for s in pickOneFromEach(len(names), d):
        for i, (f, m) in enumerate(s[::-1]):
          print(f"{names[i]}: {foods[fs.index(f)]}, {months[ms.index(m)]}")
        print()  
      

      Like

  • Unknown's avatar

    Jim Randell 7:06 am on 7 September 2025 Permalink | Reply
    Tags:   

    Teaser 3285: Daughters’ ages 

    From The Sunday Times, 7th September 2025 [link] [link]

    “How old are your five daughters?” Ignacio asked me.

    “Considering their ages as whole numbers, they are all under 20, some of them are the same, and they add up to a prime number”, I answered. “Also, if you were to choose any two of them, no matter which two, their ages would have a common divisor greater than 1”.

    “I can’t work out how old they are”, complained Ignacio.

    “And you still couldn’t even if I told you the sum of all the ages. However, if I told you how old the youngest and oldest are, then you would be able to work out all the ages”, I responded.

    “That’s great, because I now know how old all your daughters are!” Ignacio joyfully said after a pause.

    In increasing order, what are the five ages?

    [teaser3285]

     
    • Jim Randell's avatar

      Jim Randell 7:16 am on 7 September 2025 Permalink | Reply

      (See also: Teaser 2971).

      This is a job for the [[ filter_unique() ]] function from the enigma.py library.

      The following Python program runs in 70ms. (Internal runtime is 4.0ms).

      from enigma import (irange, is_prime, gcd, filter_unique, item, printf)
      
      # generate possible collections of ages
      def generate(k, min_v, max_v, ns=list()):
        # are we done?
        if k == 0:
          # there are duplicate ages; sum is prime
          if len(set(ns)) < len(ns) and is_prime(sum(ns)):
            yield ns
        elif k > 0:
          for n in irange(min_v, max_v):
            if all(gcd(n, x) > 1 for x in ns):
              yield from generate(k - 1, n, max_v, ns + [n])
      
      # candidate solutions
      ss = list(generate(5, min_v=2, max_v=19))
      
      # the sum is ambiguous
      ss = filter_unique(ss, sum).non_unique
      
      # but also knowing the (youngest, eldest) is unique
      ss = filter_unique(ss, item(0, -1)).unique
      
      # output solution(s)
      for ns in ss:
        printf("ages = {ns}")
      

      Solution: The ages are: 6, 10, 15, 15, 15.

      There are 14 sets of ages that meet the initial condition.

      This is reduced to 9 sets that have an ambiguous sum.

      And then further reduced to a single candidate that can be uniquely identified by the lowest and highest ages.


      The possible sets of ages are (grouped by sum):

      (6, 6, 6, 10, 15) = 43
      (6, 6, 10, 10, 15) = 47
      (6, 10, 10, 12, 15) = 53
      (10, 10, 15, 18, 18) = 71
      (10, 15, 18, 18, 18) = 79

      (6, 10, 10, 15, 18) = 59
      (10, 10, 12, 12, 15) = 59
      (6, 10, 15, 15, 15) = 61 [*]
      (10, 12, 12, 12, 15) = 61
      (6, 10, 15, 18, 18) = 67
      (10, 12, 12, 15, 18) = 67
      (10, 12, 15, 15, 15) = 67
      (10, 12, 15, 18, 18) = 73
      (10, 15, 15, 15, 18) = 73

      The first group of 5 have a unique sum, so if we were told the sum we could determine the ages. The second group of 9 have ambiguous sums, so (without knowing the value of the sum) the ages must be in this group.

      We are subsequently told that if we knew the youngest and oldest ages we could determine them all. This is only possible if these ages are 6 and 15 (all other pairs give multiple sets of ages), and so the set marked [*] gives the required solution.

      Like

      • Jim Randell's avatar

        Jim Randell 9:03 am on 10 September 2025 Permalink | Reply

        Here is a more efficient algorithm to generate candidate sets of ages, and this leads to a slightly faster version of the program.

        In the [[ generate() ]] function we generate sequences of distinct ages with length 1 – 4, and then choose duplicate ages to bring the total up to 5 ages.

        It has an internal runtime of 2.0ms.

        from enigma import (irange, is_prime, gcd, subsets, filter_unique, item, printf)
        
        # generate possible collections of ages
        def generate(k, min_v, max_v, ns=list()):
          if len(ns) > 0 and k > 0:
            # choose k duplicate ages
            for xs in subsets(ns, size=k, select='R', fn=list):
              rs = sorted(ns + xs)
              if is_prime(sum(rs)):
                yield rs
          if k > 1:
            for n in irange(min_v, max_v):
              if all(gcd(n, x) > 1 for x in ns):
                yield from generate(k - 1, n + 1, max_v, ns + [n])
        
        # candidate solutions
        ss = list(generate(5, min_v=2, max_v=19))
        
        # the sum is ambiguous
        ss = filter_unique(ss, sum).non_unique
        
        # but also knowing the (youngest, eldest) is unique
        ss = filter_unique(ss, item(0, -1)).unique
        
        # output solution(s)
        for ns in ss:
          printf("ages = {ns}")
        

        Like

    • Frits's avatar

      Frits 10:24 am on 7 September 2025 Permalink | Reply

      from enigma import gcd, is_prime
      from collections import defaultdict
      
      # generate combinations of <k> ages
      def solve(k, ns, t=0, ss=[]):
        if k == 0:
          # some of them are the same
          if len(set(ss)) != len(ss) and is_prime(t):
            yield ss
        else:
          for i, n in enumerate(ns):
            # any two ages have a common divisor greater than 1
            if not ss or all(gcd(a, n) > 1 for a in set(ss)):
              yield from solve(k - 1, ns[i:], t + n, ss + [n])
      
      totals, seen = set(), set()
      d = defaultdict(list)
      # generate all combinations of 5 ages (at least one of them is odd)
      for ages in solve(5, range(3, 20)):
        if (t := sum(ages)) in seen:
          totals.add(t)
        seen.add(t)  
        d[(ages[0], ages[-1])] += [(t, ages)]
      
      # filter ages if you know the youngest and oldest
      for (y, o), vs in d.items():
        sols = [ages for t, ages in vs if t in totals]
        if len(sols) == 1:
          print(f"answer: {sols[0]}")
      

      Like

  • Unknown's avatar

    Jim Randell 4:44 pm on 4 September 2025 Permalink | Reply
    Tags:   

    Teaser 2332: Schoolwork 

    From The Sunday Times, 3rd June 2007 [link]

    Five girls were each given £4 to spend on rubbers, pencils and pens (not necessarily all three). Rubbers cost 20p each, pencils 30p, pens 40p. They each bought 12 items, spending the entire £4.

    Sue bought the same number of pencils as Tina did rubbers. Tina had as many pens as Ursula had pencils. Viola had as many rubbers as Wendy had pens. And just one girl had the same number of pens as Tina had pencils.

    How many of each item did Ursula buy?

    [teaser2332]

     
    • Jim Randell's avatar

      Jim Randell 4:45 pm on 4 September 2025 Permalink | Reply

      Here is a solution using the [[ SubstitutedExpression ]] solver from the enigma.py library.

      It runs in 129ms. (Internal runtime of the generated program is 1.5ms).

      #! python3 -m enigma -rr
      
      SubstitutedExpression
      
      #    rb  pl  pn
      # S:  A   B   C
      # T:  D   E   F
      # U:  G   H   I
      # V:  J   K   L
      # W:  M   N   P
      #
      --distinct=""
      --base=13  # allow quantities 0-12
      
      # each girl spent 400p and bought 12 items
      --code="girl = lambda rb, pl, pn: (rb + pl + pn == 12) and (2 * rb + 3 * pl + 4 * pn == 40)"
      "girl(A, B, C)"
      "girl(D, E, F)"
      "girl(G, H, I)"
      "girl(J, K, L)"
      "girl(M, N, P)"
      
      # equivalences
      "B = D"  # S.pl = T.rb
      "F = H"  # T.pn = U.pl
      "J = P"  # V.rb = W.pn
      
      # "just one girl bought the same number of pens as T did pencils"
      "[C, F, I, L, P].count(E) == 1"
      
      --template="(A B C) (D E F) (G H I) (J K L) (M N P)"
      --solution=""
      
      --answer="(G, H, I)"
      

      Solution: Ursula bought: 1 rubber; 6 pencils; 5 pens.

      The full solution is:

      S: 3 rubbers; 2 pencils; 7 pens
      T: 2 rubbers; 4 pencils; 6 pens
      U: 1 rubber; 6 pencils; 5 pens
      V: 4 rubbers; 0 pencils; 8 pens
      W: 0 rubbers; 8 pencils; 4 pens

      Like

      • Jim Randell's avatar

        Jim Randell 11:24 am on 5 September 2025 Permalink | Reply

        Here is a solution using the [[ choose() ]] function from the enigma.py library.

        It runs in 70ms (with an internal runtime of 387µs).

        from enigma import (Record, decompose, choose, icount_exactly as count, nl, printf)
        
        # look for combinations of 12 items with a cost of 400p
        vs = list()
        for (rb, pl, pn) in decompose(12, 3, increasing=0, sep=0, min_v=0):
          if 2 * rb + 3 * pl + 4 * pn == 40:
            vs.append(Record(rb=rb, pl=pl, pn=pn))
        
        # selection functions (in order) for each girl
        fns = [
          None,  # S
          (lambda S, T: S.pl == T.rb),  # T
          (lambda S, T, U: T.pn == U.pl),  # U
          None,  # V
          (lambda S, T, U, V, W: V.rb == W.pn),  # W
        ]
        
        # find candidate solutions
        for (S, T, U, V, W) in choose(vs, fns):
          # "just one girl bought the same number of pens as T did pencils"
          if not count((S, T, U, V, W), (lambda x, t=T.pl: x.pn == t), 1): continue
          # output solution
          printf("S: {S}{s}T: {T}{s}U: {U}{s}V: {V}{s}W: {W}", s=nl)
          printf()
        

        Like

    • Frits's avatar

      Frits 6:36 pm on 4 September 2025 Permalink | Reply

      from itertools import product
      
      N, T = 12, 400
      pa, pb, pc = 20, 30, 40
      
      # each girl spent 400p and bought 12 items and
      # bought <a> rubbers, <b> pencils and <c> pens
       
      # quantity options for the girls    
      sols = [(a, b, c) for a in range(min(N, T // pa) + 1)
              for b in range(min(N - a, (T - a * pa) // pb) + 1)
              if T - pa * a - pb * b == pc * (c := N - a - b)]   
      
      # determine quantities for all girls
      for S, T, U, V, W in product(sols, repeat=5):
        # Sue bought the same number of pencils as Tina did rubbers
        if S[1] != T[0]: continue
        # Tina had as many pens as Ursula had pencils
        if T[2] != U[1]: continue
        # Viola had as many rubbers as Wendy had pens
        if V[0] != W[2]: continue
        # just one girl bought the same number of pens as T did pencils
        if [x[2] for x in (S, T, U, V, W)].count(T[1]) != 1: continue
        print("answer:", U)
      

      Like

    • Ruud's avatar

      Ruud 7:43 am on 5 September 2025 Permalink | Reply

      import types
      
      combinations = [
          types.SimpleNamespace(rubbers=rubbers, pencils=pencils, pens=pens)
          for rubbers in range(20)
          for pencils in range(14)
          for pens in range(11)
          if pens * 40 + pencils * 30 + rubbers * 20 == 400 and pens + pencils + rubbers == 12
      ]
      
      for s in combinations:
          for t in combinations:
              if s.pencils == t.rubbers:
                  for u in combinations:
                      if t.pens == u.pencils:
                          for v in combinations:
                              for w in combinations:
                                  if v.rubbers == w.pens:
                                      if sum(girl.pens == t.pencils for girl in (s, t, u, v, w)) == 1:
                                          for name in "Sue Tina Ursula Viola Wendy".split():
                                              print(f"{name:7} {locals()[name[0].lower()]}")
      

      Like

  • Unknown's avatar

    Jim Randell 1:39 pm on 2 September 2025 Permalink | Reply
    Tags:   

    Teaser 2488: [Birthdays] 

    From The Sunday Times, 30th May 2010 [link] [link]

    Each of George’s and Martha’s five great-grandchildren celebrates a single-digit birthday today. “If you add their ages and reverse the digits of the total”, Martha said, “you get a two-digit number divisible by Andrew’s age and by none of the others'”. “That pattern continues”, George added. “Eliminate Andrew and the same is true of the other four, giving a number divisible by Brian’s age and none of the other three. If you eliminate Brian, the same is true for the other three with Colin’s age. Eliminating Colin, this works for the other two with David’s age”.

    How old are the five children?

    This puzzle was originally published with no title.

    [teaser2488]

     
    • Jim Randell's avatar

      Jim Randell 1:41 pm on 2 September 2025 Permalink | Reply

      I often forget about the [[ choose() ]] function in the enigma.py library (see: Tantalizer 482), but this is a good chance to use it. And this gives us a nice compact program.

      The ages must all be different, as the first reversed sum formed must be divisible by A, but none of the others. Hence A is different from all the others. Similarly the second reversed sum is divisible by B, but not C, D, E. Hence B is different from all the others. And so on. So we can use the [[ distinct=1 ]] parameter to the [[ choose() ]] function.

      It wasn’t completely clear if the “2-digit” condition was meant to apply to all the reversed sums, or just the first. I applied it to all of them, although it doesn’t change the answer if this condition is ignored completely.

      This Python program runs in 62ms. (Internal runtime is 326µs).

      from enigma import (irange, choose, nrev, printf)
      
      # check a set of numbers
      def fn(*ns):
        # reverse the sum
        r = nrev(sum(ns))
        # must be a 2 digit number, divisible by just the last number
        return (9 < r < 100 and r % ns[-1] == 0 and all(r % n > 0 for n in ns[:-1]))
      
      # choose 5 single digit ages
      for (E, D, C, B, A) in choose(irange(1, 9), [None, fn, fn, fn, fn], distinct=1):
        printf("A={A} B={B} C={C} D={D} E={E}")
      

      Solution: The ages are: Andrew = 2; Brian = 8; Colin = 3; David = 7; E = 5.

      We don’t know the name of the fifth child, but it may well start with an E.

      Summing the five ages and reversing gives:

      2 + 8 + 3 + 7 + 5 = 25 → 52

      And 52 is divisible by 2, but not 8, 3, 7, 5.

      With the 4 ages B – E:

      8 + 3 + 7 + 5 = 23 → 32

      And 32 is divisible by 8, but not 3, 7, 5.

      With the 3 ages C – E:

      3 + 7 + 5 = 15 → 51

      And 51 is divisible by 3, but not 7, 5.

      With the 2 ages D – E:

      7 + 5 = 12 → 21

      And 21 is divisible by 7, but not 5.

      Like

    • Frits's avatar

      Frits 3:31 pm on 2 September 2025 Permalink | Reply

      from enigma import SubstitutedExpression
      
      def check(*s):
        if (rsum := int(str(sum(s))[::-1])) % s[-1] or not (9 < rsum < 100): 
          return False
        return all(rsum % x for x in s[:-1])
        
      # the alphametic puzzle
      p = SubstitutedExpression(
        [
          "check(E, D)",
          "check(E, D, C)",
          "check(E, D, C, B)",
          "check(E, D, C, B, A)",
        ],
        answer="(A, B, C, D, E)",
        env=dict(check=check),
        d2i=dict([(1, "BCDE")]),
        digits=range(1, 10),
        verbose=0,    # use 256 to see the generated code
      )
      
      # print answers
      for ans in p.answers():
        print(f"{', '.join((x + ': ' + y 
                for x, y in zip('ABCDE', [str(a) for a in ans])))}")
      

      Like

    • GeoffR's avatar

      GeoffR 8:37 pm on 2 September 2025 Permalink | Reply

      from itertools import permutations
      
      for a, b, c, d, e in permutations(range(1, 10), 5):
        fg = a + b + c + d + e
        f, g = fg // 10, fg % 10
        if not f > 0 and g > 0:
          continue
        gf = 10*g + f
        if gf % a == 0 and all (gf % x != 0 for x in (b, c, d, e)):
              
          # Ekiminate Andrew
          hi = b + c + d + e
          h, i = hi // 10, hi % 10
          if not h > 0 and i > 0:
            continue
          ih = 10*i + h
              
          # Eliminate Brian
          if ih % b == 0 and all( ih % x != 0 for x in (c, d, e)):
            jk = c + d + e
            j, k = jk // 10, jk % 10
            if not j > 0 and k > 0:
              continue
            kj = 10*k + j
              
            # Eliminate Colin
            if kj % c == 0 and all(kj % x != 0 for x in (d, e)):
              Lm = d + e
              L, m = Lm // 10, Lm % 10
              if not L > 0 and m > 0:
                continue
              mL = 10*m + L
      
              # Eliminate David
              if mL % d == 0 and mL % e != 0: 
                print(f" A = {a}, B = {b}, C = {c}, D = {d}, E = {e}")
      
      # A = 2, B = 8, C = 3, D = 7, E = 5 
      
      

      Like

    • Ruud's avatar

      Ruud 7:57 am on 4 September 2025 Permalink | Reply

      import istr
      
      def check(ages, i):
          return [sum(ages[i:])[::-1].is_divisible_by(age) for age in ages[i:]] == [True] + (4 - i) * [False]
      
      for ages in istr.permutations("123456789", 5):
          if all(check(ages, i) for i in range(4)):
              print(ages)
      

      Like

    • Ruud's avatar

      Ruud 2:56 pm on 4 September 2025 Permalink | Reply

      As a one-liner:

      import istr
      
      print(
          [
              ages
              for ages in istr.permutations("123456789", 5)
              if all(all(sum(ages[i:])[::-1].is_divisible_by(ages[j]) == (j == i) for j in range(i, 5)) for i in range(4))
          ]
      )
      

      Like

  • Unknown's avatar

    Jim Randell 6:46 am on 31 August 2025 Permalink | Reply
    Tags:   

    Teaser 3284: Cricket square 

    From The Sunday Times, 31st August 2025 [link] [link]

    We recently played a thrilling cricket match against the league leaders. Each team had two innings alternately (our opponents going first) and the combined total of runs scored in each innings determined the winner. Curiously both our opponents’ scores were 3-digit squares, the six digits all being different. The total of those two scores was also a square.

    After our first innings (in which we scored a 3-digit prime number of runs), we had a lead, but after their second innings we trailed by a 3-digit prime number (less than 250). The six digits in these two primes were all different. Our second innings score was also a 3-digit prime number but we lost the match by a prime number of runs.

    How many runs did we score in our second innings?

    [teaser3284]

     
    • Jim Randell's avatar

      Jim Randell 7:04 am on 31 August 2025 Permalink | Reply

      Here is a solution using the [[ SubstitutedExpression ]] solver from the enigma.py library.

      This run file executes in 154ms. (Internal runtime of the generated code is 4.7ms).

      #! python3 -m enigma -rr
      
      SubstitutedExpression
      
      # opponents scores in each innings are 3-digit square numbers
      "is_square(ABC)"  # = first innings
      "is_square(DEF)"  # = second innings
      # and their total number of runs is also a square number
      "is_square(ABC + DEF)"  # = opponents total
      
      # setters first and second innings score are 3-digit prime numbers
      "is_prime(UVW)"  # = first innings
      "is_prime(XYZ)"  # = second innings
      
      # setters team took the lead after their first innings
      "UVW > ABC"
      
      # after the opponents second innings the setters team trailed
      # by a 3-digit prime number of runs (= RST; < 250)
      "ABC + DEF - UVW = RST"
      "is_prime(RST)"
      "RST < 250"
      
      # but the setters team lost the match by a prime number of runs
      "is_prime(ABC + DEF - UVW - XYZ)"
      
      # distinct digits
      --distinct="ABCDEF,RSTUVW"
      
      # required answer is XYZ
      --answer="XYZ"
      
      # [optional] neaten up output
      --template="ABC UVW DEF XYZ" # scores in each innings
      --solution=""
      

      Solution: In the second innings, the setters team scored 239 runs.

      The opponents scores were 324 and 576 (both square numbers, and the sum is 900, also a square number), but we don’t know which order they were scored in.

      The setters team scores 659 (a prime number) in their first innings (and so were in lead lead after their first innings, but behind by 241 (a prime number) after the opponents second innings.

      In their second innings, the setters team scored 239 (a prime number), which gives them a total of 898, so they lost the game by 2 runs (a prime number).

      Like

      • Ruud's avatar

        Ruud 2:51 pm on 31 August 2025 Permalink | Reply

        import peek
        import istr
        
        for they1, they2 in istr.product((x for x in istr.range(100, 1000) if x.is_square()), repeat=2):
            if (they1 + they2).is_square() and (they1 | they2).all_distinct():
                for us1, us2 in istr.product((x for x in istr.range(100, 1000) if x.is_prime()), repeat=2):
                    if us1 > they1:
                        score3 = they1 + they2 - us1
                        if score3.is_prime() and len(score3) == 3 and score3 < 250:
                            if (they1 + they2 - us1 - us2).is_prime():
                                if (score3 | us1).all_distinct():
                                    peek(they1, they2, us1, us2)
        

        Like

    • GeoffR's avatar

      GeoffR 8:48 am on 2 September 2025 Permalink | Reply

      As an exercise, this posting is formatted to PEP-8 or the Python Enhancement Proposal. It does seem to make code more organised and readable. I used Jim’s names for variables.

      from enigma import is_prime
      
      # three digit primes 
      primes = [n for n in range(101, 1000, 2) if is_prime(n)]
      # three digit squares
      sq3 = [x ** 2 for x in range(10, 32)]
      # squares up to 2000
      sq34 = [x ** 2 for x in range(10, 44)]
      
      # opponents first score
      for ABC in sq3:
        A, B, C = (int(x) for x in str(ABC))
        if len(set(str((ABC)))) == 3:
          
          # our first score
          for UVW in primes:
            if UVW <= ABC:
              continue
            U, V, W = (int(x) for x in str(UVW))
            
            # opponents second score
            for DEF in sq3:
              if DEF == ABC or ABC + DEF not in sq34:
                continue
              
              # ABC and DEF combined have six different digits
              D, E, F = (int(x) for x in str(DEF))
              if len(set((A, B, C, D, E, F))) != 6:
                continue
              
              # after second score
              if not (100 < (RST := ABC + DEF - UVW) < 250 and is_prime(RST)):
                continue
             
              # UVW and RST combined have six different digits
              R, S, T = (int(x) for x in str(RST))
              if len(set((U, V, W, R, S, T ))) != 6:
                continue
      
              # our second score
              for XYZ in primes:
                # we lost the match by a prime number of runs
                if XYZ != UVW and is_prime(ABC + DEF - UVW - XYZ):
                  print(f"Home team scores(1st and 2nd): {UVW} and {XYZ}.")
                  print(f"Visiting team scores(1st and 2nd): {ABC} and {DEF}.")
                  print()
      
      

      Like

  • Unknown's avatar

    Jim Randell 4:23 pm on 28 August 2025 Permalink | Reply
    Tags: ,   

    Teaser 2486: [Meet at the station] 

    From The Sunday Times, 16th May 2010 [link] [link]

    Pat drove to the station at his regular speed to meet his brothers, John and Joe, whose train was due at 6pm. But John had caught an earlier train and, arriving half an hour early, decided to walk home at a steady pace. Pat waved as he passed John (a whole number of minutes before 6pm), but drove on to the station. Pat collected Joe on time and, a whole number of minutes later, they set off for home. Pat and Joe overtook John 13 minutes after Pat had waved to him.

    At what time did Pat and Joe overtake John?

    This puzzle was originally published with no title.

    [teaser2486]

     
    • Jim Randell's avatar

      Jim Randell 4:23 pm on 28 August 2025 Permalink | Reply

      Suppose John walks at a speed of 1 unit per minute.

      At a minutes before 6pm Pat passes John. At this time John has walked a distance of (30 − a) units from the station. And in 13 minutes time John will have walked an additional 13 distance units, so will be a distance (43 − a) units from the station.

      After Pat passes John he travels the remaining distance (30 − a) to the station, in a time of a minutes (to arrive at 6pm).

      So his velocity is: (30 − a)/a.

      If the Pat and John leave the station at b minutes after 6pm, then the time taken to catch up with John is:

      c = (43 − a) / (30 − a)/a

      and:

      a + b + c = 13

      (where a, b, c are all positive integer values).

      So we can consider possible integer values for a and calculate the corresponding values for c and b.

      This can be done manually or programatically.

      This Python program runs in 75ms. (Internal runtime is 33µs).

      from enigma import (irange, div, printf)
      
      for a in irange(1, 5):
        c = div((43 - a) * a, (30 - a))
        if c is None: continue
        b = 13 - (a + c)
        if b > 0:
          printf("a={a} -> c={c} b={b}")
      

      Solution: Pat and Joe caught up with John at 6:09 pm.

      Pat initially passed John at 5:56 pm, before arriving at the station 4 minutes later (at 6 pm) to pick up Joe.

      They left the station at 6:03 pm and 6 minutes later (at 6:09 pm) passed John again.

      Like

  • Unknown's avatar

    Jim Randell 8:28 am on 26 August 2025 Permalink | Reply
    Tags:   

    Teaser 2476: [Parking spaces] 

    From The Sunday Times, 7th March 2010 [link]

    Ann, Beth, Carol, Dave and Eric rent flats on each of the five floors of a block. Each has a parking space in which are their five makes of car, in five colours.

    Ann does not live in the basement, whose tenant owns the MG. Ann’s car is not the silver Audi or the blue car, which belongs to the second-floor tenant. The Ford (not black) is the first-floor tenant’s. The ground-floor tenant’s car is white and Eric’s is not black. One of the girls owns the Vauxhall, and Dave (who lives one floor below Carol) owns the Toyota.

    Whose cars are red, white and blue respectively?

    This puzzle was originally published with no title.

    [teaser2476]

     
    • Jim Randell's avatar

      Jim Randell 8:29 am on 26 August 2025 Permalink | Reply

      This Python program uses the [[ SubstitutedExpression() ]] solver from the enigma.py library to solve the puzzle.

      We assign numeric values from 0 to 4 to the floors (this makes it easier to code the condition that D is one floor below C), and then assign each of the floor values to the remaining groups of attributes: name, colour, make.

      The program runs in 71ms. (Internal runtime is 8.4ms).

      from enigma import (SubstitutedExpression, sprintf, printf)
      
      # we assign floor values (0 .. 4 = basement .. 3rd) to each of the groups:
      #   name (A, B, C, D, E)
      #   make
      #   colour
      
      # floors (basement, ground, 1st, 2nd, 3rd)
      floor = (FB, FG, F1, F2, F3) = (0, 1, 2, 3, 4)
      
      # symbols and labels for the groups
      name = (A, B, C, D, E) = "ABCDE"
      make = (MM, MA, MF, MV, MT) = "KLMNP"  # MG, Audi, Ford, Vauxhall, Toyota
      colour = (CS, CB, CK, CW, CR) = "QRSTU"  # silver, blue, black, white, red
      
      # construct the expressions
      exprs = list(sprintf(x) for x in [
        # "A does not live in the basement"
        "{FB} != {A}",
        # "basement owns the MG"
        "{FB} == {MM}",
        # "A's car is not the silver Audi" => "the Audi is silver"
        "{MA} == {CS}", "{MA} != {A}", "{CS} != {A}",
        # "A's car is not blue"
        "{CB} != {A}",
        # "the blue car belongs to the second floor"
        "{CB} == {F2}",
        # "the Ford is not black" (!)
        "{MF} != {CK}",
        # "the Ford belongs to the first floor"
        "{MF} == {F1}",
        # "the ground floor car is white"
        "{FG} == {CW}",
        # "E's car is not black"
        "{CK} != {E}",
        # "the Vauxhall belongs to A, B, or C"
        "{MV} in {{ {A}, {B}, {C} }}",
        # "D lives one floor below C"
        "{D} + 1 == {C}",
        # "D owns the Toyota"
        "{MT} == {D}",
      ])
      
      # construct the solver
      p = SubstitutedExpression(exprs, base=5, distinct=[name, make, colour])
      
      # labels for output
      floors = ["basement", "ground floor", "1st floor", "2nd floor", "3rd floor"]
      names = ["Anne", "Beth", "Carol", "Dave", "Eric"]
      colours = ["silver", "blue", "black", "white", "red"]
      makes = ["MG", "Audi", "Ford", "Vauxhall", "Toyota"]
      output = lambda f, n, c, m: printf("{f}: {n}, {c} {m}")
      
      # solve the puzzle
      for s in p.solve(verbose=0):
        # collect attributes by floor
        ss = list([f] for f in floors)
        for (xs, ts) in [(name, names), (colour, colours), (make, makes)]:
          for (x, t) in zip(xs, ts):
            ss[s[x]].append(t)
        # output solution
        for vs in ss: output(*vs)
        printf()
      

      Solution: Eric’s car is red; Anne’s car is white; Dave’s car is blue.

      The complete description is:

      basement: Beth, black, MG
      ground floor: Anne, white, Vauxhall
      1st floor: Eric, red, Ford
      2nd floor: Dave, blue, Toyota
      3rd floor: Carol, silver, Audi

      Like

    • Frits's avatar

      Frits 7:43 pm on 27 August 2025 Permalink | Reply

      Reusing code from Tantalizer 66.

      deepcopy = lambda s: [x.copy() for x in s]
      n_elements = lambda s: sum(len(y) for x in s for y in x)
      
      # update values in positions <ps> for column <col>
      def update(ps, vals):
        col = colno[vals[0]] # column for the values in <vals>
        for i in range(5):
          if i in ps:
            lst[i][col] = vals
          else:
            # remove values from other positions
            remove(i, [x for x in lst[i][col] if x in vals]) 
      
      # remove values <vals> from lst[pos][col]      
      def remove(pos, vals):
        if not vals: return
        col = colno[vals[0]] # column for the values in <vals>
        ln = len(lst[pos][col])
        lst[pos][col] = [x for x in lst[pos][col] if x not in vals]  
        if not lst[pos][col]:
          error = True   # no value remained
        if ln > 1 and len(lst[pos][col]) == 1:
          update([pos], lst[pos][col])
      
      # propagate uniqueness of value <v>
      def unique(col, v):
        # check for uniqueness
        if len(s := [i for i in range(5) if v in lst[i][col]]) == 1:
          update(s, [v])     
      
      # values <v1> and <v2> belong together
      def linked(v1, v2):
        c1 = colno[v1] 
        c2 = colno[v2] 
        # if v1 and v2 don't both exist for a position remove all of them
        for i in range(5):
          if not(v1 in lst[i][c1] and v2 in lst[i][c2]):
            remove(i, [v1])
            remove(i, [v2])
      
        unique(c1, v1)
        unique(c2, v2)  
      
      # values <v1> and <v2> don't belong together
      def not_linked(v1, v2):
        c1 = colno[v1] 
        c2 = colno[v2] 
        # remove other value if a position has a set value for <v1> or <v2> 
        for i in range(5):
          if lst[i][c1] == [v1]:
            remove(i, [v2])
          if lst[i][c2] == [v2]:
            remove(i, [v1])
      
      # try all options for person <p> and list <s>
      def solve(k, p, s, ss=[]):
        global lst
        if (n := n_elements(lst)) == 15:
          yield lst
        elif k == 0:
          # we have to try options for another person
          p = ss[1]
          s = deepcopy(lst[p])
          yield from solve(3, p, s, ss[1:])
        else:
          if len(s[k - 1]) == 1:  # one value
            yield from solve(k - 1, p, s, ss)
          else:
            save = deepcopy(lst)
            # try all values
            for v in s[k - 1]:
              update([p], [v])
              # check/update relations until <lst> is no longer updated
              n = check()   
              if not error:
                yield from solve(k - 1, p, s, ss)
              # back track all updates
              lst = deepcopy(save)          
      
      # check/update relations until <lst> is no longer updated
      def check():
        global error
        error = False
        n, prev = 75, 76
        
        while n < prev and not error:
          linked("basement", "MG")        # "basement owns the MG"
          linked("Audi", "silver")        # "A's car is not the silver Audi"
          linked("blue", "second-floor")  # "the blue car belongs to the second floor"
          not_linked("Ford", "black")     # "the Ford is not black"
          linked("Ford", "first-floor")   # "the Ford belongs to the first floor"
          linked("ground-floor", "white") # "the ground floor car is white"
          # "D lives one floor below C"
          for fl in lst[D][0]:
            if floor[floor.index(fl) + 1] not in lst[C][0]:
              remove(D, [fl])
          for fl in lst[C][0]:
            if floor[floor.index(fl) - 1] not in lst[D][0]:
              remove(C, [fl])    
      
          # calculate number of elements in flattened list
          prev, n = n, n_elements(lst)
          if n < 15:
            error = True
        return n
      
      floor  = "basement ground-floor first-floor second-floor third-floor".split()
      make   = "MG Audi Ford Vauxhall Toyota".split()
      colour = "silver blue black white red".split()
      names  = "Anne Beth Carol Dave Eric".split()
      A, B, C, D, E = range(5)
      
      # dictionary column number per item
      colno = {x: 0 if x in floor else 1 if x in colour else 2 
                 for x in floor + make + colour}
      
      # make an initial list per person with all possibilities
      lst = [[floor, colour, make] for _ in range(5)]
      error = False
      
      # initial constraints
      remove(A, ["basement"])         # "A does not live in the basement"
      remove(A, ["Audi"])             # "A's car is not the silver Audi"
      remove(A, ["silver", "blue"])   # "A's car is not blue"
      remove(E, ["black"])            # "E's car is not black"
      # "the Vauxhall belongs to A, B, or C"
      remove(D, ["Vauxhall"])
      remove(E, ["Vauxhall"])
      # "D lives one floor below C"
      remove(D, ["third-floor"])
      remove(C, ["basement"])
      update([D], ["Toyota"])         # "D owns the Toyota"
      
      # check/update relations until <lst> is no longer updated
      n = check()    
      if error: 
        print("no solution")
      elif n == 15:
        for name, vals in zip(names, lst):
          print(f"{name}:", ", ".join(x[0] for x in vals))
      elif n > 15:
        # start with person for which the most is known
        ns = sorted([(sum(len(y) for y in x), i) for i, x in enumerate(lst)])
        ns = [i for x, i in ns if x > 3]
        # try all options 
        for s in solve(3, ns[0], deepcopy(lst[ns[0]]), ns):
          for name, vals in zip(names, lst):
            print(f"{name}:", ", ".join(x[0] for x in vals))
          print("answer:", [[name for name, vals in zip(names, lst) 
                if col in vals[1]][0] for col in ["red", "white", "blue"]])
      

      Like

  • Unknown's avatar

    Jim Randell 6:11 am on 24 August 2025 Permalink | Reply
    Tags:   

    Teaser 3283: Die hard? 

    From The Sunday Times, 24th August 2025 [link] [link]

    I have three identical standard dice (1 to 6 spots on each face, with 1 opposite 6, 2 opposite 5 and 3 opposite 4). I have placed them together in a row on a table and looked at the row from various sides. Regarding each face of a die as a digit (so that, for example, five spots would be regarded as a 5), I can read three 3-digit primes; one along the front of the row, one (the largest of the three) along the top of the row, and one along the back viewed from behind. Furthermore, the total number of  spots on the 11 visible faces is also a prime.

    What, in increasing order, are the three 3-digit primes?

    [teaser3283]

     
    • Jim Randell's avatar

      Jim Randell 6:26 am on 24 August 2025 Permalink | Reply

      See also: Teaser 2598.

      Here is a solution using the [[ SubstitutedExpression ]] solver from the enigma.py library.

      It assumes the dice are “right-handed”, but you get the same answer if you use “left-handed” dice.

      The following run-file executes in 93ms. (Internal runtime of the generated code is 498µs).

      Run: [ @codepad ]

      #! python3 -m enigma -rr
      
      SubstitutedExpression
      
      # layout (viewed from above):
      #
      #       I H G
      #     +-------+
      #   X | A B C | Y
      #     +-------+
      #       D E F
      
      --digits="1-6"
      --distinct="ADIX,BEH,CFGY"
      
      # check the top
      "is_prime(ABC)"
      "ABC > max(DEF, GHI)"
      
      # the front and back
      "D + I = 7" "E + H = 7" "F + G = 7"
      "is_prime(DEF)"
      "is_prime(GHI)"
      
      # total number of exposed spots is also prime
      "is_prime(sum([A, B, C, D, E, F, G, H, I, X, Y]))"
      
      # generate die corners (clockwise around a corner)
      --code="""
      def die_corners(x, y, z, k=7):
        for (y1, z1) in tuples([y, z, k - y, k - z, y], 2):
          for t in [(x, y1, z1), (k - x, k - z1, k - y1)]:
            yield from repeat(rotate, t, 2)
      """
      --code="corners = set(die_corners(3, 2, 1))"  # (3, 2, 1) = RH; (1, 2, 3) ]] = LH
      
      # check corner configurations
      "(A, D, X) in corners"
      "(A, X, I) in corners"
      "(C, Y, F) in corners"
      "(C, G, Y) in corners"
      
      --answer="ordered(ABC, DEF, GHI)"
      --template="(A B C) (D E F) (G H I) (X Y)"
      --solution=""
      

      Solution: The 3-digit primes are: 433, 443, 661.

      Here is a layout using right-handed dice:

      The sum of the spots on the exposed faces is 41.

      The die in the middle can be rotated through 180° to give another layout with 433 on the front and 443 on the back, but this doesn’t change the answer to the puzzle.

      With left-handed dice the layout is the same, but with the 5 on the left end and the 2 on the right end.

      Like

      • Jim Randell's avatar

        Jim Randell 9:25 am on 24 August 2025 Permalink | Reply

        Here is a Python program that finds the same layout(s).

        It has an internal runtime of 499µs.

        from enigma import (primes, nsplit, tuples, repeat, rotate, find, seq_items, chain, nconcat, printf)
        
        # generate die corners (clockwise around a corner) from example (x, y, z)
        def die_corners(x, y, z, k=7):
          for (y1, z1) in tuples([y, z, k - y, k - z, y], 2):
            for t in [(x, y1, z1), (k - x, k - z1, k - y1)]:
              yield from repeat(rotate, t, 2)
        
        # make map (x, y) -> z for (x, y, z) clockwise values round a corner
        # [use: (3, 2, 1) for RH dice; (1, 2, 3) for LH dice]
        corner = dict(((x, y), z) for (x, y, z) in die_corners(3, 2, 1))
        
        # look for 3-digit primes that can be formed using the digits 1-6
        digits = { 1, 2, 3, 4, 5, 6 }
        prs = list(ds for ds in map(nsplit, primes.between(111, 666)) if digits.issuperset(ds))
        
        # choose a prime for the front
        for (i, front) in enumerate(prs):
          # determine the number on the back
          back = tuple(7 - x for x in reversed(front))
          j = find(prs, back)
          if j == -1: continue
          # choose a larger number for the top
          for (_, top) in seq_items(prs, max(i, j) + 1):
        
            # check for valid dice
            xs = tuple(corner.get((t, f)) for (t, f) in zip(top, front))
            if None in xs: continue
        
            # left and right exposed faces
            (x, y) = (xs[0], corner.get((front[-1], top[-1])))
            # sum of exposed faces is a prime
            if not (sum(chain(top, front, back, (x, y))) in primes): continue
        
            # output solution
            ns = sorted(map(nconcat, (top, front, back)))
            printf("{ns} [top={top} front={front} back={back}; left={x} right={y}]")
        

        Like

    • Frits's avatar

      Frits 9:37 am on 24 August 2025 Permalink | Reply

      #       I H G
      #     +-------+
      #   X | A B C | Y
      #     +-------+
      #       D E F
       
      # given two dice faces anti-clockwise at a vertex, find the third
      # face at this vertex (using a western die if 'same' is True)
      def third_face(fi, se, same=True):
        f, s = int(fi), int(se)
        if s in {f, 7 - f}:
          raise ValueError
        oth = [i for i in range(1, 7) if i not in {f, s, 7 - f, 7 - s}]
        return oth[((f < s) == ((s - f) % 2)) == (same == (s + f > 7))]
       
      dgts = "123456"
      # opposite side dictionary
      opp = {str(i): str(7 - i) for i in range(1, 7)}  
          
      # determine valid 3-digit primes
      prms1 = {3, 5, 7}
      prms1 |= {x for x in range(11, 52, 2) if all(x % p for p in prms1)}
      prms = {str(i) for i in range(101, 667, 2) if all(i % p for p in prms1) and
              all(x in dgts for x in str(i))}
       
      # front and back: DEF and IHG
      fb = [(DEF, GHI[::-1]) for DEF in prms if DEF[0] not in "16" and
            (GHI := (''.join(opp[x] for x in DEF))[::-1]) in prms]
      
      # combine front DEF and back IHG with top ABC
      ftb = [(f, t, b) for t in prms for f, b in fb if t > f and t > b[::-1]
             and all(y not in {x, z} for x, y, z in zip(f, t, b))]       
      
      # determine side faces X and Y and check for prime total of 11 spots
      ftbxy = [(f, t, b, x, y) for (f, t, b) in ftb if 
               (x := third_face(f[0], t[0])) + (y := third_face(t[2], f[2])) +
               sum(sum(int(b) for b in a) for a in (f, b, t)) in prms1]
      
      sols = set(tuple(sorted([f, t, b[::-1]])) for f, t, b, _, _ in ftbxy)
      print("answer:", ' or '.join(str(s) for s in sols))
      

      Like

  • Unknown's avatar

    Jim Randell 7:32 am on 22 August 2025 Permalink | Reply
    Tags:   

    Teaser 2485: [Circular field] 

    From The Sunday Times, 9th May 2010 [link] [link]

    Jack and Katy have inherited a circular field, with North Gate (N) at the northernmost point and East Gate (E), South Gate (S) and West Gate (W) at the appropriate points.

    Jack’s share of the field is one hectare in area. For each point P in his share, [exactly] three of the angles NPE, EPS, SPW and WPN are acute. For each point in Katy’s share, however, fewer than three of those angles are acute.

    How far is it between North Gate and East Gate?

    This puzzle was originally published with no title.

    [teaser2485]

     
    • Jim Randell's avatar

      Jim Randell 7:32 am on 22 August 2025 Permalink | Reply

      If we draw a circle and consider the angle subtended by the diameter with any point P, then:

      If P is on the circumference of the circle, then the angle is 90°.

      If P is inside the circle, then the angle is >90°.

      If P is outside the circle, then the angle is <90° (i.e. actute).

      So we can draw circles with diameters of NE, ES, SW, WN, and then Jack’s region is any point that is outside exactly 3 of the circles, and Katy’s region is any point that is outside fewer than 3 of the circles.

      The situation is this:

      Jack’s area (blue) consists of points that are outside exactly 3 of the circles, and Katy’s area (green) consists of points that are outside exactly 2 of the circles.


      If we suppose the distance we are interested in is 2d:

      Then the area of the semi-circle with diameter NE, radius = d is: (1/2) 𝛑 d^2.

      And this area is the same as that of the triangle NOE and two half-petals (= 1 petal):

      (1/2) 𝛑 d^2 = d^2 + P

      2P = (𝛑 − 2) d^2

      And Katy’s area (K) consists of 4 petals:

      K = 4P = 2 (𝛑 − 2) d^2

      The radius of the entire circular field is: OE = d √2.

      And so the total area of the field is then: 2 𝛑 d^2.

      And so Jack’s area (J) is:

      J = 2 𝛑 d^2 − 2 (𝛑 − 2) d^2

      J = 4 d^2

      And this is equal to 1 hectare = 10_000 sq m.

      4 d^2 = 10_000 sq m
      d^2 = 2500 sq m
      d = 50 m

      And the distance we are interested in is NE = 2d, hence:

      Solution: The distance between the N gate and E gate is 100 m.

      And Katy’s area is:

      K = 2 (𝛑 − 2) d^2
      K ≈ 5708 sq m

      So, only 57% the size of Jack’s area.

      Like

  • Unknown's avatar

    Jim Randell 7:45 am on 19 August 2025 Permalink | Reply
    Tags:   

    Teaser 2468: [Entry codes] 

    From The Sunday Times, 10th January 2010 [link]

    The entry code to George and Martha’s block of flats is a four-digit number consisting of four different non-zero digits. Following a breach of security, management has changed the code. “I can see what they have done”, Martha says after some protracted calculations. “They have taken a multiple of the old code to give a five-digit number, added up those five digits, then squared this total to give the new code”. “Being of simple mind”, replies George, “I’d have said that they’ve simply reversed the old code”.

    What was the old code?

    This puzzle was originally published with no title.

    [teaser2468]

     
    • Jim Randell's avatar

      Jim Randell 7:45 am on 19 August 2025 Permalink | Reply

      From George’s assessment we can see that the new code consists of 4 different non-zero digits.

      From Martha’s assessment we can see that the new code must be a square number.

      So we can look at 4-digit squares, consisting of 4 different non-zero digits, for the new number, reverse it, to get the old number, and then see if we can apply Martha’s algorithm to the old number to get the new number.

      This Python program runs in 69ms. (Internal runtime is 658µs).

      from enigma import (irange, sq, nsplit, nrev, dsum, printf)
      
      # Martha's algorithm
      def martha(n):
        # consider 5-digit multiples of n
        for m in irange.round(10000, 99999, step=n, rnd='I'):
          # find the square of the digit sum
          yield sq(dsum(m))
      
      # the new code is a 4-digit square with no repeated digits
      for i in irange(36, 99):
        new = sq(i)
        ds = nsplit(new)
        if 0 in ds or len(set(ds)) != len(ds): continue
      
        # the old code is the reverse of the new
        old = nrev(new)
      
        # can we apply Martha's algorithm?
        if new in martha(old):
          # output solution
          printf("new = {new}; old = {old}")
      

      Solution: The old code was: 6921.

      We can apply Martha’s technique to this number as follows:

      6921 × 13 = 89973
      8+9+9+7+3 = 36
      36^2 = 1296

      or:

      6921 × 14 = 96894
      9+6+8+9+4 = 36
      36^2 = 1296

      Like

      • Jim Randell's avatar

        Jim Randell 7:20 am on 21 August 2025 Permalink | Reply

        Using the [[ SubstitutedExpression ]] solver from the enigma.py library:

        #! python3 -m enigma -rr
        
        SubstitutedExpression
        
        # suppose the old code was: ABCD
        # and the new code is: DCBA
        # and the multiple in Martha's algorithm is: XY
        --distinct="ABCD"
        --invalid="0,ABCD"
        
        # apply Martha's algorithm:
        # the multiple:
        --macro="@M = (XY * ABCD)"
        # is a 5-digit number:
        "9999 < @M < 100000"
        # and the square of the digit sum is the new code
        "sq(dsum(@M)) = DCBA"
        
        --answer="ABCD"
        

        Like

    • Frits's avatar

      Frits 12:13 pm on 19 August 2025 Permalink | Reply

      # find a multiple of <n> that has 5 digits and a digit sum of <t>
      def multiple(n, t):
        # makes sure of a 5-digit multiple with first digit high enough
        m = 9999 if t - 37 <= 0 else (t - 36) * 11111 - 1
        # reverse looping in order to find a solution sooner 
        for k in range(99999 // n, m // n, -1):
          if sum(int(x) for x in str(k * n)) == t:
            return True
        return False    
      
      # suitable old codes and squares
      sqs = [(int(i2[::-1]), i) for i in range(32, 46) 
              if '0' not in (i2 := str(i * i)) and len(set(i2)) == 4]
      
      for n, t in sqs:
        if multiple(n, t):
          print("answer:", n)
      

      Like

    • GeoffR's avatar

      GeoffR 2:42 pm on 20 August 2025 Permalink | Reply

      
      from itertools import permutations
      from enigma import nsplit
      
      digits = set('123456789')
      
      from math import isqrt
      def is_sq(x):
         return isqrt(x) ** 2 == x 
      
      for n in permutations (digits, 4):
          a, b, c, d = n
          abcd = int(a + b + c + d)
          for m in range(2, 80):  # UB = 98765/1234 = 80
              dig5 = m * abcd
              if dig5 < 10000:continue
              if dig5 > 99999:continue
              # find digits of 5 digit number
              d1, d2, d3, d4, d5 = nsplit(dig5)
              if 0 in (d1, d2, d3, d4, d5):continue
              # find new code
              new_code = (d1 + d2 + d3 + d4 + d5) ** 2
              # new code is reverse  of old code
              if str(new_code) == str(abcd) [::-1]:
                  print(f"Old code was {abcd}.")
                     
      # Old code was 6921.
      
      

      Like

    • Ruud's avatar

      Ruud 7:03 am on 21 August 2025 Permalink | Reply

      import istr
      
      istr.int_format("04")
      for old in istr.concat(istr.permutations(istr.digits("1-9"), 4)):
              for n in range(2,10000):
                  if len(old_n:=old * n) == 5:
                      if sum(old_n) ** 2 == old[::-1]:
                          print(old, n)
                  else:
                      if len(old_n) > 5:
                          break

      Like

    • Ruud's avatar

      Ruud 7:20 am on 21 August 2025 Permalink | Reply

      As a one liner:
      import istr
      
      print(
          {
              old
              for old in istr.concat(istr.permutations(istr.digits("1-9"), 4))
              for old_n in istr.range(int(2 * old), 100000, old)
              if len(old_n) == 5 and sum(old_n) ** 2 == old[::-1]
          }
      )
      

      Like

    • GeoffR's avatar

      GeoffR 10:37 am on 21 August 2025 Permalink | Reply

      
      % A Solution in MiniZinc
      include "globals.mzn";
      
      var 1..9: a; var 1..9:b; var 1..9: c; var 1..9: d;
      var 1..9: d1; var 1..9:d2; var 1..9: d3; var 1..9: d4; var 1..9: d5;
      var 2..80:m;
      
      constraint all_different([a, b, c, d]);
      
      var 10000..99999: dig5 = 10000*d1 + 1000*d2 + 100*d3 + 10*d4 + d5;
      var 1000..9999: old_code = 1000*a + 100*b + 10*c + d;
      var 1000..9999: new_code = 1000*d + 100*c + 10*b + a;
      
      % New code
      constraint (d1 + d2 + d3 + d4 + d5) * (d1 + d2 + d3 + d4 + d5) == new_code;
      constraint m * old_code == dig5;
      
      solve satisfy;
      
      output ["Old Code = " ++ show(old_code) ++ "\n"
      ++ "New Code = " ++ show(new_code) ++ "\n"
      ++ "Multiplier = " ++ show(m) ++ "\n" 
      ++ "5-digit number = " ++ show(dig5)
      ];
      
      % Old Code = 6921
      % New Code = 1296
      % Multiplier = 13
      % 5-digit number = 89973
      % ----------
      % Old Code = 6921
      % New Code = 1296
      % Multiplier = 14
      % 5-digit number = 96894
      % ----------
      % ==========
      
      

      Like

  • Unknown's avatar

    Jim Randell 6:30 am on 17 August 2025 Permalink | Reply
    Tags:   

    Teaser 3282: Not necessarily in the right order 

    From The Sunday Times, 17th August 2025 [link] [link]

    For one composition, Skaredahora labelled the 12 notes in a standard octave from O to Z in some order, repeating cyclically. Each chord comprised four notes and had the same interval pattern: this meant the successive intervals between notes, when starting at the lowest note, ascending, and finishing with the addition of a note an octave above the lowest. The intervals all exceeded 1 and never increased. For instance, if the notes in an octave in ascending order were OQSYTVWXRPZU, then a chord of QWXZ would have an interval pattern of 5,1,3,3, which would fail for two reasons (an interval of 1 and an increase from 1 to 3).

    His chords (with notes in random order) were QPWR, WVQO, RXQS, VYRO, STUP and UVYW.

    Starting from O, what are the notes of the octave in ascending order?

    [teaser3282]

     
    • Jim Randell's avatar

      Jim Randell 7:26 am on 17 August 2025 Permalink | Reply

      The following Python program uses the [[ SubstitutedExpression ]] solver from the enigma.py library to assign values to the notes, such that each chord consists of non-decreasing intervals. And we then look for solutions where the chords share a common interval pattern.(This structure came about because initially I didn’t realise all the patterns had to be the same. So when I got multiple solutions, I re-read the puzzle, and then added a test at the end to make sure the chords found had the same pattern).

      It runs in 103ms. (Internal runtime is 32ms).

      from enigma import (
        SubstitutedExpression, tuples, is_decreasing, rotate, repeat,
        seq_all_same_r, sprintf, join, map2str, cache, printf
      )
      
      # calculate a valid pattern for a chord
      @cache
      def pattern(ns):
        # put the notes in ascending order, and add in the octave note
        ns = sorted(ns)
        ns.append(ns[0] + 12)
        # calculate the differences between adjacent notes
        ds = tuple(y - x for (x, y) in tuples(ns, 2))
        if 1 in ds: return
        # look for an order that gives non-increasing differences
        for xs in repeat(rotate, ds, len(ds) - 1):
          if is_decreasing(xs, strict=0):
            return xs
      
      # check a valid interval can be constructed
      check = lambda *ns: pattern(ns) is not None
      
      # chords to check
      chords = ["QPWR", "WVQO", "RXQS", "VYRO", "STUP", "UVYW"]
      
      # construct a solver for the puzzle
      exprs = list(sprintf("check({ns})", ns=join(crd, sep=", ")) for crd in chords)
      
      # assign notes O - Z values from 0 - 11 (with O = 0)
      p = SubstitutedExpression(
        exprs, base=12, symbols="OPQRSTUVWXYZ",
        env=dict(check=check), s2d=dict(O=0),
      )
      
      # find solutions with valid patterns
      for s in p.solve(verbose=0):
        # look for chords with a common pattern
        r = seq_all_same_r(pattern(tuple(s[x] for x in crd)) for crd in chords)
        if r.same:
          # output solution (notes and pattern)
          printf("{s} -> {r.value}", s=map2str(s))
      

      Solution: The notes in ascending order are: O P Y Q Z W S V X U R T.

      And so the chords all have an interval pattern of (5, 3, 2, 2). Here they are with the notes in ascending order:

      W R P Q
      V O Q W
      R Q S X
      Y V R O
      P S U T
      U Y W V

      Like

    • GeoffR's avatar

      GeoffR 11:36 am on 25 September 2025 Permalink | Reply

      AI code seems to have progressed significantly in the last few months, in my experience of testing it with teasers. Here is a solution, with all the code being generated by Chat GPT5, except the timer code at the beginning and end of the code from the enigma.py library.

      
      
      from enigma import Timer
      timer = Timer('ST3282', timer='E')
      timer.start()
      
      from itertools import permutations
      
      letters = ["O","P","Q","R","S","T","U","V","W","X","Y","Z"]
      chords = ["QPWR","WVQO","RXQS","VYRO","STUP","UVYW"]
      
      # All non-increasing 4-part partitions of 12 with parts >= 2
      patterns = [
          (6,2,2,2),
          (5,3,2,2),
          (4,4,2,2),
          (4,3,3,2),
          (3,3,3,3),
      ]
      
      def solve():
          for pat in patterns:
              a,b,c,d = pat
              offsets = [0, a, a+b, a+b+c]  # positions of the
              # 4 notes relative to chord's lowest note
      
              # build placements for each chord: list of dicts
              # letter -> absolute position (0..11)
              placements_by_chord = {}
              
              for ch in chords:
                  placements = []
                  seen = set()  # dedupe identical mappings
                  for perm in permutations(ch):
                      if "O" in perm:
                          # If 'O' is in this permutation it forces
                          # the base p so that O maps to 0
                          idxO = perm.index("O")
                          p = (-offsets[idxO]) % 12
                          mapping = {perm[i]: (p + offsets[i]) % 12 \
                                     for i in range(4)}
                          key = tuple(sorted(mapping.items()))
                          if key not in seen:
                              seen.add(key)
                              placements.append(mapping)
                      else:
                          # O not in chord: try all base positions
                          for p in range(12):
                              mapping = {perm[i]: (p + offsets[i]) \
                                         % 12 for i in range(4)}
                              key = tuple(sorted(mapping.items()))
                              if key not in seen:
                                  seen.add(key)
                                  placements.append(mapping)
                  placements_by_chord[ch] = placements
      
              # order chords by fewest placements to improve pruning
              chord_list = sorted(chords, \
                                  key=lambda c: len(placements_by_chord[c]))
      
              # anchor O at position 0
              assignment = {"O": 0}
              used_pos = {0}
      
              def backtrack(i):
                  if i == len(chord_list):
                      return dict(assignment)
                  ch = chord_list[i]
                  for mapping in placements_by_chord[ch]:
                      conflict = False
                      # quick conflict tests
                      for L, pos in mapping.items():
                          if L in assignment and assignment[L] != pos:
                              conflict = True;
                              break
                          if L not in assignment and pos in used_pos:
                              conflict = True;
                              break
                      if conflict:
                          continue
                      # apply
                      added = []
                      for L, pos in mapping.items():
                          if L not in assignment:
                              assignment[L] = pos
                              used_pos.add(pos)
                              added.append(L)
                      res = backtrack(i + 1)
                      if res:
                          return res
                      # undo
                      for L in added:
                          used_pos.remove(assignment[L])
                          del assignment[L]
                  return None
      
              sol = backtrack(0)
              if sol:
                  # fill missing letter (the one not appearing in
                  # any chord) into the free position
                  assigned_letters = set(sol.keys())
                  missing_letter = (set(letters) - assigned_letters).pop()
                  free_pos = (set(range(12)) - set(sol.values())).pop()
                  sol[missing_letter] = free_pos
      
                  # build octave list by position, rotate so O is first
                  order_by_pos = [None] * 12
                  for L, p in sol.items():
                      order_by_pos[p] = L
                  iO = order_by_pos.index("O")
                  octave = order_by_pos[iO:] + order_by_pos[:iO]
                  return pat, octave
          return None, None
      
      pattern, octave = solve()
      if octave:
          print("pattern:", pattern)
          print("octave starting at O:", " ".join(octave))
      else:
          print("No solution found.")
      
      timer.stop()
      timer.report()
      
      # pattern: (5, 3, 2, 2)
      # octave starting at O: O P Y Q Z W S V X U R T
      # [ST3282] total time: 0.0317849s (31.78ms)
      
      
      

      Like

      • Frits's avatar

        Frits 10:00 am on 26 September 2025 Permalink | Reply

        If I prefix the teaser text with “Write a Python program to solve the following puzzle:” the online ChatGPT generated slow code with no answer.

        Like

      • Frits's avatar

        Frits 10:28 am on 26 September 2025 Permalink | Reply

        The online ChatGPT version is the 4o version (may 2024). The version is not easy to find. I had to ask ChatGPT for it’s own version.

        Like

    • GeoffR's avatar

      GeoffR 12:13 pm on 26 September 2025 Permalink | Reply

      @Frits: I am using ChatGPT5 as an upgrade. However, usage is limited and its reverts to 4o version with continued use.

      Like

  • Unknown's avatar

    Jim Randell 10:46 am on 15 August 2025 Permalink | Reply
    Tags:   

    Teaser 2492: [Paper cut] 

    From The Sunday Times, 27th June 2010 [link] [link]

    I started with a rectangular piece of paper and made a [straight] cut across it, dividing it into a triangle and a pentagon. Then I discarded the triangle and measured the lengths of the sides of the pentagon, all of which were whole numbers of centimetres.

    I remember that the lengths of the shortest two sides [of the pentagon] were 17 cm and 19 cm, and that the length of the longest side was 33 cm.

    What were the lengths of the other two sides?

    This puzzle was originally published with no title.

    [teaser2492]

     
    • Jim Randell's avatar

      Jim Randell 10:46 am on 15 August 2025 Permalink | Reply

      To make a rectangle into a pentagon with a single cut, we cut one of the corners off, which means we discard a right-angled triangle. And all sides of the pentagon have integer lengths, which means so do the sides of the triangle, so its sides are a Pythagorean triple.

      This Python program runs in 60ms. (Internal runtime is 91µs).

      from enigma import (irange, cproduct, pythagorean_triples, ordered, printf)
      
      # the hypotenuse of the removed triangle cannot be more than 33
      for (x, y, z) in pythagorean_triples(33):
        if z < 17: continue  # or less than 17
        # consider possible rectangles (a, b)
        for (a, b) in cproduct([irange(x + 17, 33), irange(y + 17, 33)]):
          # the sides of the pentagon
          P = ordered(a, b, a - x, b - y, z)
          if not (P[0] == 17 and P[1] == 19 and P[-1] == 33): continue
          # output solution
          printf("rectangle={R} triangle={T} -> pentagon={P}", R=(a, b), T=(x, y, z))
      

      Solution: The remaining sides of the pentagon have lengths of 20 cm and 31 cm.

      The scenario is this:

      Like

    • Frits's avatar

      Frits 2:54 pm on 15 August 2025 Permalink | Reply

      from enigma import pythagorean_triples
      
      m1, m2, M = 17, 19, 33
       
      # the hypotenuse of the removed triangle cannot be more than M
      for (x, y, z) in pythagorean_triples(33):
        if z < m1: continue
        # consider possible rectangles (w, h)
        for w in range(m1 + x, M + 1):
          lo, mi, hi = sorted([w, w - x, z])
          if mi < m2: continue
          if {m1, m2, M}.isdisjoint({lo, mi, hi}): continue 
          if hi != M: 
            h_rng = [M] 
          elif lo != m1: 
            h_rng = [m1 + y] if m1 + y <= M else []
          else:
            h_rng = range(m1 + y, M + 1)
          
          for h in h_rng:
            # five sides of the pentagon
            s1, s2, s3, s4, s5 = sorted([lo, mi, hi, h, h - y])
            if not (s1 == m1 and s2 == m2 and s5 == M): continue
            print("answer:", [s3, s4])
      

      Like

c
Compose new post
j
Next post/Next comment
k
Previous post/Previous comment
r
Reply
e
Edit
o
Show/Hide comments
t
Go to top
l
Go to login
h
Show/Hide help
shift + esc
Cancel
Design a site like this with WordPress.com
Get started