Zulip Chat Archive

Stream: general

Topic: tactical rewrite of expressions, outside of proofs


Matthew Pocock (Sep 21 2023 at 22:04):

Still playing with collaltz, and doing what I've seen maths people do on youtube when they don't understand a problem and creating a number system for it. Turns out that it's "almost" binary written backwards. Anyhoo ... to make a long story short I have this fragment:

-- a collatz numeral, decomposed into odd and even components
inductive ColN where
    | zero      : ColN                -- zero, also leading zero
    | of_even   : ColN  ColN   -- the even number that is twice the previous
    | of_odd    : ColN  ColN   -- the odd number that is one more than twice the previous

-- Evaluation at 0 recovers the natural number equivalent to the faux binary representation.
def ColN.eval (cn: ColN) (n : ) :  := match cn with
| ColN.zero => n
| ColN.of_even cm => 2 * (ColN.eval cm n)
| ColN.of_odd cm => 1 + 2 * (ColN.eval cm n)

#reduce ColN.eval (ColN.of_even $ ColN.of_odd ColN.zero) 0 -- 2
#reduce ColN.eval (ColN.of_even $ ColN.of_odd ColN.zero) 1 -- 6
#reduce ColN.eval (ColN.of_even $ ColN.of_odd ColN.zero) 2 -- 10

example (n : ): (ColN.eval (ColN.of_even $ ColN.of_odd ColN.zero) $ n) = 4 * n + 2 := by
    repeat rw [ColN.eval]
    ring

#reduce ColN.eval (ColN.of_even $ ColN.of_odd ColN.zero) -- some ungodly eldritch terror

Clearly, rewriting ColN.eval and applying ring can simplify down my number to 4n+2, but is there a way to use this directly to automatically simplify the horrific lambda expression to λn, 4*n+2 outside of a proof and without having the target expression already written out? I guess I'm asking how to use tactics to rewrite/simplify code. I won't typically know the target equation and also don't really want to need to work it out on paper.

Sky Wilshaw (Sep 21 2023 at 22:10):

If you add the following lemmas, simp will prove your repeat rw [ColN.eval] expression.

@[simp]
lemma eval_zero (n : ) : ColN.eval ColN.zero n = n := rfl

@[simp]
lemma eval_of_even (cn : ColN) (n : ) : (ColN.of_even cn).eval n = 2 * cn.eval n := rfl

@[simp]
lemma eval_of_odd (cn : ColN) (n : ) : (ColN.of_odd cn).eval n = 2 * cn.eval n + 1 := rfl

Sky Wilshaw (Sep 21 2023 at 22:11):

I did slightly change this line:

| ColN.of_odd cm => 2 * (ColN.eval cm n) + 1  -- not 1 + 2 * (ColN.eval cm n)

just because it's normally better style to add one on the right in Lean. That's just because natural addition is defined by recursion on the right.

Sky Wilshaw (Sep 21 2023 at 22:14):

You can write your own tactic to do that "simp then ring" process:

macro "eval_coln" : tactic => `(tactic| simp only [eval_zero, eval_of_even, eval_of_odd] <;> ring_nf)

then the proof becomes

example (n : ): (ColN.eval (ColN.of_even $ ColN.of_odd ColN.zero) $ n) = 4 * n + 2 :=
  by eval_coln

Sky Wilshaw (Sep 21 2023 at 22:15):

It does a bit more than just evaluate ColN.eval expressions though - the ring_nf in the macro will try to turn all ring expressions in your goal into a normal form.

Sky Wilshaw (Sep 21 2023 at 22:16):

It's less readable to use a custom tactic though, so I think it's probably only better if you're using it lots.

Matthew Pocock (Sep 21 2023 at 22:18):

Thanks. I've tidied up my definitions. But my question isn't really about the example but the #reduce. I really want something that will eat my nasty #reduce ColN.eval ... expression and convince reduce to print out the equivalent of λn, 4*n+2 instead of the mess it currently does.

Sky Wilshaw (Sep 21 2023 at 22:20):

Ah, I see. In this case, we can change the simp lemmas to this form:

@[simp]
lemma eval_zero : ColN.eval ColN.zero = id := rfl

@[simp]
lemma eval_of_even (cn : ColN) : (ColN.of_even cn).eval = 2 * cn.eval := rfl

@[simp]
lemma eval_of_odd (cn : ColN) : (ColN.of_odd cn).eval = 2 * cn.eval + 1 := rfl

Sky Wilshaw (Sep 21 2023 at 22:21):

Note that the LHS and RHS of each lemma is now a function. For example, eval_of_even says that the function (ColN.of_even cn).eval is 2 * the function cn.eval. Scalar multiplication on functions is defined "pointwise", so (2 * f) x = 2 * (f x).

Sky Wilshaw (Sep 21 2023 at 22:22):

Then, calling simp on ColN.eval (ColN.of_even $ ColN.of_odd ColN.zero) gives 2 * (2 * id + 1). I wonder if we can make this any better.

Sky Wilshaw (Sep 21 2023 at 22:26):

If we write the simp lemmas in this form:

@[simp]
lemma eval_zero : ColN.eval ColN.zero = id := rfl

@[simp]
lemma eval_of_even (cn : ColN) : (ColN.of_even cn).eval = fun n => 2 * cn.eval n := rfl

@[simp]
lemma eval_of_odd (cn : ColN) : (ColN.of_odd cn).eval = fun n => 2 * cn.eval n + 1 := rfl

it introduces lambda abstractions as needed. Then simping ColN.eval (ColN.of_even $ ColN.of_odd ColN.zero) gives fun n => 2 * (2 * n + 1)). The issue with this is that ring is a bit worse when there's a fun in the way.

Matthew Pocock (Sep 21 2023 at 23:12):

I've done it manually for now:

/--!
This evaluates a `ColN` to a pair, ⟨m, c⟩ that can be used as in `m*x+c` to recover the equation of the colaltz number.
Unlike `ColN.eval`, it does not produce a nested lambda structure, but instead keeps everything flat.
-/
def ColN.eval_p (cn: ColN) : × := match cn with
| ColN.zero => 1,0
| ColN.of_even cm =>
    let  m, c  := cm.eval_p
    2*m, 2*c
| ColN.of_odd cm =>
    let  m, c  := cm.eval_p
    2*m, 2*c + 1

/--!
Evaluate a `ColN` using the parameterisation calculated by `ColN.eval_p`.
-/
def ColN.eval_p_n (cn: ColN) (n : ) :  :=
    let m, c := cn.eval_p
    m * n + c

Perhaps writing in out manually is how we're supposed to do things. I still wonder if there's a way to encourage tactics to rewrite code, outside of trying to satisfy a proof.

Sky Wilshaw (Sep 21 2023 at 23:38):

This is probably a good idea. Of course, you can also write simp lemmas to simplify these kinds of expressions, and the simplifier will have an easier job if things are parameters instead of inside lambdas. Incidentally, I think you can get away with splitting eval_p into two functions - one for the left output and one for the right. It might make manipulation a bit easier (but you may need more lemmas).

Matthew Pocock (Sep 21 2023 at 23:48):

Yeah. I can certainly do that. But what I really want to do is take a value like ColN.eval (ColN.of_even $ ColN.of_odd ColN.zero), and where #reduce currently prints out a complex lambda, be able to apply a tactic to that expression to reduce it to the simple one. So, to invent syntax.

#reduce {simp, ring_nf} ColN.eval (ColN.of_even $ ColN.of_odd ColN.zero) -- displays `fun x => 4 * x + 2`

Sky Wilshaw (Sep 21 2023 at 23:51):

You can see what would happen if you applied tactics to expressions by just putting them in a proof state:

example {x : } : 1 + 1 = x := by
  ring
-- x: ℕ
-- ⊢ 2 = x

I put the x in there to make the goal false, this stops the tactics from automatically solving the goal for me.

Sky Wilshaw (Sep 21 2023 at 23:51):

There's probably a better way to do this though.

Sky Wilshaw (Sep 21 2023 at 23:54):

I don't think most tactics really know how to act on "bare" terms (of types in Type not Prop).

Kevin Buzzard (Sep 22 2023 at 05:52):

Yeah tactic mode is for proving theorems, not constructing data in general. Some tactics can make awful terms but because they're supposed to be proofs it doesn't matter

Kyle Miller (Sep 22 2023 at 09:17):

I don't recommend using it for anything but exploration, but there's a reduce tactic that does what #reduce does.

Matthew Pocock (Sep 22 2023 at 09:59):

Thanks. I was hoping that I could convince the tactic system to do code optimizations, to rewrite verbose and inefficient execution paths into better ones. But seems the machinery isn't keen on that :D I realise the proofs themselves are a nightmare, but it's the target expression that I'm interested in tidying as an optimized expression equivalent to my original one. The proof doesn't matter to me. Just the rewritten term.

Matthew Pocock (Sep 22 2023 at 10:00):

I'll chalk it up as a "not ever intended to work"

Yakov Pechersky (Sep 22 2023 at 11:28):

Rewriting terms like this, for reduction purposes, is what norm_num and expression based "calculators" do. You could write a custom tactic or elaborator for your specific use case.

Joachim Breitner (Sep 22 2023 at 15:57):

Were you thinking of something like this:

import Lean.Meta.Tactic.Simp
import Mathlib.Tactic.RunCmd
import Std.Tactic.TryThis

open Lean

def pretty (e : Expr) : Elab.TermElabM Format := do
    let s  Std.Tactic.TryThis.delabToRefinableSyntax e
    let f  PrettyPrinter.ppCategory `term s
    pure f

elab "simp_expr " e:term : command => Lean.Elab.Command.liftTermElabM $ do
    let expr  Lean.Elab.Term.elabTerm e none
    let ctxt   Lean.Meta.Simp.Context.mkDefault
    let (r, _)  Lean.Meta.simp expr ctxt
    logInfo $ m!"Before simplifying:\n{← pretty expr}\n" ++
              m!"After simplifying:\n{← pretty r.expr}"
    -- Std.Tactic.TryThis.addTermSuggestion e r.expr

simp_expr (id ())

This prints

Before simplifying:
id ()
After simplifying:
()

Joachim Breitner (Sep 22 2023 at 15:58):

(I was actually pondering to (ab)use the simplifier like that for a little experiment of mine. I don’t see why it shouldn’t work.)

Alex J. Best (Sep 22 2023 at 16:01):

In mathlib3 there was a simp_result tactic combinator that simplified the results of other tactics before assigning them. But as far as I know it wasn't ported yet

Matthew Pocock (Sep 22 2023 at 18:39):

Joachim Breitner said:

Were you thinking of something like this:

Yes, that's a lot more like what I was thinking of. Thanks.

Mario Carneiro (Sep 22 2023 at 19:46):

Isn't that the #simp command?

Joachim Breitner (Sep 22 2023 at 19:46):

It seems that for what I wanted to do, it doesn’t quite work: I was hoping to (ab)use the simp machinery as a convenient way to perform recursive term rewrites to Expr. But my expressions are sometimes Prop, and then simp will simply say “done” because two proofs are definitionally equal. Too bad.

Joachim Breitner (Sep 22 2023 at 19:48):

Isn't that the #simp command?

Indeed! Didn’t even know about that :-)

Joachim Breitner (Sep 22 2023 at 19:57):

I assume this #simp doesn’t seem to do do anything because the expression is already a Prop:

import Mathlib.Tactic.Conv

set_option trace.Meta.Tactic.simp true
set_option trace.Meta.Tactic.simp.heads true
set_option trace.Debug.Meta.Tactic.simp true

def other_eq_self (a : Prop) : (a = a) = True := eq_self a
theorem other_eq_self_eq_eq_self (a : Prop) :
    other_eq_self a = eq_self a := rfl
#simp [other_eq_self_eq_eq_self] => other_eq_self True

Ah, here it is, from the core of simp:

partial def simp (e : Expr) : M Result := withIncRecDepth do
  checkMaxHeartbeats "simp"
  let cfg  getConfig
  if ( isProof e) then
    return { expr := e }

So no chance using simp to rewrite proof terms :-)

Tomas Skrivan (Sep 22 2023 at 23:08):

I'm have not read carefully this thread so I'm not sure if I'm relevant with this but I have macro term rewrite_by conv that rewrite term by conv tactic.

For example

#check (id 42) rewrite_by simp

prints 42 : Nat.

code

Tomas Skrivan (Sep 22 2023 at 23:14):

Matthew Pocock said:

Thanks. I was hoping that I could convince the tactic system to do code optimizations, to rewrite verbose and inefficient execution paths into better ones. But seems the machinery isn't keen on that :D I realise the proofs themselves are a nightmare, but it's the target expression that I'm interested in tidying as an optimized expression equivalent to my original one. The proof doesn't matter to me. Just the rewritten term.

I'm definitely using Lean for this purpose. I had to modify the simplifier a bit but doing this kind of stuff is totally possible.

I'm planning on modifying the simplifier even more such that it handles let bindings a bit better and I will add an option to turn off proof generation such that on massive programs is runs reasonably fast.

Tomas Skrivan (Sep 22 2023 at 23:21):

An example of code optimization, I have conv tactic let_normalize that cleans up let bindings and moves them out of lambdas if possible

#check
    (fun (x y : Nat) =>
      let a := x + 1
      let b := x + a
      let c := x + y
      a+b+c)
    rewrite_by
      let_normalize

prints

fun x =>
  let a := x + 1;
  let b := x + a;
  fun y =>
  let c := x + y;
  a + b + c

Kyle Miller (Sep 23 2023 at 11:52):

@Tomas Skrivan mathlib has a lift_lets tactic, but I don't recall a conv version yet. There are conv tactics at least for reduce, whnf, projection unfolding, and a few others.

Kyle Miller (Sep 23 2023 at 11:54):

@Joachim Breitner You might be interested in the elementwise attribute in mathlib (look in the category theory folder in the tactic folder). It uses a function to simp the lhs and rhs independently when generating a lemma statement.

Matthew Pocock (Sep 23 2023 at 13:54):

@Tomas Skrivan That looks interesting. It works for me in some simple examples. I tried to capture the rewritten form inside a def, but failed.

inductive ColN where
    | zero      : ColN
    | even_of   : ColN  ColN
    | odd_of    : ColN  ColN
deriving Repr

@[simp]
def ColN.eval (cn: ColN) (n : ) :  := match cn with
| ColN.zero => n
| ColN.even_of cm => 2 * (ColN.eval cm n)
| ColN.odd_of cm => 2 * (ColN.eval cm n) + 1

def z10 := ColN.even_of $ ColN.odd_of ColN.zero

#reduce z10.eval -- bleutch!
#check z10.eval rewrite_by simp ; ring -- gives 2+n*4, which is exactly what we would want :)

-- try to capture the rewritten form
def z10eval := (z10.eval rewrite_by simp ; ring)

#reduce z10eval -- bleuch again!
#check z10eval

Tomas Skrivan (Sep 28 2023 at 23:27):

You should not use #reduce z10eval, calling #reduce fun n => 2 + n * 4 gives you the same result as you are reducing through multiplication on Nat. To see the value of z10eval do #print z10eval and you get

def z10eval :    :=
fun n => 2 + n * 4

as desired, you can also play with it using rewrite_by as

#check
  z10eval
  rewrite_by
    unfold z10eval

Tomas Skrivan (Sep 28 2023 at 23:48):

Kyle Miller said:

Tomas Skrivan mathlib has a lift_lets tactic, but I don't recall a conv version yet. There are conv tactics at least for reduce, whnf, projection unfolding, and a few others.

Nice! Much cleaner implementation then mine. For my use case I need few more reductions like splitting let a := (x,y); f a into let a1 := x; let a2 := y; f (a1,a2) but that should be easy to adopt.

Tomas Skrivan (Sep 28 2023 at 23:57):

@Kyle Miller Ideally I would like lift_lets simplifications happen when I run simplifier, any idea what would be the easiest way to do this?

Tomas Skrivan (Sep 28 2023 at 23:59):

I have lots of simp rules that create nested let bindings or let bindings like let a := (x, 0) which I want to split and eliminate the zero.


Last updated: Dec 20 2023 at 11:08 UTC