Bad Apple but it's Lean Tactics

5 hours ago 2

Lean is a modern programming language that happens to also be a proof assistant. It’s also the best programming language of all time.

Kublai: No it’s not! You haven’t even convinced any of your friends to try Lean, much less convince them that it’s the best!

OK, uh, good point. I guess “the Ali’s Uyghur Kitchen of programming” is not a very convincing sales pitch to most people. One friend asked me for any examples of Lean programs that actually do real1 stuff, and sure there’s Verso and lean4-raytracer and of course Lean itself, but they don’t really grab people’s attentions.

Alright, I’ll give a serious reason. One thing that Lean surprisingly excels at is the editor experience. It’s like an interactive Jupyter notebook and the infoview displays a lot of useful real-time information about types and tactic states. Oh, and #eval is amazing. Check out this demo of Lean’s live programming features!

Kublai: Hold up, hold up, what’s the infoview? What’s a tactic?

Here’s an example program opened in VSCode (technically VSCodium).

A screenshot of a Lean file in VSCodium

Imagine a proof in Lean as a board game. The infoview on the right shows the state of the game. We start with some state that contains the goal, which is the thing we’d like to prove. Tactics are moves in this game that modify the state and goal. We win if we can simplify the goal to be a known true statement.

So, if Lean is so amazing, the obvious question of course is: Can we play “Bad Apple!!” in the infoview using Lean tactics?

Warm-up

Let’s cheat a little bit. The easiest way to display stuff in the infoview is by using a custom widget, which are tiny JavaScript snippets. Here’s a simple widget that plays “Bad Apple!!”:

import Lean @[widget_module] def badWidget : Lean.Widget.Module where javascript := " import * as React from 'react'; export default function(props) { return React.createElement('video', {controls: \"true\"}, React.createElement('source', {src: \"https://f.unnamed.website/%E3%80%90%E6%9D%B1%E6%96%B9%E3%80%91Bad%20Apple!!%20%EF%BC%B0%EF%BC%B6%E3%80%90%E5%BD%B1%E7%B5%B5%E3%80%91%20[FtutLA63Cp8].webm\"})) }"

We can now display the widget:

Kublai: What a scam. That’s not Lean! You just made a website that plays a video, wow, what a shocker.

OK, sure sure, that was some pretty heavy cheating. Let’s make it only half-cheating my displaying that widget using a tactic instead of the built-in #widget command.

Writing a Lean tactic is a dark evil art. Tactics are not normal Lean functions. They’re metaprograms. Think macros or C++ templates, but fancier. The nice thing about Lean metaprogramming is that it’s done using Lean itself instead of a cursed DSL like C++ templates. (As the cool kids would say, the the meta-level is reflected to the object-level.) The nasty thing about Lean metaprogramming is that it’s metaprogramming. You’ll see.

There’s a whole book on Lean metaprogramming but somehow the specific thing I want to do here isn’t documented, so I ended up digging up some random magic incantation from the implementation of the apply? tactic that finally worked:

syntax (name := bad_apple) "bad_apple" : tactic @[tactic bad_apple] def evalBadApple : Lean.Elab.Tactic.Tactic := fun stx => do Lean.Widget.savePanelWidgetInfo badWidget.javascriptHash stx (props := return json% {})

Finally, let’s see our new tactic in action!2

example : true := by bad_apple trivial

An HTML5 video of Bad Apple in VSCodium

Yay! It works! Well, obviously this would work. But can we do it without cheating? Using 100% Lean?

Let’s metaprogram harder.

Getting spammy

Kublai: Make the goal display a “Bad Apple!!” animation and I’ll stop accusing you of cheating!

Well… this is embarrassing. I couldn’t find a good way to do that. Lean only displays the updated state after a tactic finishes running, so we can’t animate the goal using a single tactic. We could make our proof thousands of lines long where each tactic advances the video frame by one, but Lean only displays the state of the current line so we’d have to hold down an arrow key and scroll at 30 FPS. Or is it LPS? Whatever. Anyways, I’d rather have some way to start the video and leave it running unattended. Also, VSCode seems to wait a bit after moving your cursor before showing the updated state so that caps us to around 5 FPS.

We need a better solution. Look back at that screenshot above. See that “Goals accomplished!” message? It’s being displayed even though the cursor is on the bad_apple line and not the trivial line, where the goal is actually solved. So there we go. We just need to spam the infoview with log messages of “Bad Apple!!” frames.

Let’s dive into the code. I made the implementation a bit more general-purpose so there’s color support even though “Bad Apple!!” is grayscale.

import Lean structure Color where red : UInt8 green : UInt8 blue : UInt8 deriving Repr structure Emoji where value : Char color : Color deriving Repr -- Yeah I know these are technically not emojis whatever def emojis : List Emoji := [ ⟨'█',0xFF, 0xFF, 0xFF⟩⟩, ⟨'▓',0xC0, 0xC0, 0xC0⟩⟩, ⟨'▒',0x80, 0x80, 0x80⟩⟩, ⟨'░',0x40, 0x40, 0x40⟩⟩, ⟨' ',0x00, 0x00, 0x00⟩⟩, ] -- Maybe use heart emojis instead since they have more color choices? def colorEmojis : List Emoji := [ ⟨'🟥',0xFF, 0x12, 0x28⟩⟩, ⟨'🟧',0xFF, 0x90, 0x00⟩⟩, ⟨'🟨',0xFF, 0xC9, 0x00⟩⟩, ⟨'🟩',0x6C, 0xB6, 0x27⟩⟩, ⟨'🟦',0x00, 0x78, 0xD9⟩⟩, ⟨'🟪',0xB9, 0x38, 0xC2⟩⟩, ⟨'🟫',0xC3, 0x67, 0x4F⟩⟩, ⟨'⬛',0x42, 0x42, 0x42⟩⟩, ⟨'⬜',0xE0, 0xE0, 0xE0⟩⟩ ] def Color.distance (a b : Color) := -- Subtracting Nats causes underflow [(a.red, b.red), (a.green, b.green), (a.blue, b.blue)].map (fun (x, y) => (x.toNat.toInt32 - y.toNat.toInt32) * (x.toNat.toInt32 - y.toNat.toInt32)) |>.sum def Color.toEmoji (c : Color) := -- I WILL write unreadable one-liners and you can't stop me (emojis.map fun e => (e.color.distance c, e.value)).foldl (fun acc a => if acc.fst < a.fst then acc else a) (1000000, '❓') |>.snd

Next I have some boring code (omitted) that uses IO.Process.run to run some shell commands for ffmpeging the “Bad Apple!!” video into PPM images of each frame and using mpv --video=no to play the audio in the background.

I choose PPM because it seemed the least excruciating to parse, so here’s my parser:

def parsePPM (bytes : ByteArray) := do let dimEnd ← bytes.findIdx? (fun c => c = '\n'.toUInt8) 3 let dimString ← String.fromUTF8? <| bytes.extract 3 dimEnd match dimString.splitOn with | [widthString, heightString] => let width ← widthString.toNat? let height ← heightString.toNat? if h : bytes.size = dimEnd + 5 + 3 * width * height then some <| List.ofFn (n := height) (fun i => List.ofFn (n := width) fun j => let start := dimEnd + 5 + 3 * (i * width + j) have : start + 3 ≤ bytes.size := by -- This proof is a bit janky since I don't want a huge mathlib4 dependency have h₁ : start = dimEnd + 5 + 3 * (↑i * width + ↑j) := by trivial rw [h, h₁, Nat.add_assoc] apply Nat.add_le_add_left rw (occs := .pos [2]) [←Nat.mul_one 3] rw [←Nat.mul_add, Nat.mul_assoc] apply Nat.mul_le_mul_left rw [Nat.add_assoc] suffices ↑i * width + width ≤ width * height by omega rw (occs := .pos [1]) [Nat.mul_comm] rw (occs := .pos [2]) [←Nat.mul_one width] rw [←Nat.mul_add] apply Nat.mul_le_mul_left omega Color.mk bytes[start] bytes[start + 1] bytes[start + 2] ) else none | _ => none

Kublai: AAAAAHHH!! Why is there a giant math proof plopped right in the middle?

For disabling bounds checking! The nice thing about Lean is that you can choose your level of verification and safety:

  1. The code above proves that bytes[start + 2] is always within bounds so the compiler can emit efficient code.
  2. bytes[start]? returns an Option so our code itself can handle the out-of-bounds case.
  3. bytes[start]! adds a runtime bounds check, like Rust.
  4. Replacing the proof with sorry will YOLO it, like C++, and possibly segfault if you’re wrong.

Let’s skip over more boring code and do some fun metaprogramming! Here’s a tactic that shows a frame, where I’m using a file called idx to persistently track the current frame index since storing it as a goal was too finicky.

elab "s" : tactic => do let frameDir := pathToFrameDir videoPath let idxStringSplit := (← IO.FS.readFile <| frameDir.join "idx").splitOn let idx := idxStringSplit[0]!.toNat! let startTime := idxStringSplit[1]!.toNat! let frame ← getFrame frameDir idx logInfo frame <| 10000 - idx IO.FS.writeFile (frameDir.join "idx") s!"{idx + 1} {startTime}" IO.sleep <| startTime + 100 * idx / 3 - (← IO.monoMsNow) |>.toUInt32

Now let’s repeatedly call this tactic from our bad_apple tactic!

macro "bad_apple" : tactic => `(tactic| s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>s<;>...

Kublai: AAAAAAAAAAAAAAAAAHHHHHHHHHHHHHH!!!!!!!!

I mean&mldr; I have a reason for doing it this way (it has to be a macro, not an elab, for technical reasons) but I’m sure some erudite Lean wizard could do it in a much less cursed way, but this method is straightforward and doesn’t hurt my brain.

We can now enjoy our lovely tactic:

set_option maxRecDepth 1000 example : true := by prepare_video bad_apple

And&mldr; it works! Hooray! I toold yyoouuu Lleeaaannn iiiissss tttthhheeeeee bbbbbbbeeeeeeeessssssssstttttttttttt&mldr;

Ugghhhhh! VSCode is lagging like crazy! This tactic keeps prepending more and more log messages and VSCode really doesn’t like that.

This isn’t my first “Bad Apple!!” rodeo. Last time I had performance problems, I just used a better computer.

So, I tried my tactic with the same Ryzen 3950X as from that previous post and more cores didn’t seem to help. Seems like a single-threaded problem. I also tried using lean.nvim instead of the VSCode Lean extension and I got as far as somehow installing lean.nvim by pasting a bunch of random stuff before realizing I have absolutely no idea how to use Neovim.

But what is a tactic?

Why is this tactic so freaking slow in VSCode? I wrote a short main function that uses the same code to instead print to the terminal and it runs at a buttery smooth 30 FPS. So there’s definitely something slothy about VSCode. And it only becomes slow after several hundred log messages, so what if we could clear them somehow? I tried messing with Lean’s internal MonadLog but it just made my brain hurt.

Let’s take a step back. Lean is a purely functional programming language, so if you’ve been following along very, very carefully, you might’ve noticed something odd. The s tactic reads and writes directly to the filesystem! That’s not very pure, is it? It’d be pretty sad if our proof worked on one machine with the right files and then failed on the next! Wait&mldr; what even is a tactic, and what can it do?

A Lean tactic is a thing with type ReaderT Context $ StateRefT State TermElabM Unit. That’s Lean-speak for “you can do whatever the fox you want to”. A tactic can read from the filesystem. A tactic can POST to the SageMath API. A tactic can start a fork bomb. A tactic can spawn a new terminal that asks for your sudo password and becomes root and corrupts the memory of the Lean kernel so it ignores all your sorrys in your marvellous proof of the Riemann hypothesis. A tactic can launch nukes for a lovely game of global thermonuclear war.

Here’s an incredibly cursed thought: what if our tactic controlled VSCode and made it open a new identical file when too many log messages fill up in order to clear the log?

elab "s" : tactic => do let frameDir := pathToFrameDir videoPath let idxStringSplit := (← IO.FS.readFile <| frameDir.join "idx").splitOn let idx := idxStringSplit[0]!.toNat! if idx % 400 = 371 then do -- Too many log messages, open a new file let curEpoch := idx / 400 let _ ← IO.Process.spawn { cmd := "codium" args := #[s!"Main{curEpoch + 1}.lean"] stdin := .null stdout := .null stderr := .null } let startTime := idxStringSplit[1]!.toNat! let frame ← getFrame frameDir idx logInfo frame <| 10000 - idx -- 15 FPS since VSCode probably can't even render that fast IO.FS.writeFile (frameDir.join "idx") s!"{idx + 2} {startTime}" IO.sleep <| startTime + 100 * idx / 3 - (← IO.monoMsNow) |>.toUInt32

Enjoy the show.

Kublai: Thanks, I hate it.

Full code here, if you wanna give it a try. @Kevin you really need to try Lean, it’s like the Ali’s Uyghur Kitchen of programming!

Read Entire Article