Make no AI plans
July 4, 2025 at 4:26 PM by Dr. Drang
A couple of weeks ago, I asked ChatGPT to plan a driving route for me to visit all the Louis Sullivan jewel box banks. My goal was to make an Apple Note with all the banks and their Apple Maps links so I’d have everything I needed in a single document, but I didn’t have time right then to make the Note, so I put it aside without looking at it carefully. Today I went back to the ChatGPT conversation to pull out the information and found—well, you can probably guess what I found: one major mistake and a couple of minor ones. Enough errors that I ended up writing the Note without copying any of the info ChatGPT gave me.
Here’s ChatGPT’s unnecessarily verbose introduction to the route it planned:
The eight “jewel box” banks designed by Louis Sullivan are located in the Midwest, primarily in Iowa and Illinois. To visit them all, a logical route can be planned to minimize driving distances. Here’s an optimized route starting and ending in Chicago, Illinois, for convenience:
To its credit, ChatGPT did get the number of jewel box banks correct, even though I didn’t include that in my request. However, the “primarily in Iowa and Illinois” part is troublesome. Iowa is fine—there are three in that state—but Illinois? We’ll see about that later.
Here’s the list of banks in the order I’m supposed to travel:
- National Farmers Bank of Owatonna
101 N Cedar Ave, Owatonna, MN 55060 - Peoples Savings Bank
101 1st Ave SW, Cedar Rapids, IA 52405 - Henry Adams Building
124 E State St, Algona, IA 50511 - Merchants National Bank
833 4th Ave, Grinnell, IA 50112 - Home Building Association Company
23 S Park Pl, Newark, OH 43055 - Peoples Federal Savings and Loan Association
101 E Court St, Sidney, OH 45365 - Purdue State Bank
210 Main St, Lafayette, IN 47901 - Land Bank (Southwest State Bank)
331 E Monroe St, Springfield, IL 62701
Going from Grinnell, Iowa, to Newark, Ohio, makes for a long day of driving, so I doubt this is the “optimized route” ChatGPT claimed. At least it’s not optimized for the person doing the driving.
More important, though, is the last bank on the list, which is fictitious. There are no jewel box banks in Illinois. And as far as I can tell, there’s no bank of any kind at that address. Maybe ChatGPT got tired after getting seven out of eight right.1
The eighth jewel box bank is the Farmers and Merchants Union Bank in Columbus, Wisconsin. The Wikipedia pages for most of the other seven banks—Newark, Sidney, West Lafayette, Cedar Rapids, Grinnell, Algona, and Owatonna—have links to all the others, so it’s hard to imagine how ChatGPT messed this up.
While I’ve had some success with LLMs—and ChatGPT in particular—the successes have come with coding problems, where checking the results is easy and immediate: I just run the code generated for me and see if it works. Other errors take effort to find, and I’m finding that effort often greater than just doing all the work myself. This problem, for example, took basically just a couple of Kagi searches and then following the Wikipedia links. And I now have a Note with all the (correct) information I need.
One more thing: Don’t write to tell me that my title for this post is messed up. I know it was Daniel Burnham who said “Make no little plans,” not Louis Sullivan. I just couldn’t think of a good title based on “Form follows function.” And frankly, a Burnham/Sullivan mixup seems appropriate for a post about AI.
-
Close enough to right, anyway. The address it gave for the bank in Newark, Ohio, is wrong, but the real address is just around the corner. If you went to the ChatGPT-supplied address, you’d see the bank across the town square. ↩
Python, interactively
July 1, 2025 at 10:30 PM by Dr. Drang
I recently realized, after including code from interactive Python sessions in recent posts (this one and this one), that I have more to say about my use of the Jupyter console than I covered in this post back in December.
First, although the Jupyter console can run interactive sessions in Julia and R, I use it only for Python, which means I’m really working in IPython, which is what the Jupyter console runs by default. Here’s the startup message that appears after I run jupyter console
in the Terminal:1
Jupyter console 6.6.3
Python 3.13.5 (main, Jun 11 2025, 15:36:57) [Clang 17.0.0 (clang-1700.0.13.3)]
Type 'copyright', 'credits' or 'license' for more information
IPython 9.3.0 -- An enhanced Interactive Python. Type '?' for help.
Tip: `?` alone on a line will brings up IPython's help
It displays the version numbers for Jupyter console, Python, and IPython. Everything after the Jupyter console version number is the IPython “banner.” The tip at the end varies from session to session.
So if all I’m really doing is running IPython, why not do so directly? It’s a good question, and one I don’t have an especially satisfactory answer to. I started using Jupyter with notebooks. When I decided I preferred working in a console session instead, I just stayed in the Jupyter environment out of habit. At the time, I probably thought it was different from IPython and possibly an improvement. Maybe confessing publicly to this lame reason will get me to shift to IPython directly. To start the ball rolling in that direction, I’ll just refer to IPython from now on.
If you look back at the startup message, you’ll see that I’m running Python 3.13. You may have heard that this version has improvements to its standard interactive environment. It does, but it still isn’t as nice as working in IPython, mainly because of the magic commands.
When I’m working on a Python script, I try to test all the code as I write it, Typically, this means writing the code in BBEdit, saving it to a file, and testing it in IPython. The magic commands %run
and %reset
are the keys to this workflow. Typing
%run script.py
in an IPython session runs all the code in the file and keeps the session active. I can then check on what’s been written so far: calling functions with a variety of inputs, inspecting the values of variables, making sure loops and conditionals work the way they’re supposed to.
After adding to or fixing the code, I return to the IPython session and type
%reset -f
This clears out all the names defined earlier in the session so the next %run
starts fresh.
Because IPython keeps a history, I seldom have to type out %run script.py
or %reset -f
in full. Typing ⌃R puts IPython in an interactive backward search mode. Typing %ru
after that brings up the most recent %run
command; typing %re
brings up the most recent %reset
.
Even better—and this is a Bash tip, not an IPython tip—because I have these commands in my .bashrc
file,
bind '"\e[A":history-search-backward'
bind '"\e[B":history-search-forward'
I can type %ru
and then the ↑ to bring up the most recent %run
command. Similarly with %re
and ↑. I learned these bindings from a long-ago post by Craig Hockenberry and they work in all Terminal sessions, not just IPython.
(Depending on how you have things configured, IPython may show you the most recent matching command as soon as you start typing. These are called autosuggestions. I find the feature very annoying and don’t use it.)
The %rerun
command can be useful when you have a handful of commands that you want to run again in the same order. For example,
%rerun -l 4
will rerun the last four commands. I confess I don’t use %rerun
as much as I should. I tend to use ↑ to go back through the history again and again, only realizing after I’m done that I could’ve saved time using %rerun
.
After %run
and %reset
, my most commonly used magic command is probably %history
. Using it like this,
%history -opf session.txt
creates a file with all the input and output of the current session. The -o
option tells it to include the output; the -p
option tells it to put >>>
in front of all the input lines, making it look like a regular Python interactive session (even though it wasn’t); and the -f <filename>
option tells it where to save the output. You can also give it a range of session lines to print. This is what I did to create the interactive Python code I added to those recent posts.
Another %history
option, helpful when you’re trying out code in an IPython session and finally hit upon what you want to add to your script, is -t
. Using this without -p
or -o
will print out the session code in a format that can be pasted directly into your script.
Finally, IPython has several ways to incorporate previous output into a command. Suppose our session has gone like this:
In [1]: a = 1; b = 2; c = 3
In [2]: b*c
Out[2]: 6
In [3]: a + b*c
Out[3]: 7
In [4]: a + c**2
Out[4]: 10
At this point, we can multiply the third Out
by c
like this:
In [5]: Out[3]*c
Out[5]: 21
In
and Out
are not just labels, they’re Python variables that are continually updated during the session, and you can refer to them at any time. If typing Out
and brackets is too much work (it is for me), you can use an underscore and a number to refer to previous output, e.g.,
In [6]: _4**c
Out[6]: 1000
This simplified way of referring to previous output is reminiscent of Python’s long tradition of using the underscore (all by itself) to refer to the most recent output in an interactive session. IPython follows that tradition and extends it: two underscores (__
) refers to the second most recent output, and three underscores (___
) refers to the third most recent output. But that’s where it stops—apparently the IPython developers consider four underscores beyond the pale.
I’m sure there are other things I do in interactive Python sessions, but these are the ones I use often enough to remember.
-
You may recall from the December post that I have
jupyter console
aliased tojc
in my.bashrc
file, so I haven’t typed outjupyter console
in full in several years. ↩
In defense of floating point
June 28, 2025 at 3:06 PM by Dr. Drang
I’ve noticed that many programmers have a phobia about floating point numbers. They see something like this (a Python interactive session, but a similar thing could be done in many languages),
python:
>>> sum = 0.0
>>> for i in range(10):
... sum += 0.1
...
>>> sum
0.9999999999999999
and decide never to trust floating point numbers again. Web pages with titles like “Why Are Floating Point Numbers Inaccurate?” and “What is a floating point number, and why do they suck” help promote the mistrust.1 I fear this post published yesterday by John D. Cook will do the same.
The gist of Cook’s article, which is perfectly correct, is that the overwhelming majority of 32-bit integers cannot be represented exactly by a 32-bit float. And an even greater majority of 64-bit integers cannot be represented exactly by a 64-bit float.
If your response to the previous paragraph is “Well, duh!” you’re my kind of people. The mantissa of a 32-bit float is only 24 bits wide (one of the bits is implicit), so of course you can only represent a small percentage of the 32-bit integers. After accounting for the sign bit, you have a 7-bit deficit.
But here’s the thing: a 32-bit float can represent exactly every integer from -16,777,216 to 16,777,216 ( to ). Here’s a quick demonstration in an interactive Python session:
python:
>>> import numpy as np
>>> n = 2**24
>>> ai = np.linspace(-n, n, 2*n+1, dtype=np.int32)
>>> af = np.linspace(-n, n, 2*n+1, dtype=np.float32)
>>> np.array_equal(af.astype(np.int32), ai)
True
As Cook explains, there are actually many more integers that can be represented exactly by a float32
, but there are gaps between them. The run from -16,777,216 to 16,777,216 has no gaps.
That’s a big range, possibly bigger than you need. And you’re more likely to be using double precision floats than single precision. For float64
s, the mantissa is 53 bits (again, one bit is implicit), so they can exactly represent every integer from -9,007,199,254,740,992 to 9,007,199,254,740,992. Yes, as Cook says, that’s a very small percentage of 64-bit integers, but it’s still damned big.
JavaScript programmers understand the practical implications of this. By default, JavaScript stores numbers internally as 64-bit floats, so you’ll run into problems if you need an integer greater than 9 quadrillion. That’s why JavaScript has the isSafeInteger
function and the BigInt
type.
I guess the main point is understand the data types you’re using. You wouldn’t use an 8-bit integer to handle values in the thousands, but it’s fine if the values stay under one hundred. The same rules apply to floating point. You just have to know how they work.
-
The author of the second piece apparently doesn’t trust question marks, either. ↩
Library search via web page
June 26, 2025 at 7:43 PM by Dr. Drang
A few month ago, I wrote about three short Python scripts I could run from the command line to search for books in my technical library. The information on the books and authors was kept in an SQLite3 database, and the SQL queries built into the scripts were originally written with the help of ChatGPT and then improved with comments from readers. I said at the time that my goal was to move the database to a server and access it through a web page, the idea being that I could check on what books I have when I’m away from my computer. (In a used book store, for example.) I made the move last weekend and figured I’d write up a quick overview.
There’s just a single web page. Here’s what it looks like on my iPhone (a 16 Pro running Safari) before and after a search:
It’s set up to search by title, author, or both, depending on which fields I fill in.
Because I’m not a web programmer and I wanted to reuse as much of the previously written Python code as possible, the page is generated by an old-fashioned CGI script. This one:
python:
1: #!/path/to/python3
2:
3: import os
4: from urllib.parse import parse_qs
5: import sqlite3
6:
7: # Get form data from QUERY_STRING
8: def parse_query_string():
9: query = os.environ.get("QUERY_STRING", "")
10: params = parse_qs(query)
11: return {k: v[0] for k, v in params.items() if v}
12:
13: tString = ''
14: aString = ''
15: params = parse_query_string()
16: tString = params.get('title', '').strip()
17: aString = params.get('author', '').strip()
18:
19: # Set up query strings
20: qTitle = '''SELECT b.loc, b.title, GROUP_CONCAT(a.name, '; ')
21: FROM book_author ba
22: JOIN author a ON a.id = ba.author_id
23: JOIN book b ON b.id = ba.book_id
24: WHERE b.title LIKE ? GROUP BY b.id ORDER BY b.loc;'''
25:
26: qAuthor = '''SELECT b.loc, b.title, GROUP_CONCAT(a.name, '; ')
27: FROM book_author ba
28: JOIN author a ON a.id = ba.author_id
29: JOIN book b ON b.id = ba.book_id
30: WHERE b.id IN (
31: SELECT ba.book_id
32: FROM book_author ba
33: JOIN author a ON a.id = ba.author_id
34: WHERE a.name LIKE ?
35: )
36: GROUP BY b.id ORDER BY b.loc;'''
37:
38: qTitleAuthor = '''SELECT b.loc, b.title, GROUP_CONCAT(a.name, '; ')
39: FROM book_author ba
40: JOIN author a ON a.id = ba.author_id
41: JOIN book b ON b.id = ba.book_id
42: WHERE b.title LIKE ?
43: AND b.id IN (
44: SELECT ba.book_id
45: FROM book_author ba
46: JOIN author a ON a.id = ba.author_id
47: WHERE a.name LIKE ?
48: )
49: GROUP BY b.id ORDER BY b.loc;'''
50:
51: # Query the database
52: def search(t, a):
53: con = sqlite3.connect('library.db')
54: cur = con.cursor()
55: if (t != '') and (a == ''):
56: sql = qTitle
57: vals = [f'%{t}%']
58: elif (t == '') and (a != ''):
59: sql = qAuthor
60: vals = [f'%{a}%']
61: elif (t != '') and (a != ''):
62: sql = qTitleAuthor
63: vals = [f'%{t}%', f'%{a}%']
64: else:
65: results = []
66: con.close()
67: return results
68: cur.execute(sql, vals)
69: results = cur.fetchall()
70: con.close()
71: return results
72:
73: results = search(tString, aString)
74: if results:
75: rcount = len(results)
76: else:
77: rcount = 0
78:
79: if rcount == 1:
80: rheader = '1 Result'
81: else:
82: rheader = f'{rcount} Results'
83:
84: # Output HTML
85: print(f'''Content-Type: text/html
86:
87: <html>
88: <head>
89: <title>Library Search</title>
90: <link rel="stylesheet" href="search.css">
91: </head>
92: <body>
93: <h1>Search Library</h1>
94: <form method="get" action="search.py">
95: <p class="desc">Enter strings for title, author, or both</p>
96: <label for="title">Title:</label>
97: <input type="text" name="title" value="{tString}">
98: <label for="author">Author:</label>
99: <input type="text" name="author" value="{aString}">
100: <input type="submit" value="Submit">
101: </form>
102: <div class="results">
103: <h2>{rheader}</h2>''')
104:
105: if results:
106: print('<ul>')
107: for row in results:
108: print(f'<li>{"<br/>".join(row)}</li>')
109: print('</ul>')
110:
111: print(''' </div>
112: </body>
113: </html>''')
Once upon a time, a script like this would’ve imported the cgi
module to handle the form data. But that module was deprecated in Python 3.11 and removed in 3.13, so the parse_query_string
function in Lines 8–11 was built “by hand” by pulling in data from the $QUERY_STRING
environment variable.
(Unfortunately, the word query will be doing double-duty in this post. In the parse_query_string
function, it means the query portion of a URL—the part after the question mark. In the rest of the script, it’ll mean an SQL query. The potential confusion is unavoidable; query is the standard term for both situations.)
The title and author strings, tString
and aString
, are set in Lines 13–17. The default values are empty strings; they get set to the corresponding field values when the script is run via the Submit button.
The next portion of the script, Lines 20–49, define the three SQL queries we’ll need: one for searching on a title only, one for searching on an author only, and one for searching on both. These strings were taken from the earlier Python scripts.
Lines 52–71 define the search
function, which runs the appropriate SQL query according to which strings were provided in the input fields. Lines 73–82 perform the search and set up the header for the Results section of the page.
The rest of the script outputs the page’s HTML. The part that’s most variable is given in Lines 105–109, which spit out the search results in a unordered list.
The CSS file that styles the page is this:
css:
1: /* Base styles */
2: body {
3: font-family: Helvetica, sans-serif;
4: margin: 1em;
5: padding: 0;
6: line-height: 1.6;
7: background-color: #f9f9f9;
8: color: #333;
9: }
10:
11: /* Form container */
12: form {
13: max-width: 40em;
14: margin: 0 auto 2em;
15: background-color: #fff;
16: padding: 1em;
17: border-radius: 8px;
18: box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
19: }
20:
21: /* Form elements */
22: form p {
23: padding-top: 0em;
24: margin-top: 0em;
25: font-size: 1.25em;
26: }
27:
28: form label {
29: display: block;
30: margin-bottom: 0.5em;
31: font-weight: bold;
32: }
33:
34: form input[type="text"] {
35: width: 100%;
36: max-width: 100%;
37: padding: 0.5em;
38: font-size: 1em;
39: margin-bottom: 1em;
40: box-sizing: border-box;
41: }
42:
43: form input[type="submit"], form button {
44: padding: 0.6em 1.2em;
45: font-size: 1em;
46: cursor: pointer;
47: background-color: #007bff;
48: border: none;
49: border-radius: 4px;
50: color: #fff;
51: }
52:
53: form input[type="submit"]:hover, form button:hover {
54: background-color: #0056b3;
55: }
56:
57: /* Results list */
58: .results {
59: max-width: 40em;
60: margin: 0 auto;
61: padding: 1em;
62: background-color: #fff;
63: border-radius: 8px;
64: box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
65: }
66:
67: .results h2 {
68: margin-top: 0em;
69: padding-top: 0em;
70: }
71:
72: .results ul {
73: font-size: 1.1em;
74: line-height: 1.25em;
75: padding-left: 1.2em;
76: }
77:
78: .results li {
79: margin-bottom: 0.75em;
80: }
81:
82: /* Responsive tweaks */
83: @media (pointer: coarse) {
84: html {
85: font-size: 30px;
86: }
87: body {
88: margin: 0.5em;
89: }
90:
91: form, .results {
92: padding: 0.8em;
93: }
94:
95: form input[type="text"], form button {
96: width: 100%;
97: font-size: 1.25em;
98: margin-top: 0.5em;
99: }
100: form input[type="submit"], form button {
101: width: 100%;
102: margin-top: 0.5em;
103: }
104: }
About the only clever thing in this is the use of pointer: coarse
to make the page easier to read on an iPhone. I started out trying a responsive design with different width
values in the @media
query (oops! there’s a third use of that word), but I wasn’t happy with the results. I didn’t want the text to get bigger just because the width of the browser window on my Mac got narrow. And I didn’t want to rewrite this when I got a new (and different resolution) phone. Then I saw the pointer
feature and decided that was the way to go. Yes, it might make the text larger than I like on my iPad, but I don’t see myself using this on my iPad.
The Python script, the CSS file, and the database file are all kept in the same directory, a directory that’s password-protected through few Apache settings. Whenever I get a new book, I’ll add it to the local version of the database file and then upload that to replace the one on the server.