Preface
Acknowledgements
This course was developed with support provided in part by a research grant from the National Science Foundation, #1909414, SHF: Small: Explicating and Exploiting the Physical Semantics of Code.
Disclaimers
Any opinions, findings, and conclusions or recommendations expressed in this material are those of the author(s) and do not necessarily reflect the views of the National Science Foundation.
The views expressed in this article are those of the author(s) and do not necessarily reflect the views, policies, or positions of the University of Virginia.
This work expresses cetain technical juddgments by the author based on observation and experience but not always on outcomes of scientific testing. No IRBs have been needed or sought. No student or other human subjects data is reported here or has been reported outside of official reporting channels.
A Couple of Pillars
Computation and systematized reasoning are two great intertwined pillars of computer science. Consequently we have languages for expressing conputations, namely programming languages, and languages for reasoning about propositions over diverse worlds. For decades, we in computer science have excelled at teaching computational thinking and the use of programming languages. By contrast, we have done exceptionally poorly in teaching reasoning and the formal languages and thought processes needed to reason formally in practice.
The use of programming langauges is familiar territory even to the earliest computer science students. The very first course in computer science is invariably a course in programming, and implicitly in programming languages. Programming then remains one of the primary areas of emphasis throughout the entire undergraduate curriculum. Students of CS thus today generally graduate with high proficiency in computational thinking and in the use of programming languages that support it.
Recognizing the essential foundational character or reasoning, the second course in the undergraduate CS curriculum is typically called something like CS2: Discrete Mathematics and Theory (DMT1). It is in this course that students gain their first, and sadly usually their last, exposure to formal reasoning and languages and systems that support it. Such courses are generally paper-and-pencil affairs covering propositional logic, first-order predicate logic and set theory, and induction (but usually only on natural numbers).
The problem with the ubiquity of such courses are many. First, CS students tend ot find these courses boring, abstract, disconnected from their intrinsic motations to learn about computing, irrelevant to practice, and deeply forgettable. Anecdotally, most students entering graduate programs in CS appear remember almost nothing from their early DMT courses, and few have ever had to use reasoning langauges and methods after DMT1.
On the demand side, on the other hand, we are now seeing rapidly growing needs for engineers who do actually understand formal reasoning and languages. On the other hand, the supply of such talent is miniscule, due in large part to the failure to train our students in such knowledge and skills. Moreover, this explosion in demand for reasoning skills is happening at the same time we're seeing a significant drop-off in demand for "mere" computational thinking and programming.
The conclusions of the author include the following: (1) Our field has failed to train generations of graduating computer scientists in the thought processes and the formal languages needed to be productive with reasoning in theory or industrial practice. (2) The standard DMT1 course is, for far too many students, not a productive or memorable experience, as evinced by the exceptionally poor state of knowledge of most incoming graduate students in computer science (in the author's experience). (3) It is time to replace the standard DMT1 course with something entirely new, different, and far better. (4) It is time to think about re-balancing the entire undergraduate curriculum toward greater emphasis on mathematical abstractions and formal reasoning.
The course presented here is thus offered as a model for an entirely new approach. At the highest level, it teaches all of the core material in any DMT1 course but with all of the context formalized in the reasoning language, Lean 4. In languages like this, supported by wonderful tooling, reasoning is linked to comptuation by the amazing unification known as the the Curry-Howard Correspondence (CHC). The CHC holds that formalized deductive reasoning of certains kinds (natural deduction, which is perhaps the core concept in any DMT course) is a form of computing, but not only with the usual data and function types but with now axioms, propositions, and proofs as first-class citizens.
Lean 4 is so beautifully expressive of such a broad range of mathematical concepts that a significant community of mathematicians have organized around it to drive the development of formalized versions of mathematics across a very broad range of fields. Meanwhile, CS students remain stuck learning a logic (first-order predicate logic and set theory) that is not suitable as a foundation for formalized or automated abstract mathemtics. This course, on the other hand, adopts, type theory, here as implemented by Lean 4, as a far better choice, even for early CS students.
Some Problems
That we've arrived at a point where reasoning technology is advancing at extraordinary speed but where are students are by and large entirely unprepared to understand or use it. Of course, for many decades, the demand for programming was voracious, and at the same time cost and difficulty of reasoning were prohibitively high. But now the tables are turned. Generative and related AI promise to reduce demand for programming code while the needs of industry and national security are driving significant increases in demand for formal reasoning.
This course aims to help address the resulting shortfall in talent by radically replacing the traditional undergraduate DMT1 course with a new one, covering essentiall the same basic content, but now using the wildly successful reasoning and computation language and toolset of Lean 4. The course is scoped for a full undergraduate semester or as the first half of an introductory graduate course in formal languages and reasoning. Big changes in in circumstances make now a great time to consider such a transition in CS pedagogy. They include the following:
- Rapidly increasing industrial demand for formal, machine-supported and machine-checked reasoning about critical properties software-intensive systems that undergird our society
- The emergence of type-theory-based formalisms with exceptional expressiveness and broad applications that have attracted large communities of researchers in mathematics, which gtends to validate the proposition that there's something new and remarkable in them
- The development of superb tooling for using reasoning languages effectively in practice
- The profound intertwining of computation and reasoning afforded by such langauges
- The real possibility that mere routine programming will increasingly be done by "AIs"
New Approach
The idea is to simultaneously gain a deeper understanding of reasoning while also seeing it as a form computation, albeit now over reasoning rather than computational terms. For example, we begin with propositional logic---syntax, semantics, validity, soundness, etc.--through its standard deep embedding into (the logic of) Lean 4. A demonstrated strength of Lean 4 is in its enabling communities to express rich theories in the clear, abstract, generalized terms of the particular domain itself, across a wide range of domains in graduate- and beyond-level mathematics.
The entire course is set up this way. Predicate logic is presented through its standard shallow embedding in Lean 4. First-order logic is described as a special case. Set theory is built directly on predicate logic. Etc.
This course can then express generalized mathematical concepts, such as the property of a relation of being reflexive (or whatever). In a first-order course, you can formally express what it means for a particular binary relation, r, to be "reflexive." That's ok. But one really hopes the student will acquire is an understanding of the property itself: the property of any binary relation, r, on any set, s, of being reflexive. This generalized concept can then be applied to any particular relation to speak of its being reflexive, or not. The first-order theory of the traditional DMT1 course isn't expressive enough to represent generalized properties of higher-order things, such as relations, functions, or types. Lean is not much harder to learn and is really a better language for expressing and working with core DMT1 concepts. It's really better to learn from the outset to be able to say things like this: reflexivity is a property of any binary relation on any set of objects of any type. None of those anys can be said in first-order logic as one cannot quantify over relations, sets, or types.
It's not just a nicety, either, to have reflexive as a predicate on any binary relation on any set of terms of any type. It means that this predicate can be applied to any particular relation so as to produce the proposition that it is reflexive. The application of predicates to particulars is ubiquitous in formal reasoning.
Another principle is that all of the main concepts taught in the traditional course must be taught in the new course: propositional logic, predicate logic, sets, induction. This course covers the same topics but in different ways.
But it's not only topic coverage. Notations matter. Embeddings of mathematical concepts in Lean often come with the standard notations of the field as a paper-and-pencil affair. Differences in surface syntax in having to read and write in set theory as embedded in Lean and as learned using paper and pencil are minor, while the gain in capabilities at one's fingertips is substantial. They include automated syntax and proof checking, among other things. Having a superb supportive community of experts is a tremendous human asset.
Design Constraints
This course was developed under a few key constraints:
- Continue to focus on the basic content of the traditional course: logic, sets, induction
- Avoid assiduously overwhelming early students with the complexity of modern proof assistants
- Formalize every concept in the uniform logic of the proof assistant using conventional notations
- Ensure that first-order theory is a special case of the more expressive theory of the course
- Provide students with a deeply computational perspective, from great tooling to Curry-Howard
This Solution
The solution, now tested in practice (but not scientifically evaluated yet), has a few key elements:
- Make standard embeddings propositional and predicate logic in Lean a path to Lean 4 itself
- Begin with a deep embedding of propositional logic syntax, semantics, and validity in Lean 4
- Thoroughly cover all of the axioms for reasoning in predicate logic as its embedded in Lean 4
- Build all of the material on set theory (sets, relations, properties) on top of this logic
- Present induction first as a way to build functions and only later as a way to build proofs
- Minimize covereage of complex or inessential Lean; e.g., tactics are omitted until the end
An Example
Here are two simple examples of what students might see in this course.
The first illustrates how students would write propositional logic expressions.
- def andAssociative := ((P ∧ Q) ∧ R) ↔ (P ∧ (Q ∧ R))
- def equivalence := (P ↔ Q) ↔ ((P ⇒ Q) ∧ (Q ⇒ P))
This one, second, specifies the generalized property of a relation of being well founded.
- def isWellFounded {α β : Type} : Rel α α → Prop := fun r => ∀ (s : Set α), s ≠ ∅ → ∃ m, (m ∈ s ∧ ¬∃ n ∈ s, r n m)
By the end of the course students should be able to read and explain what this definition means, and apply it to particulars in the process of making richer claims about them. The undergraduate course does emphasize ongoing practice in the skills of translating between formal and natural natural language.
Status
The status of this online book is drafted work in progress. The material through the second chapter on induction reflects the content, edited, from my Fall 2024 undergraduate class offering. It's also the first part of an introductory graduate software logic course that I'm teaching this Spring of 2025. There are a few concept/chapter placeholders at the end for material not previously taught at all, including formalized reasoning about induction through well founded relations. (Students have learned and demonstrated the abilty to reason formally about properties of relations, though, and should be able to handle proofs involving this property without much more difficulty than they've already done involving other properties of relations). The entire document still needs a good editing. Some parts are still rough. A most notably rough one is the first, on talking about languages.
Evaluation
It is a challenge toto teach this course using Lean 4 for significant net advantage over more traditional courses. The hard problem, now cracked I hope, was reduce the added burden imposed by conceptually irrelevant aspects of Lean sufficiently to not impair students' capacity to learn the underlying concepts. At this point I believe that we can teach DMT1 using this kind of technology to the significant net advantage of our students, while covering all the essential concepts of any DMT1 class without undue haste.
Use for DMT1
A conservative offering could cover chapters on propositional logic, arithmetic, and induction for arithmetic function definitions, then skip theory extensions and SMT, and proceed to predicate logic, set theory, before a final return to induction generalized to proof construction. A possible capstone would be on termination via well founded relations, as they now have full command of the concept of properties of relations. I plan to reduce coverage of SMT by 80% on the next offering to have more time for this stuff at the end.
Student Paths
From here, advanced courses in several areas are possible at both undergraduate and graduate levels: cyberphysical systems, programming language design and implementation, verification, provable security, machine learning (e.g., see AlphaProof), robotics, quantum computing, etc.
Tool Paths
Working knowledge of the concepts of this course will provide students with easy access to understanding the concepts underpinning and the use of a broad range reasoning technologies. Dafny and Z3 are good next steps and there are surely others. A small enhancement to this course would be a much shorter "highlight" on Z3 then another on one Dafny, just to show that there's a whole new world of reasoning systems out there.
Research Paths
- Natural experiments potentially accessible (IRBs)
- Constructive mathematical concept inventories
Humility
There are surely problems and opportunities for improvement, in concept and presentation, here. Reach out if you wish.
Invitation
If you with to discuss, or alert us privately to possible issues, please do email me with DMTL4 in the subject line. It's my last name at Virginia, Edu.
© Kevin Sullivan 2024-2025.
Languages
Computer science is deeply connected with the concept of languages, particularly formal languages, which are languages where the valid forms of expressions are precisely specific, unlike so-called natural languages, such as English, where one typically has greater expressive freedom.
In this course, we will distinguish between two varieties of formal languages, in turn. Languages in the first class are mostly intended to support the specification of practical computations. These are the programming languages. The languages in the second class are weighted toward the expression of propositions, or claims about some work, and reasoning about them.
Elements
A language consists of several essential components that define its structure and meaning. These include:
- Syntax: The formal arrangement of symbols and symbolic expressions.
- Semantic Domains: The entities to which symbols and expressions refer, commonly referred to as denotations.
- Interpretations: The mappings that relate symbols to their denotations.
- Semantics: The relations that map entire expressions to their corresponding denotations.
Understanding these elements is fundamental to both natural and formal languages, as they provide the foundation for meaning and interpretation.
Moods
Languages can be classified by their moods, which reflect different communicative intents:
- Declarative (Indicative): Used to assert statements about reality (e.g., "It is the case that ...").
- Optative: Expresses a desired state of affairs (e.g., "I require it to be the case that ...").
- Imperative: Issues commands to achieve a certain action (e.g., "Make it the case that ...").
- Conditional: Specifies a dependency between conditions and outcomes (e.g., "If X is true, then it is the case that ...").
- Subjunctive: Expresses counterfactuals or uncertainty (e.g., "If only it were the case that ...").
- Exclamatory: Conveys strong emotions (e.g., "Oh my!").
- Interrogative: Seeks information through questioning (e.g., "Is it the case that ...?").
Purposes
Different languages serve different functional purposes, including:
- Human-Human Communication: Facilitating communication between people (e.g., English, Mandarin).
- Human-Machine Communication: Enabling humans to issue instructions to machines (e.g., Java, Python).
- Machine-Machine Communication: Standardized formats for automated data exchange (e.g., JSON, XML).
- Automated Reasoning: Languages designed for formal reasoning, program execution, and proof verification.
Natural vs. Formal
Natural Languages
Natural languages—such as English, Spanish, and Mandarin—evolve to maximize ease of communication among humans. However, they introduce challenges in rigorous reasoning due to inherent properties:
- Ambiguity: A single phrase may have multiple interpretations (e.g., "Shoes must be worn, dogs must be carried").
- Imprecision: Some instructions lack formal specificity (e.g., "Keep a reasonable distance from the next car").
- Computational Complexity: While natural languages have historically been difficult to process computationally, large language models (LLMs) are changing this dynamic.
Formal Languages
Formal languages—such as those used in logic, mathematics, and programming—are designed for precision and mechanical reasoning. Their primary benefits include:
- Rigor: Supporting exact definitions and structured reasoning.
- Computability: Enabling automated processing and verification.
- Application Scope: Used in programming, specification, verification, and mathematical proofs.
Imperative vs. Declarative
Imperative Languages
Imperative languages define step-by-step procedures for solving problems. These languages execute commands that transform mutable states and perform I/O operations. A classic example is a Python implementation of Newton’s method for computing square roots.
While imperative languages enable efficient execution, they often lack expressiveness compared to declarative approaches.
Declarative Languages
Declarative languages emphasize expressiveness over step-by-step execution. Key characteristics include:
- Syntax and Semantics: Clearly defined structure and meaning.
- Domains, Interpretations, and Evaluation: A well-defined mapping between symbols and their interpretations.
- Models:\n - Validity: An expression is valid if it is true under all interpretations.
- Satisfiability: An expression is satisfiable if at least one interpretation makes it true.
- Unsatisfiability: An expression is unsatisfiable if no interpretation makes it true.
- Expressiveness vs. Solvability Trade-offs: More expressive languages tend to pose greater computational challenges.
- Decidability: The problem of determining the validity of arbitrary expressions may be undecidable in some cases.
Formal Language Case Study: Propositional Logic
A classic example of a formal language is propositional logic, which serves as a foundation for automated reasoning.
Syntax
Propositional logic consists of:
- Literals: Atomic Boolean values (e.g.,
true
,false
). - Variables: Symbols that represent Boolean values (e.g.,
P
,Q
). - Operators: Logical connectives such as
AND
,OR
,NOT
,IMPLIES
.
Semantic Domain
Propositional logic is grounded in Boolean algebra, where expressions evaluate to either true
or false
.
Interpretations
- Fixed Symbols: Operators map to specific Boolean functions.
- Variables: Assigned Boolean values in a given interpretation.
Semantic Evaluation
Expressions are evaluated within a given interpretation, determining their truth values.
Properties of Expressions
- Validity: An expression is valid if it evaluates to
true
in all interpretations. - Satisfiability: An expression is satisfiable if there exists at least one interpretation where it evaluates to
true
. - Unsatisfiability: An expression is unsatisfiable if it evaluates to
false
in all interpretations.
Computations in Propositional Logic
Several computational tasks arise in propositional logic:
- Model Finding: Identifying an interpretation that satisfies a given expression.
- Model Checking: Verifying whether an interpretation satisfies a given expression.
- Validity Checking: Determining whether an expression is valid.
- Counterexample Finding: Identifying interpretations that falsify an expression.
Conclusion
Languages—whether natural or formal—play an essential role in communication, computation, and reasoning. While natural languages prioritize human usability, formal languages enable rigorous expression and mechanical processing. The study of formal languages, including logic and programming languages, continues to shape advancements in computing, verification, and artificial intelligence.
Propositional Logic
Syntax
This chapter presents our formal definition of the syntax propositional logic.
namespace DMT1.Lectures.propLogic.syntax
Abstract Syntax
The syntax specifies the set of all and only syntactically correct propositional logic expressions. Roughly speaking, the syntax defines "literal" and "variable" expressions and a collection of expression-composiing operators that take smaller expressions as arguments to yield larger expressions as results.
- literal expressions
- variable expressions
- operator (application) expressions
Remember: we're not talking about Boolean meanings of literals, variables, or bigger expressions here. The syntax of a formal languages defines only the set of syntactically correct expressions in our language and says nothing about their intended meanings.
Now what's interesting is that we formalized the set of all correct expressions as Expr. It's a data type. Now any particular logical expression is just a value of this Expr type. The available constructors describe all all and only the ways to construct a term of this type. Lean automatically checks the correctness of terms declared to be of this type. The Lean type checker thus now provides us with a syntax checker for propositional logic.
Let's dive down into literal, variable, and application (operator application, if you want) expressions and how we represent them in Lean.
Literal Expressions
The term literal expression refers to an expression that directly names a value. In propositional logic the two values of interest are the Boolean values, true and false.
Our implementation of propositional logic thus defines two literal expressions, each built by applying the expression constructor called lit to one the two Boolean values (Bool.true or Bool.false). Our language will thus include (lit true) and (lit false) as its two literal expressions. In logical writing it's common to use the concrete syntactic symbols ⊤ (pronounced "top"), as a notation for (lit true), and ⊥ (pronounced "bottom") for the expression (lit false).
Variable Expressions
Variable expressions are a little more complicated, but not much. Just as a lit expression is built from a Boolean value (and incorporated as a value into the resulting term), a variable expression is built from a variable (a value of a type we'll call var). Each var object in turn is built from a natural number (0, 1, 2, ...). The natural number in one var distinguishes that var from any var having a different natural number "index." This design provides us as future authors of masterpieces in propositional logic an infinite supply of distinct variables to use in writing our logical opus magni. So, again, lets dig down a little more.
Variables
We define var to be the type of variables as we've been using the term: they are not themselves expressions but are necessary values for making variable expressions.
abstract syntax for variables (var)
The idea is that eventually an interpretation function will take a Var object as arguments and return the Boolean values that that interpretation assigns to it.
structure Var : Type :=
mk :: (index: Nat)
deriving Repr
The structure keyword indicates we're defining a data type with just one constructor, here called mk. In this case, it takes one argument, a natural number. The result of applying var.mk to the number 3 is the term (var.mk 3), which we will take to mean "the particular variable from our set of infinitely many, built from the Nat value 3". Here's a shorthand notation we just made up. You can use var_[3], for example, to mean (var.mk 3). It't not a lot of help, but
The "deriving Repr" construct is a detail you can ignore for now. In a nutshell it tells Lean to try to define a function to convert any value of this type to a string for presenting values as properly formatted output strings. Anyway, a detail.
Concrete syntax for variables (var)
We could define our own concrete notation for variables, but Lean provides one automatically for any "structure" type. A structure type has exactly one constructor. You can give it a name, but Lean defines mk as the default. This constructor will take zero or more argument. So, to build a value of such a type you can use ⟨ a, b, c,... ⟩ notation as a shorthand for, say, "var.mk a b c ....". We do need to let Lean know the result show be a "var".
#check (⟨3⟩ : Var) -- it's a variable (var)
-- #check ⟨3⟩ -- not unique constructor expr
-- But where Lean can infer Var type ⟨3⟩ will suffice
Now we just defined variables expressions as expressions of the form (var_expr v). We define var_expr shortly. View var_expr as the method for constructing a variable expression from a variable. ANd given a variable expression, you can get the underlying variable back out to work with. We'll need that when it comes to defining interpretations as functions that take variables in and that return values from the semantic domain, here just of Boolean values.
Operator Expressions
Next we define the expression-composing operator (also called connectives) of propositional logic. Each such operator takes at least one expression as an argument and uses it in constructing a larger expression.
- applying the not operator to an expression e yields the expression (not e)
- applying a binary operator, op, to e1 and e2, yields the expression (op e1 e2)
- here, op can be and, or, not, implies, iff, and so forth
We call the only unary operator not, and the usual binary operators by their usual names. Remeber, these are meaningless symbols in the formal syntax of propositional logic. We give them names that will reflect their usual meanings when we define semantics for syntact expressions.
-- unary connective (syntactic expression composers)
inductive UnOp : Type
| not
deriving Repr
-- binary connectives (syntactic expression composers)
inductive BinOp : Type
| and
| or
| imp
| iff
deriving Repr
Now we get to the heart of the matter. With those preliminary dependencies out of the way, we now formally specify the complete (abstract) syntax of propositional logic. We do this by defining yet another data type. The idea is that values of this type will be (really will represent) expressions in propositional logic. In fact, in Lean, this type specifies the set of all and only the expressions legal in propositional logic.
Formal Syntax of Propositional Logic
inductive Expr : Type
| lit_expr (from_bool : Bool) : Expr
| var_expr (from_var : Var)
| un_op_expr (op : UnOp) (e : Expr)
| bin_op_expr (op : BinOp) (e1 e2 : Expr)
deriving Repr
Every type encloses the names of its constructors in a snamespace with the same name as the type. So Expr is now a namespace, and the constructor names (lit_expr, etc.) are referred to as Expr.lit_expr, etc. To avoid having to repeat that Expr. bit all the time, one can "open" the namespace. Just don't do this if it would result in names having multiple different definitions being "directly visible."
open Expr
Concrete Syntax: All the Usual Notations
A well designed, user tested, concrete syntax for a given formal language can be a huge aid to the human of abstract formal definitions in that language. We prefer to write (3 + 4) over (add 3 4), for example. We don't expect you to have justifiable confidence in your deep understanding of this notation stuff at this point! We encourge you to follow it carefully to get the gist.
-- (lit true) and (lit false) expressions
-- a variable expression constructed from from a variable
-- our single unary connective, *not* (¬)
-- we set it to have maximum precedence (binding strength)
Here are concrete notations for our binary connectives. The letter "l" after infix specifies left associativity. The numbers after the colons specify binding strengths. The de-sugared versions follow after the arrows.
notation:max " ⊤ " => (Expr.lit_expr true)
notation:max " ⊥ " => (lit_expr false)
notation:max "{" v "}" => (var_expr v)
notation:max "¬" p:40 => un_op_expr UnOp.not p
infixr:35 " ∧ " => Expr.bin_op_expr BinOp.and
infixr:30 " ∨ " => Expr.bin_op_expr BinOp.or
infixr:20 " ↔ " => bin_op_expr BinOp.iff
infixr:25 " ⇒ " => bin_op_expr BinOp.imp
That's it. That's the entire abstract and concrete syntax for predicate logic. Note that some definitions of the syntax of propositional logic do not include the literal expressions, ⊤ and ⊥. In that case one will just use variable expressions instead, taking care to fix the interpretations of these variables as unvaryingly either true or false.
end DMT1.Lectures.propLogic.syntax
Syntax
namespace DMT1.Library.propLogic.syntax
structure Var : Type where
mk :: (index: Nat)
deriving Repr
inductive UnOp : Type
| not : UnOp
deriving Repr
inductive BinOp : Type
| and
| or
| imp
| iff
deriving Repr
inductive Expr : Type
| lit_expr (from_bool : Bool) : Expr
| var_expr (from_var : Var)
| un_op_expr (op : UnOp) (e : Expr)
| bin_op_expr (op : BinOp) (e1 e2 : Expr)
deriving Repr
open Expr
notation:max " ⊤ " => (Expr.lit_expr true)
notation:max " ⊥ " => (lit_expr false)
notation:max "{" v "}" => (var_expr v)
notation:max "¬" p:40 => un_op_expr UnOp.not p
infixr:35 " ∧ " => Expr.bin_op_expr BinOp.and
infixr:30 " ∨ " => Expr.bin_op_expr BinOp.or
infixr:20 " ↔ " => bin_op_expr BinOp.iff
infixr:25 " ⇒ " => bin_op_expr BinOp.imp
end DMT1.Library.propLogic.syntax
Examples
The material in this chapter depends on our specification of the syntax of propositional logic. We enclose these definitions in a namespace, DMT1.lecture.propLogic.axioms, to avoid naming conflicts with definitions in other files. We open the namespace for both the syntax definitions and the specification of the syntax itself (Expr), so as to be able to use them here without prefixes.
import DMT1.Lectures.L02_propLogic.formal.syntax
namespace DMT1.Lectures.propLogic.axioms
open propLogic.syntax
open Expr
Variable Expressions
It'll suffice for our purposes to have three variable expressions. We'll call them P, Q, and R. The following definitions provide them, built on the variables indexed by 0, 1, and 2, resp. See our concrete syntax definitions.
Note that the first example of a definition of a variable expression is written entirely using abstract syntax. We apply Var.mk to the natural number index, 0, to create a Var object that we then use to build a variable expression by giving it as an argument to the Expr constructor used to build variable expressions. The next two examples use our concrete notations. Note: the expression, (Var.mk 1), uses parentheses, while the expression ⟨2⟩ uses angle brackets. Look closely. Lean provides this angle bracket notation as a shorthand for applying the "mk" constructor of a structure type to its arguments.
def P : Expr := var_expr (Var.mk 0) -- Lean abstract syntax
def Q : Expr := var_expr ⟨1⟩ -- Lean concrete: Var.mk 1
def R : Expr := {⟨2⟩} -- Our concrete: Expr.var_expr ...
Operator Expressions
Now that we have a few elementary expressions (we can call them propositions) we present a set of propositions that turn out to be important. For now, they're just examples of syntactically correct expressions in the syntax of our little language.
def and_intro := R ⇒ Q ⇒ R ∧ Q
def and_elim_left := R ∧ Q ⇒ R
def and_elim_right := R ∧ Q ⇒ Q
def or_intro_left := R ⇒ R ∨ Q
def or_intro_right := Q ⇒ R ∨ Q
def or_elim := Q ∨ R ⇒ (Q ⇒ P) ⇒ (R ⇒ P) ⇒ P
def not_intro := (R ⇒ ⊥) ⇒ ¬R
def not_elim := ¬¬R ⇒ R
def imp_intro := R ⇒ P ⇒ (R ⇒ P)
def imp_elim := (R ⇒ P) ⇒ R ⇒ P
def equiv_intro := (R ⇒ P) ⇒ (P ⇒ R) ⇒ (R ↔ P)
def equiv_elim_left := (R ↔ P) ⇒ (R ⇒ P)
def equiv_elim_right := (R ↔ P) ⇒ (P ⇒ R)
def true_intro := ⊤
def false_elim := ⊥ ⇒ P
As we'll later, this is not a collection of arbitrary propositions (expressions), but of propositions that we can take as defining a set of elementary principles for valid reasoning. For now, though, we can take them as good examples of expressions we can write now using the syntax we've defined.
end DMT1.Lectures.propLogic.axioms
Semantics
The idea of semantics in Propositional Logic is simple: provided that we have a function that maps each variable expression to a Boolean value, then every expression in propositional logic has a Boolean value as its meaning. We will call such a function a variable interpretation.
For literal expressions the mapping to Boolean values is fixed: the meaning of ⊤ is Boolean true, and for ⊥ it is Boolean false. For variable expresssions, given an additional variable interpretation function, the meaning of a variable expression is the meaning assigned to it by a variable interpretation function.
Next, the connectives of propositional logic also have meanings, which are Boolean functions. For example, the syntactic symbol ∧ has as its semantic meaning the Boolean and function, often written as &&. The meanings of the other connectives (operators) of predicate logic are defined similarly: ∨ means ||, ¬ means !, and so forth.
Finally, expressions built from smaller expressions using the logical connectives have meanings that are determined compositionally. Given an expression, e1 op e2 (let's call it e), its meaning is determined by first getting the Boolean meanings of e1 and e2 and by then applying the Boolean function that is the designated meaning of op. That's it!
import DMT1.Lectures.L02_propLogic.formal.syntax
import DMT1.Lectures.L02_propLogic.formal.domain
namespace DMT1.Lectures.propLogic.semantics
open propLogic.syntax
Fixed Interpretation of Unary Connectives
The first thing we'll do is define what Boolean operators we mean by the names of our unary and binary "conenctives".
-- function takes unary operator and returns *unary* Boolean function
-- (Bool -> Bool) means takes *one* Bool argument and "returns" a Bool
def evalUnOp : UnOp → (Bool → Bool)
| (UnOp.not) => Bool.not
Fixed Interpretation of Binary Connectives
- takes a binary operator and returns corresponding binary Boolean function
- (Bool → Bool → Bool) is the type of function that takes two Bools and returns one
def evalBinOp : BinOp → (Bool → Bool → Bool)
| BinOp.and => Bool.and
| BinOp.or => Bool.or
| BinOp.imp => domain.imp -- DMT1.lecture.propLogic.semantics.domain.imp
| BinOp.iff => domain.iff -- likewise
Interpretations: Variable Semantics of Variable Expressions
We've now understood that an "interpretation" can be understood to be and can at least here actually be used as a function that takes a variable (var) as an argument and that returns the Boolean value that that interpretation assigns to it.
To understand the next line, understand that (var → Bool) in Lean is the type of any function that takes a var argument and returns a Bool value. Here we just give this type a name to make subsequent code just a easier for people to read and understand.
abbrev Interp := Var → Bool
open Expr
Evaluation: Operational Semantics of Predicate Logic
NB: This is the material you most need and want to grok.
Finally now here is the central definition: the semantics of propositional logic, specified in terms of our representations of interpretations, variables, etc.
The first line defines evalBoolExpr to be some function taking an expression, e, and an interpretation, i, as arguments and returning the Boolean meaining of e in the "world" (binding of all variables to Boolean values) expressed by that i.
def eval : Expr → Interp → Bool
| lit_expr b, _ => b
| (var_expr v), i => i v
| (un_op_expr op e), i => (evalUnOp op) (eval e i)
| (bin_op_expr op e1 e2), i => (evalBinOp op) (eval e1 i) (eval e2 i)
Standard Notation for Semantic Evaluation Operator
The standard notation for (eval e i) is ⟦e⟧ᵢ, where ⟦⬝⟧ is notation for the semantic evaluation function, eval. In Lean we'll write ⟦e⟧i, without i being a subscript.
notation "⟦" e "⟧" i => (eval e i)
#check (e : Expr) → (i : Interp) → ⟦e⟧i
That's it. From this material you should be able to aquire a justifiably confident grasp of essentially every aspect of the syntax and semantics of propositional logic.
end DMT1.Lectures.propLogic.semantics
Interpretations
- Defining Interpretations
- Generating All Interpretations for an Expression
- Helper Functions
- API: Get List of All Interprations for Expression
import DMT1.Lectures.L02_propLogic.formal.utilities
import DMT1.Lectures.L02_propLogic.formal.semantics
namespace DMT1.Lectures.propLogic.semantics
open propLogic.syntax
open propLogic.utilities
An interpretation, i, in predicate logic is a function from variables to Booleans;. That is just how we represent on in Lean: as a value of type Interp, an abbreviation for the type of total functions from Var to Bool, Var → Bool.
Recall that in our specification, a variable expression is constructed from a variable. All variable expressions built from the same variable will thus be assigned the same value.
Defining Interpretations
Suppose for example that we want to evaluate an expression under an interpretation that assigns the value, false, to every variable, and thus to every variable expressions. We can define such an interpretation in one line
private def allFalse : Var → Bool := fun v => false
The lambda expression, fun v => false, which can also be written as λ v => false, denotes the function that takes any variable, v as an argument and that returns false. The yellow squiggly line under v is Lean warning that we don't use v in the definition of the return value. We can in any such case remove the argument name, v, replacing it with an underscore, _, to specify that we won't be using this value in the subsequent computation.
In any case, with this interpretation in hand, we can now evaluate an expression under it. We can use Lean's reduce command to apply eval to an expression,, e, and to i, to answer the question, does i satisfy e?
private def e : Expr := {⟨0⟩} -- expression on 0th variable
#reduce eval e allFalse -- expect false
#reduce ⟦(¬e)⟧ allFalse -- expect true (our notation)
#reduce ⟦(e ∨ ¬e)⟧ allFalse -- expect true
#reduce ⟦(e ∧ ¬e)⟧ allFalse -- expect false
We can also easily define the all-true interpretation,
private def allTrue : Interp := λ _ => true -- _ for don't care
#reduce ⟦e⟧ allTrue -- expect true
#reduce ⟦(¬e)⟧ allTrue -- expect false
#reduce ⟦(e ∨ ¬e)⟧ allTrue -- expect true
#reduce ⟦(e ∧ ¬e)⟧ allTrue -- expect false
Now suppose we want an interpretation for an expression with two variable sub-expressions. One way to define such an interpretation function is by case analysis on variable indices. Here's an interpretation that assigns true to the 0th variable, false to the 1st variable, and true to every other (irrelevant) variable.
private def tf : Interp :=
fun v =>
match v with
| (Var.mk 0) => true -- using our abstract syntax
| ⟨1⟩ => false -- using Lean's notation for mk
| _ => true -- default for all other variables
def f : Expr := {⟨1⟩}
#reduce ⟦(e ∧ f)⟧ tf -- expect false
#reduce ⟦(e ∨ f)⟧ tf -- expect true
#reduce ⟦(e ⇒ f)⟧ tf -- expect false
#reduce ⟦(f ⇒ e)⟧ tf -- expect true
Generating All Interpretations for an Expression
We we soon be at the point where we'll want to generate all possible interpretation functions for any given collection of variables. Given that an expression with n distinct variable expressions has 2^n interpretations (ignoring valuations for variables beyond the range of those appearing in the expression), it'd be a real chore to have to write exponential numbers of explicit definitions.
To address this problem, we will define functions for building interpretation functions from simple specifications.
For example, we'd like to have a function that takes any expression, e, and returns a list of all 2^n of its possible interpretations. This will enable us to compute truth tables for expressions, where each interpretation corresponds to one row of a truth table except for the final output value, which is computed by applying eval to e* and to the interpretation that the corresponding row of variable values in the truth table represents.
The implementation is involves some details. We break the presentation of our specification of this function into two parts: all the helpful functions, followed by the main function of interest. For those looking just to use this function, you may skip the next section on the underlying details and jump ahead to the definition of the "API" that this module provides for end users.
Helper Functions
One key helper function in our implementation takes a list of Boolean values in positions with indices from 0 to n-1 (for an expression, e, with n variable expressions), and returns an interpretation that maps the variable with index 0 ≤ k ⋖ n-1 to the Boolean value in the k'th position in the array of Boolean values.
We'll build such a final interpretation iteratively, by starting with an interpretation, e.g., allFalse, then iteratively overriding the value that it assigns to the k'th variable, with k increasing from 0 to n-1. Variables with greater indices are irrelevant as they do not appear in e, and so default to the values of the interpretation provided as a starting point (e.g., allFalse).
Overriding Variable-Value Assignments
The key operation in all of this is overrideBValue, as defined next. It takes an interpretation function as an input along with a variable and a value to be assigned to that variable, and returns a new interpretation that is just like the given one except that it now assigns the specified value to the specified variable. Study this function with some care. See that the returned function returns the new value for the variable being bound to a new value, otherwise the new function just calls the old function to return the value of any other variable.
private def overrideValue : Interp → Var → Bool → Interp
| i, v, b =>
λ (v' : Var) =>
if (v'.index == v.index) -- if index is variables overridden
then b -- return new value
else (i v') -- else value under old interp
Here's an example. Remember that e is a variable expression build on the variable (Var.mk 0) with index 0. Here we start with allFalse and override it to assign true as the value of this variable. When we evaluate e on the resulting interpretation we'll see that we get the value, true.
#reduce ⟦e⟧ allFalse -- expect false
def newInterp := overrideValue allFalse ⟨0⟩ true -- override
#reduce ⟦e⟧ newInterp -- expect true
Constructing an Interpretation from a List of Bools
With this function value override function in hand, we can now define a function that takes a list of Bool values (using Lean's "parametrically polymorphic" List type) and that returns an interpretation as just described. It works by passing the number of variable values to be overridden (the length of the list of Bools) and that list to a helper function that takes the number of variable values and this list and that uses recursion to iteratively override variable k with the corresponding list entry.
Note that the helpder function is defined by cases that match on two arguments. The first pattern matches when the list of Booleans to assign is empty, in which case this function just returns the allFalse interpretation. Otherwise, if the number of variables to bind is non-zero, and the list of values has h (for head of list) as its first value and t (for tail) as the rest of the list), then it overrides the value of the next variable with that value at the head of this list. It may be possible to improve this definition. If you do that, please send a pull request.
private def interpFromBools : List Bool → Interp
| l => boolListToInterp l.length l
where boolListToInterp : (vars : Nat) → (vals : List Bool) → Interp
| _, [] => (λ _ => false)
| vars, h::t =>
let len := (h::t).length
overrideValue
(boolListToInterp vars t)
(Var.mk (vars - len)) h
Here's an example, where nextInterp assigns true to the variable with index 0 (and thus as the value it assigns to e), false to the variable with index 1, and then false to every variable at any higher index.
def nextInterp : Interp := interpFromBools [true, false]
#eval ⟦e⟧ nextInterp -- expect e to evaluate to true
Nth Interpretation for Expression With k Variables
A key insight behind our design is that each row of a truth table (without the final column containing the value of the expression) represents an interpretation. Moreover, the Boolean values in each row can be made to correspond to the Boolean digits (bits) in the binary expansion of the row index.
For example, if e includes two variable expressions, it will have four rows with indices from 0 to 3, with binary expansions of 00, 01, 10, 11. The bits in each of these numerals are exactly the Boolean values to be assigned to each of the two variables by each of the respective interpretations.
The following function uses these observations to take a row index and the number of variables in an expression and return an interpretation corresponding to that row. It works by obtaining a list of Booleans for the indexed row (computed by another helper function) and converting it to an interpretation as described above.
private def interpFromRowNumVars : (nth: Nat) → (numVars: Nat) → Interp
| index, vars =>
interpFromBools
(listBoolFromRowIndexForNumVars index vars)
All Interpretations for Expression With k Variables
The next function takes a number of variables in an expression and returns a list of all 2^n interpretations for that number of variables. Be careful as the size of the constsructed list is exponential in length in the number of variables.
private def interpsFromNumVars (numVars : Nat) : List Interp :=
mk_interps_helper (2^numVars) numVars
where mk_interps_helper : (rows : Nat) → (numvars : Nat) → List Interp
| 0, _ => []
| (n' + 1), v => (interpFromRowNumVars n' v)::(mk_interps_helper n' v)
Printable Representation for Interpretation Functions
Next we define two helper functions, available for use by clients of this module, for mapping an interpretation (which as a function is unprintable) to printable forms, namely to a list of 0/1 natural numbers reflecting the values assigned to the variables with corresponding indices.
private def bitListFromInterpHelper : (i : Interp) → (w : Nat) → List Bool
| _, 0 => []
| i, (w' + 1) => bitListFromInterpHelper i w' ++ [(i ⟨w'⟩)] -- ++ is List.append
#reduce bitListFromInterpHelper allFalse 3 -- expect [0, 0, 0]
This function takes any list of interpretations and returns a list of bit lists, one for each of the interpretation.
private def bitListsFromInterpsHelper : List Interp → Nat → List (List Bool)
| [], _ => []
| h::t, n => bitListFromInterpHelper h n::bitListsFromInterpsHelper t n
Lean knows how to print lists of natural numbers, and lists of lists of natural numbers, so these functions can be used to derive printable forms of interpretation and lists thereof. Remember that an interpretation in our design binds values to an infinite number of variables, of which only a finite initial sub-list of variable-to-value bindings is relevant. We provide the number of relevant variable bindings for which outputs should be generated as the second argument to this function.
#reduce bitListsFromInterpsHelper (interpsFromNumVars 3) 3
By can ask for bindings for more variables than are relevant, and in this case, we'll get default values from the initial interpretation that was overriden with new values for all of the relevant (first n) variables. Here, we started with the allFalse interpretation, so the values beyond the three that we care about in this example will be false. These values are ignored during evaluation and so are irrelevant and should be considered as an implementation detail in our design.
#reduce bitListsFromInterpsHelper (interpsFromNumVars 3) 5
Counting the Number of Variables in an Expression
To compute a list of all interpretations for given expression, e, we just need to know the number of variable expressions in e. That number, in turn, is one more than the highest variable index value for an variable used in a variable expression in e. As examples, the answer for P ∧ P would be 1; for ⊤ it'd be 0, as there are no variable expressions in the expression, ⊤.
This function definition provides a nice example of case analysis on the structure of the expression argument, e. If it's a literal expression, the answer is 0. If it's a variable expression built from the variable (Var) with index i, the answer is i + 1. For a unary operator expression, (op e), it's the number of variables in e. And for a binary operator expression, (op e1 e2) it's the maximum of the number of variables in each of e1 and e2.
private def numVarsFromExpr : Expr → Nat :=
(fun e => maxVariableIndex e + 1) where
maxVariableIndex : Expr → Nat
| Expr.lit_expr _ => 0
| Expr.var_expr (Var.mk i) => i
| Expr.un_op_expr _ e => maxVariableIndex e
| Expr.bin_op_expr _ e1 e2 => max (maxVariableIndex e1) (maxVariableIndex e2)
API: Get List of All Interprations for Expression
Given a Expr, e, return a list of all of its 2^n interpretations in ascending order (from all-false to all-true). This function works by computing a list of all interpretations for n variables where n is the number of variables in e.
def interpsFromExpr : Expr → List Interp
| e => interpsFromNumVars (numVarsFromExpr e)
In addition we provide public versions of the functions for deriving printable representations of interpretations.
def boolListFromInterp := bitListFromInterpHelper
def boolListsFromInterps := bitListsFromInterpsHelper
-- Example
def anExpr := ({⟨0⟩} ∧ {⟨1⟩} ∨ {⟨2⟩}) -- P ∧ Q ∨ R
#reduce boolListsFromInterps (interpsFromExpr anExpr) 3
As a final note, if you improve this module's implementation, please send me a pull request!
end DMT1.Lectures.propLogic.semantics
Model Theory
THIS SECTION IS UNDER CONSTRUCTION.
The semantics of propositional logic enable the evaluation an expression under an interpretation (for it). As we've seen, an interpretation in propositional logic is in the form of a function from the variable expressions in the expression to the Boolean values that the interpretation is said to assign to them.
Interpretations as World Representations
There's another way to see the interpretations for a given proposition, where each one specifies one specific possible world (or state of affairs), within which the truth of the proposition is to be evalauted.
Example: The Dog is Dandy or The Cat is Comfy
Suppose we use D to stand for the proposition, the dog is dandy, and C for, the cat is comfy. Now consider the proposition, C or D. There are four worlds within which it can be evaluated. The proposition, understood as asserting that at least one of the conditions, C or D, holds in a given world. This statement is true in only three of the four worlds, exluding as a counterexammple the one where neither is true.
Propositions as Specifying Properties of Worlds
We can view our or expression as specifying the subset of all of those possible (here four) worlds that have the property of having at least one of D or C being true in that world. We thus have a first indicition of a notion of model theory. It has to be about models of propositions.
Model Worlds and Counterexamples Worlds
We call a world in which a proposition is true a
model for the proposition, and a world in which
it's not true a counterexample.
Generalizing from Propositional Logic
At the heart of all of this is the idea that when syntactic expressions have elements with late-bound meanings, we need to provide additional structures, descriptions of specific worlds, states of affairs, within, or under, which evluation can be performed. Moreover, when a proposition is true within a world, that world is called a model,, and when false, it's called a counterexample.
Propositional Logic as a Special Case
In propositional logic, these structures are just our interpretation functions. Other logics use other structures. Nevertheless, across many logics, the structure over which a proposition is evaluated can be understood as specifying a particular world, or state of affairs, within which the truth of a given proposition can be evaluated.
Validity as A Central Concept in Logic and Maths
Now we come to a truly central idea. Sometimes, we want a proposition to defines not so much a property of certain selected worlds, but to express a more general form of reasoning that we expect that to be valid in all possible worlds.
An an example, the proposition C or D is not valid in propositional logic because there is one world where it's false: where both propositions are false. On the other hand, we can reasonably accept the idea that if C and D is true, then C is: that is. C ∧ D → C. Let's call this expression, e.
We can easily see that e is valid by showing that it's true in in all four possible worlds. We form each of the four interpretations, then evaluate e under each of them, to find that e is alwasy true.
- when w := (C := ⊤, D := ⊤) e* is true
- when w := (C := ⊤, D := ⊥) e* is true
- when w := (C := ⊥, D := ⊤) e* is true
- when w := (C := ⊥, D := ⊥) e* is true
As is true in all worlds, it's said to be valid. We can now rightfully say that C ∧ D → C is not only a valid proposition, but a theorem, or that it's a reasonable, rational principle for reasoning under any any circumstances, in all worlds.
When we prove that a proposition in mathematics is a theorem, we are proving that it's valid. We can thus reasonably say it is a theorem that if C and D are any propositions then C ∧ D → C is always true. It's valid. It's a theorem (in propositional logic).
A useful logic is one that defines a set of such rules for reasoning that are both valid individually and that when used together can never produce a contradiction except from assumptions that are themselves false.
Example
Now would be a good time to revisit the examples of syntactically correct propositions from a few chapters back. The ones we picked are mostly valid, and in fact form the basis for reasoning in many related logics. Moreover the collection of the valid propositions will provide us with a partial foundation for reasoning in predicate logic, where that can be infinite spaces of worlds, and where validity can no longer be assured by semantic evaluation of a finite number of worlds.
Exercise: Determine which of the syntactically correct propositions in the bare code file are not valid, and for each of these expressions, give a counterexample in the form of an interpretation as a set of variable to value bindings as used just above.
Further Study and Next Steps
We've now given as much of an introduction as we can in this setting to the topic of model theory in teneral. Interested students might look at the Kripke structure over which propositions in some temporal logics are evaluated.
The Rest of This Chapter
For the rest of this chapter, we will focus on model theory for proposition logic, only. We will formally specify what validity means by brute for evaluation of a given expression over all possible inpretations for it and then summing of the results.
In addition to being valid, we will also formally being satisfiable (having at least one model), and being unsatisfiable as having no models at all. We will then define procedures for checking whether all worlds are models (validity), whether at least one world is a model (satisfiability), or whether no worlds satify an expression to check for its being unsatisfiable.
Our discussion is already getting interesting, in that we've now viewed interpretations as representations of different worlds, propositions as specifying properties that a given world might more might not have, and now we're talking about properties of propositions (of being valid, satisfiable, or unsatisfiable). The rest of this chapter formalizes these ideas in executable forms, providing us with tools for automated reasoning about whether any given proposition has or lacks any of these properties.
Exercise
How many worlds are there in the space of worlds contemplated by an expression in propositional logic with n distint variable expressions?
Models
As a final chapter in our unit on propositional logic, we now present the concepts of models and counter-examples.
import DMT1.Lectures.L03_modelTheory.truthTable
namespace DMT1.Lectures.propLogic.semantics.models
open propLogic.syntax
Given a proposition (Expr), e, and an interpretation for the variables in e, we can apply our semantic evalation function, ⟦⬝⟧, to e and i, to compute the truth of e under i.
A model is an interpretation that makes a proposition true. This function takes an interpretation, i and an expression, e and returns true if i is a model for e, otherwise it returns false.
def isModel (i : Interp) (e : Expr) : Bool := ⟦e⟧i
A "SAT solver" returns true for an expression, e, just when there is at least one interpretation for e that is a model: under which e evaluates to true. We can think of a model as a solution to the problem posed by an expression: to find an assignment of values to its variables under which it evaluate to true.
Here's a brute force function that if given an expression, e, returns a list of all of its models. (The list filter function takes a list of elements and a function that takes and element and returns true or false, and that returns the list of all of those elements for which this function returns true. It's possible, of course, for an expression to have no models, in which case the returned list will be empty.
def findModels (e : Expr) : List Interp :=
List.filter
(fun i => ⟦e⟧i)-- given i, true iff i is model of e
(interpsFromExpr e)
A typical model finder will search for a model and return the first one it finds. This function computes the list of all interpretations and returns either some interpretation or none, encoded as a value of the type, Option interpretation. You can hover your mouse cursor over "Option" to read the documentation string for this type.
def findModel : Expr → Option Interp
| e =>
let ms := findModels e
match ms with
| [] => none
| h::_ => h
end DMT1.Lectures.propLogic.semantics.models
Truth Tables
Given expression, e, a truth table for e is a list of all 2^n interpretations for e with each one paired with the value of e under it. The primary function that this module defines takes an expression and returns the list of its output values under each interpretation for that expressiom. From this information a truth table can be assembed.
import DMT1.Lectures.L02_propLogic.formal.interpretation
namespace DMT1.Lectures.propLogic.semantics.models
open propLogic.syntax
Compute and return the list of Bool values obtained by evaluating an expression, e, under each interpretation, i, in a given list of them.
def mapEvalExprInterps : Expr → List Interp → List Bool
| _, [] => []
| e, h::t => (⟦e⟧h)::mapEvalExprInterps e t
Return the list of Bool values obtaibed by evaluating an expression, e, over each of its interpretations, in their natural order.
def mapEvalExprAllInterps : Expr → List Bool
| e => mapEvalExprInterps e (interpsFromExpr e)
-- just another name for this function
def truthTableOutputs := mapEvalExprAllInterps
end DMT1.Lectures.propLogic.semantics.models
Counterexamples
import DMT1.Lectures.L03_modelTheory.models
namespace DMT1.Lectures.propLogic.semantics.models
open propLogic.syntax
Final All Counterexamples
A counterexample is an interpretation that makes a proposition false. If you write a specification, S, about a system in the form of a proposition that should be true of all possible system behaviors, you'd like to know if there are any behaviors that do not satisfy the specification. Such a behavior would be a counterexample to the specification. So how might we put together a method for finding a counterexample if there is one?
def findCounterexamples (e : Expr) : List Interp := findModels ¬e
Find One Counterexample
def findCounterexample (e : Expr) : Option Interp := findModel ¬e
These functions use types you don't yet know about: namely List and Option. You should understand lists intuitively from CS1. You can think of an option as a list of length either zero (called none) or one (called some e), where e the specific value in the length-one list of values (an interpertation).
end DMT1.Lectures.propLogic.semantics.models
Properties of Propositions
We built a satisfiability checker. The procedure it implements decides whether any propositional logic expression, e, has at least one interpretation, i, such that (i e) is true. It works by generating all 2^n intepretation for any set of n propositional variables, evaluating the expression under each interpretation, then returning true if and only if any of the results are true.
With the same underlying machinery we can easily implement what we will decision procedures that similarly answer two similar questions: does a given expression, e, have the property of being unsatisfiable? And does "e" have the property of being valid.
import DMT1.Lectures.L03_modelTheory.truthTable
-- TODO: Fix namespace names in this unit
namespace DMT1.Lectures.propLogic.semantics.models
open propLogic.syntax
open propLogic.utilities
Satisfiability
Satisfiability means there's some interpretation for which e is true
def is_sat : Expr → Bool :=
λ e => reduce_or (truthTableOutputs e)
Unsatisfiability
def is_unsat : Expr → Bool :=
λ e => not (is_sat e)
Validity
Validity means that a proposition is true under all interpretations
def is_valid : Expr → Bool :=
λ e => reduce_and (truthTableOutputs e)
end DMT1.Lectures.propLogic.semantics.models
Semantic Validity
UNDER CONSTSRUCTION
Validity
Validity is an incredibly important property to understand. Whether is propositional or some other logic, an expression is said to be valid if it's always true, which is to say it's true in all possible worlds, independent of choice of domain, and of interpretations into any domain.
A domain where validity is absolutely central is mathematics. A theorem is a proposition in a formal language for which there is a proof that it is valid. We will often say informally that a theorem has been proven to be true. What we mean by that the proposition is true in all of the possible worlds to which the elements of the proposition might refer.
Semantic Validity for Propositional Logic
At this point, we've established a definition of what it means for a proposition in propositional logic to be valid: that it is true under all possible intepretations: in all possible world states, as it were.
Predicate Logic Changes the Game
In the next unit of this class, we will meet a much more expressive logic: namely predicate logic. This is the language of the working mathematician, software specifier, symbolic AI coder, and many others.
The Domain is Variable
Predicate logic is far more expressive language in several ways. First, its semantic domain is now a variable. In order to evaluate the truth of a proposition, the domain must be defined, along with an interpretation of variables as objects in the selected domain.
It has Existential and Universal Quantifier Expressions
In addition, predicate logic extends the propositional logic with universall and existential quantifier expressions, of the formm, ∀ (x : α), P x and ∃ (x : α), P x. The first can be read as, every x of type *α satisfies the predicate, P; and the latter, as "some (at least one) x satisfies P."
It has Predicates: Abstractions from Families of Propositions
A predicate, in turn, is parameterized proposition. It's a proposition with some remaining blanks to be filled in. We will represent predicates as functions. One applies a predicate to an argument to fill in the blanks where that choice of value is required.
Here's an example. I propositional logic, I could define three propositions, TomIsHappy, MaryIsHappy, and MindyIsHappy. There is way in propositional logic to abstract from this family of apparently close related propositions to a single predicate, XIsHappy, where X is a parameter. Now applying XIsHappy to a particular person, Ben, would reduce to the proposition, no longer a predicate, that BenIsHappy, as a special case.
Validity is No Longer Semantically Definable
In propositional logic, there's just one semantic domain, Boolean algebra, and only two semantic values to which variable expressions can be referred by an interpretation. Expressions, as values of an inductive type, are always finite in size, so there can only be a finite number of variable expressions in any given larger expressions, so there is always a finite number of interpretations for an expression in propositional logic. We can thus decide whether any proposition is valid algorithmically (which means with an always finite computation).
That is no longer the case when we get to predicate logic. There's now an unbounded number of possible domains. You can se predicate logic to talk about anything. Predicate logic is a "bring your own domain" formal language! Such domains need not be finite (e.g., we can take real= numbers as a values to which some variable expressions refer. Or the same expression might be interpreted as asserting that some condition is true of traffic in Boston. Cearly we can no longer test an expression for validity by evaluating it under a finite number of interprtations.
How can we show that a given proposition is true for every possible interpretation (every possible world state) in every possible domain? We need something different.
Arithmetic
In this chapter we adapt the what we've learned in the chapter on propositional logic syntax and semantics to specify the syntax and semantics of a simple language of natural number arithmetic. The language we specify here will have two distinct kinds of expressions.
An expression of the first kind of expression evaluates to a natural number. Consider 2 + X, for example. The operator symbol, +, tells us that this expressions has a natural number as its semantic meaning: namely, the sum of 2 and whatever (now numberic) value X has under a given interpretation. We will refer to + and similar symbols as arithmetic operators, and to expressions that use them as arithemtic operator expressions.
An expression of the second kind will evaluate not to a numeric value but to a Boolean,. Consider 2 < X as an example. The standard symbol, < tells us that we can expect this expression to evaluate to a Boolean value: true if 2 is less than whatever value X evaluate to, and to false otherwise. We will refer to symbols such as < as predicates, and expressions such as 2 < X as predicate expressions.
We will use the phrase arithmetic expression to refer to either an operator or a predicate expression in our little language of natural number arithmetic.
One of the main insights that you can expect to take away from this chapter is that you already knew how to formally define a language such as this one by simple adaptation of the lessons of the preceding chapter on the syntax and semantics of propositional logic.
import Mathlib.Data.Set.Basic
import Mathlib.Logic.Relation
namespace DMT1.Lectures.natArithmetic.domain
Domain: natural number arithmetic
The Nat Type
We use Lean's definition of the Nat type.
#check Nat
The Nat type defines terms that we understand to represent natural numbers. Nat.zero represents 0, while Nat.succ, applied to any Nat value, n, represents the natural number n + 1.
inductive Nat where | zero : Nat | succ (n : Nat) : Nat
-- Examples of such terms
def z : Nat := Nat.zero
def o : Nat := Nat.succ Nat.zero
def o' : Nat := Nat.succ z
def th : Nat := Nat.succ (Nat.succ (Nat.succ Nat.zero))
Lean provides sophisticated built in support for concrete notations (base 10 numerals) for natural numbers. The natural numbers that we represent in base 10 as 0, 1, and 3 can thus be written in just this way in Lean: as 0, 1, and 3. But you should now see these numerals, in Lean, as de-sugaring to terms of type Nat.
#reduce 0 -- Notation for Nat.zero
#check 1 -- Notation for (Nat.succ Nat.zero)
Nat Operations
We will use the term, operations, here to refer to functions from one or more natural numbers to natural number results. That is, operations, in our lexicon, will refer to what in algebra are known as endomorphisms, which is the name used for functions that map elements from a given set back into the same set.
Unary Operations
Here are straightforward definitions of three unary operations: the identity function, the increment (++) function, and the predecessor function, which does the usual thing unless applied to 0 in which case it just returns 0 itself, as there's no small Nat value that it could return.
The concept to focus on here is pattern matching and its use to destructure terms of the Nat type so that desired results can be returned. The id function does not need to destructure its argument. Nor does inc. But when applied to any non-zero Nat term the dec function matches the incoming argument as the successor of some number; that number is temporarily given the name, n', and that is the value that the function then returns.
def id : Nat → Nat
| n => n
def inc : Nat → Nat
| n => Nat.succ n -- Nat.succ n
def pred : Nat → Nat
| 0 => 0 -- Nat.zero
| Nat.succ n' => n' -- Nat.succ n'
Pattern matching can do more than to strip away a single constructor application to reveal, and name, the terms that were its arguments. Here is pred2, a function that for 0 or 1 returns 0 but that for any argument greater than 1 returns two less than that number. It does this by matching on two Nat.succ applications, given a name to the nat was the argument to the second, and returning that value. (Note: pred is short for predecessor, and is unrelated to predicates.)
def pred2 : Nat → Nat
| 0 => 0
| 1 => 0
| Nat.succ (Nat.succ n'') => n''
-- Example applications
#eval pred 0
#eval pred2 0
#eval pred2 2
#eval pred2 5
-- Just defining *dec* as another name for *pred*
def dec : Nat → Nat := pred
Recursively Defined Operations
A recursive definition of a function to compute the factorial of any given natural number.
def fac : Nat → Nat
| 0 => 1
| (Nat.succ n') => (Nat.succ n') * fac n'
Binary Operations
def add : Nat → Nat → Nat
| n, 0 => n
| n, (m' + 1) => (add n m') + 1
def sub : Nat → Nat → Nat
| 0, _ => 0
| n, 0 => n
| (n' + 1), (m' + 1) => sub n' m'
def mul : Nat → Nat → Nat
| _, 0 => 0
| n, (m' + 1) => add n (mul n m')
def exp : Nat → Nat → Nat
| _, 0 => 1
| n, (m' + 1) => n * exp n m'
Predicates
A predicate is a parameterized proposition. In other words, predicates are applied to arguments, and they then reduce to propositions that might or might not be true about those arguments. As an example, a predicate called isEven could take an argument, say n, and return the proposition that *n%2 = 0%. This proposition is true for some values of n, such as 0, 2, and 4, but not true for other values, such as 1, 3, and 5.
def isEven : Nat → Bool := λ n => n % 2 = 0
Predicates are applied to arguments. They are literally thus functions of a sort, which you can see clearly here, where isEven is defined to be a function that takes an argument, n, and then reduces (in Lean evaluates to) n % 2 = 0, which in turn reduces to the Boolean value, true.
#reduce isEven 4
Predicates can be understood as formally defining properties of objects of a certain kind. Here the isEven predicate expresses the property of "being even." When a predicate takes multiple arguments, it can be understood as defining a property of tuples of values. For example, equals is a predicate taking two arguments, call them p and q of some type, and defines the property of those numbers being equal. As a first example, consider a predicate that defines the property of any number, n, being equal to zero. This is a unary predicate on natural numbers.
Unary Predicates
def isZero : Nat → Bool
| 0 => true
| _ => false
#reduce isEven 0 -- expect true
#reduce isEven 1 -- expect false
#reduce isEven 2 -- expect true
#reduce isEven 3 -- false
There are often multiple ways to specify the same predicate. Here's another definition of isEven as a recursive, function. For 0 it returns true; for 1, false; and for any larger number n, it returns the answer for n-2, eventually reaching either the first or the second base case.
def isEven' : Nat → Bool
| 0 => true
| 1 => false
| n'' + 2 => isEven n''
And here's a concise definition of the property of being odd. Because we've represented predicates as Lean functions, we can actually run them to compute whether a given object has a given propert, getting the answer as a value of type Bool.
def isOdd : Nat → Bool
| n => !(isEven n)
Binary Predicates
Returning to the semantic domain of natural number arithmetic, we now define the basic binary predicates, also called relational operators, on natural numbers. ASSIGNMENT: Study each definition; and to test your understanding, erase it then recreate it.
These examples illustrate another important point: we can use predicates to represent relations on objects. Each of the following definitions formalizes a binary relation on natural numbers: the less than or equal to relation, along with others. Each predicate defines the condition that a pair of numbers must satisfy to be consider in the given relation.
-- Equality of Natural numbers
def eq : Nat → Nat → Bool
| 0, 0 => true
| 0, _ => false
| _, 0 => false
| (n' + 1), (m' + 1) => eq n' m'
-- The less than or equal
def le : Nat → Nat → Bool
| 0, _ => true
| (_ + 1), 0 => false
| (n' + 1), (m' + 1) => le n' m'
-- greater than
def gt : Nat → Nat → Bool
| n, m => ¬ le n m
-- less than
def lt : Nat → Nat → Bool
| n, m => le n m && !(eq n m)
-- greater than or equal
def ge : Nat → Nat → Bool
| n, m => gt n m || eq n m
As a final point, in anticipation of developments to come, we can similarly view a unary predicate as defining not just a property of objects of a given kind, but a set of all objects of that type, namely the ones that satisfy the predicate, which can call a membership predicate. Indeed, this is exactly how we will not only specify but represent sets in Lean. Before we can formalize this idea, we'll need to see how to define predicates logically in Lean, rather than computationally as we've done here. Computational representations can be run to computate the truth of a proposition such as isEven 3, whereas the application of a logical definition of a predicate to argument reduces to a proposition for which determination of its truth requires a proof. We address such issues a little later in this book. Here, though, is a preview.
-- Logical definition of the set of even numbers
def evens : Set Nat := { n : Nat | isEven n}
-- The logical proposition that 4 is even
def fourEven := 4 ∈ evens
-- That proposition and a proof of it.
example : 4 ∈ evens := by rfl
For now, you know how to represent predicates as functions. You can call them predicate functions if you wish. This is how you'd typically express predicates in an ordinary programming language, such as C, Python, or Java, that doesn't have any notion or support for logical reasoning. In Lean, functions must be total, but not every predicate can be expressed as a total function. Consider the predicate halts that takes an C program as it argument and returns true iff and only if there is no input on which the program can go into an infinite loop. In Lean we will be forced instead to represent such predicates logically. We will no longer be able to compute whether a given object (such as a program) has a given property (halts on all inputs), but will instead have to construct a proof of that proposition for any given program.
end DMT1.Lectures.natArithmetic.domain
namespace DMT1.Lectures.natArithmetic.syntax
Syntax of Arithmetic Expressions
We define a syntax for arithmetic and relational expressions in almost exactly the same way as we did for propositional logic. Here, however, we will have both arithmetic and relational expressions. The former will evaluate to natural numbers, when we get to the semantics (e.g., 3 + 2), while the latter will evaluate to Booleans (e.g., 3 < 2).
Variables
We introduce variables indexed by natural numbers just as we did for propositional logic expressions. Variable expressions in arithmetic and relational expressions will be constructed on these variable terms, just as we did for propositional logic.
structure Var : Type where
(index: Nat)
deriving Repr
Operators
We now define syntax for unary and binary arithmetic operator expressions, that we will (in semantics.lean) interpret as evaluating to natural numbers, as well as unary and binary arithemtic predicate operators that we will evaluated as reducing to Boolean values.
Unary
We define syntactic symbols for the unary operators of our emerging little language for arithmetic. Just as we did for propositional logic, we will give them fixed interpretations as functions in the semantic domain of actual natural number arithmetic: namely, as meaning increment, decrement, and factorial. The functions themselves are defined in domain.lean.
inductive UnOp : Type where
| inc
| dec
| fac
deriving Repr
Binary
We define binary operator symbols here, that we will eventually interpret as having corresponding arithemtic functions as fixed meanings. We will skip division at this point, as it's a more complicated to implement than addition, subtraction, and multiplication.
inductive BinOp : Type where
| add
| sub
| mul
deriving Repr
Predicates
Unary
Next we define syntax for unary predicate operator symbols. Here we define just one, namely isZero. We will interpret it as returning a Boolean indicating whether the expression to which it's applied evaluates to zero or not.
inductive UnPredOp : Type
| isZero
deriving Repr
Binary
Next we define a set of syntactic symbols (names) for basic natural number arithmetic relational operators. We will interpret expressions of this kind as evaluating not to natural numbers but to Boolean values that indicate whether a given pair of numbers satisfies a particular predicate, e.g., that the first argument is equal to, greater than, etc., the second.
inductive BinPredOp : Type
| eq
| le
| lt
| ge
| gt
deriving Repr
inductive TernPredOp : Type
-- TODO: Nothing here for now. Could be add relation
deriving Repr
Operator Expressions
The syntax of arithemtic expressions is nearly isomorphic to (has the same structure) as that expressions in predicate logic. There is just one difference here: literal expressions hold natural number values rather than Booleans, so taht they can be evaluated as having fixed numeric rather than fixed Boolean values.
inductive OpExpr : Type where
| lit (from_nat : Nat) : OpExpr
| var (from_var : Var)
| unOp (op : UnOp) (e : OpExpr)
| binOp (op : BinOp) (e1 e2 : OpExpr)
deriving Repr
Predicate Expressions
Arithemtic Predicate expressions are similarly specified as an inductively defined type, values of which represent (true/false) unary and binary predicates on natural numbers. (Can you think of a sensible example of a ternary predicate?)
inductive PredExpr : Type where
| unOp (op : UnPredOp) (e : OpExpr)
| binOp (op : BinPredOp) (e1 e2 : OpExpr)
| ternOp (op : TernPredOp) (e1 e2 e3 : OpExpr)
deriving Repr
We define (non-standard notations) to construct variable terms from natural numbers and variable expression terms from variable terms, as we did for propositional logic.
notation:max " { " v " } " => OpExpr.var v
notation:max " [ " n " ] " => OpExpr.lit n
Arithmetic operators are generally defined as left associative. The precedences specified here also reflect the usual rules for "order of operations" in arithmetic.
notation:max e " ! " => OpExpr.unOp UnOp.fac e
infixl:65 " + " => OpExpr.binOp BinOp.add
infixl:65 " - " => OpExpr.binOp BinOp.sub
infixl:70 " * " => OpExpr.binOp BinOp.mul
-- TODO: ternOp notation?
We also specify concrete syntax for the usual binary predicates (aka relational operators) in arithmetic.
#check PredExpr.binOp BinPredOp.eq
notation:50 x " == " y => PredExpr.binOp BinPredOp.eq x y
notation:50 x " ≤ " y => PredExpr.binOp BinPredOp.le x y
notation:50 x " < " y => PredExpr.binOp BinPredOp.lt x y
notation:50 x " ≥ " y => PredExpr.binOp BinPredOp.ge x y
notation:50 x " > " y => PredExpr.binOp BinPredOp.gt x y
end DMT1.Lectures.natArithmetic.syntax
import DMT1.Lectures.L04_natArithmetic.syntax
import DMT1.Lectures.L04_natArithmetic.domain
namespace DMT1.Lectures.natArithmetic.semantics
Given syntactic operator and predicate terms, return their fixed semantic meanings as Nat- and Bool-valued functions.
open DMT1.Lectures.natArithmetic.domain
open DMT1.Lectures.natArithmetic.syntax
def evalUnOp : UnOp → (Nat → Nat)
| UnOp.inc => Nat.succ
| UnOp.dec => Nat.pred
| UnOp.fac => domain.fac
def evalBinOp : BinOp → (Nat → Nat → Nat)
| BinOp.add => domain.add
| BinOp.sub => domain.sub
| BinOp.mul => domain.mul
def evalBinPred : BinPredOp → (Nat → Nat → Bool)
| BinPredOp.eq => domain.eq -- eq is from from natArithmetic.domain
| BinPredOp.le => domain.le -- etc.
| BinPredOp.lt => domain.lt
| BinPredOp.ge => domain.ge
| BinPredOp.gt => domain.gt
-- TODO:
def evalTernPred : TernPredOp → (Nat → Nat → Nat → Bool) := sorry
def evalUnPred : UnPredOp → (Nat → Bool)
| UnPredOp.isZero => domain.isZero
A helper function for evaluating "literal n" expressions, to simplify the expression of the semantic evaluation function defined below.
def evalLit (n : Nat) : Nat := n
A more abstract name for the type of a variable interpretation.
def Interp := Var → Nat
A helper function for evaluating variables under given interpretations.
def evalVar : Var → Interp → Nat -- evalVar is a function
| v, i => i v -- apply interpretation i to variable v to get value
-- Semantic evaluation of arithmetic expression, yielding its Nat value
def eval : OpExpr → Interp → Nat
| OpExpr.lit n, _ => (evalLit n)
| OpExpr.var v, i => (evalVar v i)
| OpExpr.unOp op e, i => (evalUnOp op) (eval e i)
| OpExpr.binOp op e1 e2, i => (evalBinOp op) (eval e1 i) (eval e2 i)
Semantic evaluation of a predicate expression.
--
def evalPredExpr : PredExpr → Interp → Bool
| PredExpr.unOp op e, i => (evalUnPred op) (eval e i)
| PredExpr.binOp op e1 e2, i => (evalBinPred op) (eval e1 i) (eval e2 i)
| PredExpr.ternOp op e1 e2 e3, i => (evalTernPred op) (eval e1 i) (eval e2 i) (eval e3 i)
Standard concrete notation for applying semantic evaluation functions to expressions.
notation "⟦" e "⟧" i => eval e i
notation "⟦" e "⟧" i => evalBinPred e i
end DMT1.Lectures.natArithmetic.semantics
Induction
Can we easily imagine a function that computes value of functions, such as factorial n, for any natural number argument, n, from 0 all the way up. As such a function consumes a natural number value and returns another natural number value, it's type must be Nat → Nat. But can we compute the value of the function for any n?
- A Manual Approach
- Specification: Base and Step Functions
- Induction
- Another Example
- Notation
- Lean preferred notation
- Unrolling recursions
- Forget about unrolling recursions
- Exercises
A Manual Approach
First, we could fill in a table of function values starting with, in the 0th row, n = 0 in the first column, and the value of (factorial n), which is 1, in the in the second column.
+---+-------+ | n | n! | +---+-------+ | 0 | 1 | | 1 | | | 2 | | | 3 | | | 4 | | | 5 | |
Having thereby "initialized" the table, let's figure out how to complete the second row. What do we know at this point? What can we assume? We know that for n = 0, n! = 1. Those entries are already in the table. Now we need to compute 1!. We could say, we know it's 1, because someone told you, and just fill it in, but it's better to find the general principal.
That principle (now that the table is initialized) is that you can always fill in the right value in the next row to be completed multiplying n (in the left column) by the factorial of n' in the previous row (on right).
You do it. Fill in the answers in the table above.
Here's another easy computation that can help you to start to think inductively:
- 54321*1 =
- 5*(43211) =
- 5*4!
So, to compute 5! for the right hand column in row n = 5, you just multiply 5 by the result you must already have for n' = 4. That's it, unless n = 0, in which case, the answer is already there from the initialization.
The preceding demonstration and analysis should leave you convinced that in principle we could compute n! for any n. Fill in the first row, then repeat the procedure to get the answer for the next row until you've down that for rows all the way up to n. In other words, do the base step to start, then repeat the "inductive" step n times, and, voila, the answer right there in the righthand column of the table.
That's pretty cool. To make use of it in computer science, we need to formalize these ideas. Here we'll formalize them in the logic of Lean, as propositional logic is too simple to represent these rich ideas.
Specification: Base and Step Functions
So let's now look at what we're doing here with precision.
We envisioned a table. The rows are indexed (first column) by natural numbers. The second row should contain the factorials of these arguments. We don't really have to include the idea of a table in our specification. The only really interesting values are those in the second column, fac n.
So, first, we'll specify the value of fac n for the base case, where n = 0. The value is one, and we'll call it facBase.
def facBase := 1
Now comes the interesting part. To compute each subsequent value, we repeatedly applied the same formula, just moving the index up by one each time, and building a new answer on the answer we already had for the preceding value of n (the preceding row). What computation did we carry out exactly? We represent it as a function, facStep.
Suppose we're trying to fill in the value for row n, where n ≠ 0. As n ≠ 0, there is a natural number one less than n; call it n'. Clearly n = n' + 1, and n' is the index of the preceding row. We know we can fill in that table as high as we want, so when we're trying to fill it in for some row, n, we can assume that we known both n' (easy) and fac n', and from those we can compute fac n.
Crucial idea: What do assumptions turn into in code? They are arguments to other definitions. When you're writing a function that will take two arbitrary arguments, a and b, and combine them into a result in some what, what is your mindset when you write the code? You assume that you actually have two such arguments, and you write the code accordingly. It's going to be the same for us. When you think of assumptions, think of code from the perspective of someone who's writing it. You can always assume something and then act accordingly. This is what programmers do!
We thus turn the assumptions that we know n' and fac n' when trying to compute fac n, into arguments to a function. The person who "implements" the function assumes they get the named values as arguments and then computes the right result and returns it accordingly.
def facStep : Nat → Nat → Nat
| n', facn' => (n' + 1) * facn'
-- Applying facStep to n' and fac n' yields fac n
-- The base case, that fac 0 = 1, gives us a place to start
--
-- n' fac n' n fac n
#eval facStep 0 facBase -- n = 1, 1
#eval facStep 1 1 -- n = 2, 2
#eval facStep 2 2 -- n = 3, 6
#eval facStep 3 6 -- n = 4, 24
#eval facStep 4 24 -- n = 5, _
Induction
Ok, that's all super-cool, and formalizing it in Lean has let us to semi-automatically compute our way up to fac n for any n. The crucial idea is: start with the base values and then apply the step function n times, always feeding it the results of the previous step, or the base values, as the case may be.
Yes, that's fine, but how do we graunch our base and step "machines" into a new machine, a function, that takes any n and automatically computes n!? We'll, at this point, we do not have a way, even in principle, to do this. Any yet we're convinced that having base and step machines should suffice for the construction of a machine that automates what we just said: start with the base then step n times.
The answer is that we need, and have, a new principle: an induction principle, here for the natural numbers. What it says, semi-formally, is that if you have both a value of a function, f, for n = 0, and you have a step function that takes n' and f n' and returns f n, then you can derive and then use a function that takes any n and returns f n for it.
You can think of the induction principle (for Nat) as taking two small machines--one that answers (f 0) and one that takes any n' and f n' and answers (f n)--and that combines them into a machine that takes any n and answers (f n). In a nutshell, the induction principle automates the process of first using the base machine then applying the step machine n times to get the final answer.
In Lean, the induction principle (function!) in Lean for the natural numbers is called Nat.rec.
def fac : Nat → Nat := Nat.rec facBase facStep
Let's break that down. We are defining fac as the name of a function that takes a Nat argument and returns a Nat results. That's the type of the factorial function, as we discussed above. The magic is on the right. The function that fac will name is computed by passing the base value and the step function to the induction function for Nats!
And by golly, it works.
#eval fac 0 -- expect 1
#eval fac 1 -- expect 1
#eval fac 2 -- expect 2
#eval fac 3 -- expect 6
#eval fac 4 -- expect 24
#eval fac 5 -- expect 120
Another Example
Compute the sum of all the natural numbers up to any n.
What is the sum of all the numbers from 0 to 0? It's zero. Now specify your step function: a function that takes n' and sum n' and that returns sum n. Finally, pass these two "little machines" to the Nat.rec to get a function that will work for all n. Then have fun!
def baseSum := 0
def stepSum (n' sumn' : Nat) := (n' + 1) + sumn'
def sum : Nat → Nat := Nat.rec baseSum stepSum
-- Voila!
#eval sum 0 -- expect 0
#eval sum 1 -- expect 1
#eval sum 2 -- expect 3
#eval sum 3 -- expect 6
#eval sum 4 -- expect 10
#eval sum 5 -- expect 15
#check Nat.rec
Nat.rec.{u} {motive : Nat → Sort u} (zero : motive Nat.zero) (succ : (n : Nat) → motive n → motive n.succ) (t : Nat) : motive t
#check List.rec
List.rec.{u_1, u} {α : Type u} {motive : List α → Sort u_1} (nil : motive []) (cons : (head : α) → (tail : List α) → motive tail → motive (head :: tail)) (t : List α) : motive t
Notation
From now on, think of induction as building forward from a base case, at each step using the results from previous steps as arguements to step functions that compute results for the next inputs up.
Moreover, when you're figuring out how to write such a function don't just think of it this way but write down your base value and inductive step functions. What you no longer need deal with is the awkward separate definition then combination of the smaller machines.
Let's break this definition down.
We define a function, sum', that returns the sum of the natural numbers from 0 to any given argument, n.
This function is of coures of type Nat → Nat
The value to which "sum" will be bound is (of course) a function, here specified to take an argument, n, that then acts in one of two ways depending on whether n is 0 (Nat.zero) or the successor (n' + 1) of some one-smaller number (Nat.succ n').
The two cases here correspond directly to the two small machines! In the case of n = 0, we return 0. In the case n = n' + 1 (n greater than 0), we return (n' + 1) + sum n. That is, we apply the step function to n' and sum n', just as we did earlier. And it all works.
def sum' : Nat → Nat :=
fun n => match n with
| Nat.zero => Nat.zero
| Nat.succ n' => Nat.succ n' + sum' n'
#eval sum' 0 -- expect 0
#eval sum' 5 -- expect 15
Lean preferred notation
When writing such definitions, however, it's preferred to use Lean notations for natural numbers. This avoids having to take extra steps when writing proofs later to simply translate and forth between, say, 0 and Nat.zero.
So, instead of Nat.zero, write 0 in practice. And instead of Nat.succ n', write (n' + 1). Here it is important that the + 1 be on the right. When it is, Lean interprets it as Nat.succ 1, whereas on the left it'd be Nat.add 1 n'. The latter is not what you want in a case analysis pattern where you intend to match with Nat.succ n' for some n'
So here's the cleaned up code you should actually write.
def summ : Nat → Nat
| 0 => 0
| n' + 1 => (n' + 1) + summ n'
#eval summ 100
Lean's preferred notation for specifying such functions really does involve specifying the two smaller machines. Just look carefully at right sides of the two cases! The base case is 0. On the right for the inductive case is the step function! summ n = summ n' + 1 = (n' + 1) + summ n'.
Unrolling recursions
Ok, so the induction principle seems wildly useful, and it is. It reduces the problem of writing a function for any n to two simpler subproblems: one for base case (constant), and one to define the step function. Then wrap them both up using "the induction machine" to get a function that works automatically for any n.
But, you ask, what actually happens at runtime? Well, let's see! Here we iteratively evaluate the application of the sum function to 5, to compute the sum of the numbers from 0 to 5, which we can easily calculate to be 15. Be careful to read the notes about which cases in the function definition the different arguments match.
sum 5 -- matches n'+1 pattern; reduces to following: 5 + (sum 4) -- same rule: keep reducing nested applications 5 + (4 + (sum 3)) -- 5 + (4 + (3 + (sum 2))) -- 5 + (4 + (3 + (2 + (sum 1)))) -- 5 + (4 + (3 + (2 + (1 + (sum 0))))) -- now reduce these expressions by addition 5 + (4 + (3 + (2 + (1 + 0)))) -- by apply Nat.add to 1 and 0 5 + (4 + (3 + (2 + (1)))) -- all the rest is just addition 5 + (4 + (3 + (3))) 5 + (4 + (6)) 5 + (10) 15 -- the result for 5
Forget about unrolling recursions
Now that you've seen how a recursion unfolds during actual computation, you can almost forget about it. The induction principle for natural numbers is valid and can be used and trusted: all you need to define are a base case and a step function (of the kind required, taking n' and the answer for n' as arguments and producing the answer for n), and induction will iterate the step function n times over the result from the "base function." The only "hard" job for you is usually to figure out the right step function.
Exercises
Sum of Squares of Nats Up To n
Define a function two ways: using Nat.rec applied to two smaller "machines" (base value and step function), and then using Lean's preferred notation as described above. The function should compute the sum of the squares of all the natural numbers from 0 to any value, n.
Start by completing a table, in plain text, just as we presented above, but now for this function. As you fill it in, pay close attention to the possibility of filling in each row using the values from the previous row. That will tell you what your step function will be.
-- base value machine
def baseSq : Nat := 0
-- step up answer machine
-- from n' and sumSq n' return (n' + 1)^2 + sumSq n'
def stepSq : Nat → Nat → Nat
| n', sum_sq_n' => sorry
-- here's how the stepping up works
#eval stepSq 0 0 -- return answer for n = 1; expect 1
#eval stepSq 1 1 -- return answer for n = 2; expect 5
#eval stepSq 2 5 -- return answer for n = 3; expect 14
#eval stepSq 3 14 -- return answer for n = 4; expect 30
#eval stepSq 4 30 -- return answer for n = 5; expect 55
-- apply induction to construct desired function
def sumSq : Nat → Nat := Nat.rec baseSq stepSq
#eval sumSq 0 -- expect 0
#eval sumSq 1 -- expect 1
#eval sumSq 2 -- expect 5
#eval sumSq 3 -- expect 14
#eval sumSq 4 -- expect 30
#eval sumSq 5 -- expect 55 (weird: also sum of nat 0..10)
def sumSq' : Nat → Nat
| 0 => 0
| (n' + 1) => let n := n' + 1
n^2 + sumSq' n'
#eval sumSq' 0 -- expect 0
#eval sumSq' 1 -- expect 1
#eval sumSq' 2 -- expect 5
#eval sumSq' 3 -- expect 14
#eval sumSq' 4 -- expect 30
#eval sumSq' 5 -- expect 55 (weird: also sum of nat 0..10)
Format your table here
Now write test cases as above, including "expect" comments based on your table entries above, and be sure your tests (using #eval) are giving the results you expect. Example: sumSq 2 = 2^2 + 1^2 + 0^2 = 5.
Nat to BinaryNumeral
Define a function that converts any natural number n into a string of 0/1 characters representing its binary expansion. If n = 0, the answer is "0". If n = 1, the answer is "1". Otherwise we need to figure out how to deal with numbers 2 or greater.
Let's make a table
0 | "0" 1 | "1" 2 | "10" 3 | "11" 4 | "100" 5 | "101" etc
What do we notice? The righmost digits flip from 0 to 1 to 0 to 1, depending on whether the number is even or odd. Another way to think about it is that if we divide the number by 2 and compute a remainder, we get the rightmost digit. 4/2 = 0. 0 is the right digit of "100". And 5/2 = 1, the rightmost digit of "101". The remainder when dividing by 2 is also called n mod 2, or n % 2 in many popular programming languages.
#eval 0 % 2
#eval 1 % 2
#eval 2 % 2
#eval 3 % 2
#eval 4 % 2
#eval 5 % 2
So given any n we now have a way to get its rightmost digit in binary by taking the number mod 2. As an aside, we can easily convert such a 0/1 natural number to a string using toString.
#eval toString 5
But what about converting the rest of the input to binary digits? What even is "the rest" of the input. Well, in this case, it's n/2, right? When we divide n by 2 using natural number arithmetic, we get the quotient and thow away the remainder. So let's look at how to covert 5 to a its binary expansion.
n | n/2 | n%2 | str |
---|---|---|---|
5 | 2 | 1 | "1" |
2 | 1 | 0 | "0" |
1 | 0 | 1 | "1" |
0 | 0 | 0 | "" |
At each step, we get the right most digit using %2 and we get the rest of the number to covert on the left of that last binary digit (bit) using /2. We're done at the point where the number being converted is 0, for which we return "". Finish the definition of the function here, and write test cases for n up to 5 to see if it appears to be working.
By the way, you can append to strings in Lean using the String.append function, with notation, ++.
#eval "10" ++ "1"
notation based on your known base and step "machines". We provide most of an answer. Do not guess. Figure it out! After the ++ comes the length-one "0" or "1" string for the rightmost digit in the binary numeral. What completes the answer by appearing to the left of the ++ ((String append)?
def binaryRep : Nat → String
| 0 => "0"
| 1 => "1"
| n' + 2 => let n := n' + 2
sorry ++ toString (n % 2)
-- Complete the definition. The tests will work,.
#eval binaryRep 0 --expect "0"
#eval binaryRep 5 --expect "101"
Other Induction Axioms
Interesting. We've got ourselves a recursive function, but it doesn't quite fit the schema we've seen to now. Nat.rec allows us to build a general purpose function from a machine that returns an example for one based case and a machine that builds an answer for n = (n' + 1) from the one for n' (which is obviously n - 1). But in our function, we construct an answer for n (for n ≥ 2) from an answer for n/2, not n-1. It works but it won't work to pass this kind of step function to Nat.rec.
As you might guess there are several induction axioms. The form we've studied lets you assume that when computing an answer for n = (n' + 1) that you can assume you have n' and the answer for n'. From those you compute the answer for n.
A different form of induction let's you assume when computing an answer for n that you have n' and answers for all values from n' down to the base value, here 0.
We have implicitly used this other axiom in this case, as we're accessing an answer "earlier" in our table, but not at position n - 1, rather at position n / 2. The string representation of that will be all of the binary digits to the left of the final digit, determined separately by the remainder, n % 2.
When we write recursive functions in practice, we can usually assume access to answer for *all smaller" values of n' (e.g., lesser by one, or quotient by 2), by way of recursive calls with these values as actual parameters.
Specifying the Fibonacci Function
Haha. So we finally get to the exercise. You are to specify in Lean and test a function to compute values of the Fibonacci function for any Nat argument, n. Here's how you might see it defined in a book.
For 0, fib should answer 0 For 1, fib should answer 1 For any n = (n' + 2), fib should answer fib n' + fib (n' + 1)
You can see here that the definition assumes that when computing an answer for n, that answers are available for both fib n' [fib (n - 2)] as well as for fib (n' + 1) [fib (n - 1)]. Nat.rec won't work here. Just use the recommended syntax and notations for writing such functions.
Oh, the table. It helps! Fill in enough answers to grok it! Complete the table in your notes.
| 0 | 0 | | 1 | 1 | | 2 | 1 | | 3 | 2 | | 4 | 3 | | 5 | 5 | | 6 | 8 | | 7 | 13 | | 8 | __ | | 9 | __ | | 10 | __ |
You see how you build an answer for n from answer not just one but both one and two rows back.
Final hint: Define two base cases, for n = 0 and n = 1, then a third case for the indutive construction, for any n = (n' + 2).
def fib : Nat → Nat
| 0 => sorry
| 1 => sorry
| n' + 2 => sorry
Write test cases for 0, 1, 2, and 10. Does it work?
What we see in the definition of the Fibonacci function are two base cases and a "step" function that uses the results of the prior two computations for all inputs greater than or equal to two.
The Nat induction principle we've see up to now does not rely on multiple prior values. This function does. So it must be using a different notion of induction, and that's exactly right. In strong induction, at each step one can assume one has not only the result for the immediately preceding argument, but for the answers for all prior argument values.
The Fibonacci function is a special case. It assumes it has, and it uses, the prior two outputs to compute the output for the next larger input value. The variant of simple induction (for Nat values) used here is called strong induction. We're not yet prepared to use it directly to define recursive functions like fib, but you should now be able at least to read and understand it. Your assignment here, then, is simply to express the formal definition in easy to understand English. In Lean, strong induction is supported (for Nat) by the axiom called Nat.strongRecOn.
--
#check Nat.strongRecOn
open Nat
import DMT1.Lectures.L04_natArithmetic.syntax
import DMT1.Lectures.L04_natArithmetic.semantics
import DMT1.Lectures.L04_natArithmetic.domain
namespace DMT1.Lectures.natArithmetic.arithLang.demo
Our Natural Number Arithmetic Language!
open DMT1.Lectures.natArithmetic.syntax
#check OpExpr
-- some arithmetic literal expressions
def zero : OpExpr := OpExpr.lit 0 -- abstract syntax
def one : OpExpr := {⟨1⟩} -- our concrete syntax
-- some arithmetic variable expressions
def X : OpExpr := OpExpr.var (Var.mk 0) -- abstract syntax
def Y : OpExpr := {⟨1⟩} -- concrete syntax
def Z : OpExpr := {⟨2⟩}
def K : OpExpr := {⟨3⟩}
def M : OpExpr := {⟨4⟩}
def N : OpExpr := {⟨5⟩}
-- an example of the kinds of expressions we can now write
def e0 : OpExpr := [5] -- literal expression
def e1 : OpExpr := X -- variable expression
def e2 : OpExpr := Y -- variable expression
def e3 : OpExpr := Z -- variable expression
def e4 : OpExpr := X + [2] -- operator (+) expression
def e5 : OpExpr := X + ([5] * Y) - X --
def e6 : OpExpr := X + [2] * Y - X --
def e7 : OpExpr := [2] * Y + X - X --
def e8 : OpExpr := [10] - [2] * X --
def e9 : OpExpr := [2] * Y - X --
-- an interpretation: X = 2, Y = 5, Z = 11, rest = 0
def i259 : Var → Nat
| Var.mk 0 => 2 -- X = 2
| ⟨ 1 ⟩ => 5 -- Y = 5
| ⟨ 2 ⟩ => 9 -- Z = 11
| _ => 0
#eval i259 ⟨ 0 ⟩
#eval i259 ⟨ 1 ⟩
#eval i259 ⟨ 2 ⟩
#eval i259 ⟨ 3 ⟩
-- predict values of our six expressions under this interpretation
#eval ⟦e0⟧ i259 -- expect 5
#eval ⟦e1⟧ i259 -- expect 2
#eval ⟦e2⟧ i259 -- expect 5
#eval ⟦e3⟧ i259 -- expect 11
#eval ⟦e4⟧ i259 -- expect 2 + 2 = 4
#eval ⟦e5⟧ i259 -- expect 10
#eval ⟦e6⟧ i259 -- expect 10
#eval ⟦e7⟧ i259 -- expect 10
#eval ⟦e8⟧ i259 -- expect 6
#eval ⟦e9⟧ i259 -- expect 6
-- an interpretation: all variables evaluate to zero
def i0 (_ : Var) := 0
#eval ⟦e0⟧ i0
#eval ⟦e1⟧ i0
#eval ⟦e2⟧ i0
#eval ⟦e3⟧ i0
#eval ⟦e4⟧ i0
#eval ⟦e5⟧ i0
-- function: first six *variables* go to given values, rest to 0
def i230463 : Var → Nat
| ⟨ 0 ⟩ => 2 -- X := 2
| ⟨ 1 ⟩ => 3 -- Y := 3
| ⟨ 2 ⟩ => 0 -- Z := 0
| ⟨ 3 ⟩ => 4 -- N := 4
| ⟨ 4 ⟩ => 6 -- M := 6
| ⟨ 5 ⟩ => 3 -- P := 3
| _ => 0 -- any other variable := 0
#eval ⟦e0⟧ i230463
#eval ⟦e1⟧ i230463
#eval ⟦e2⟧ i230463
#eval ⟦e3⟧ i230463
#eval ⟦e4⟧ i230463
#eval ⟦e5⟧ i230463
end DMT1.Lectures.natArithmetic.arithLang.demo
Theory Extensions
Semantic Domain: Boolean Algebra
namespace DMT1.Lectures.theoryExtensions.domain
def imp : Bool → Bool → Bool
| true, true => true
| true, false => false
| false, true => true
| false, false => true
def iff : Bool → Bool → Bool
| true, true => true
| false, false => true
| _, _ => false
end DMT1.Lectures.theoryExtensions.domain
Syntax
This chapter presents our formal definition of the syntax propositional logic.
- Literal Expressions
- Variable Expressions
- Variables
- Operator Expressions
- Syntax of Propositional Logic with Arithmetic
- Concrete Syntax: All the Usual Notations
We import the theory syntactic language
import DMT1.Lectures.L04_natArithmetic.syntax
namespace DMT1.Lectures.theoryExtensions.syntax
The syntax specifies the set of all and only syntactically correct propositional logic expressions. Roughly speaking, the syntax defines "literal" and "variable" expressions and a collection of expression-composiing operators that take smaller expressions as arguments to yield larger expressions as results.
- literal expressions
- variable expressions
- operator (application) expressions
Remember: we're not talking about Boolean meanings of literals, variables, or bigger expressions here. The syntax of a formal languages defines only the set of syntactically correct expressions in our language and says nothing about their intended meanings.
Now what's interesting is that we formalized the set of all correct expressions as Expr. It's a data type. Now any particular logical expression is just a value of this Expr type. The available constructors describe all all and only the ways to construct a term of this type. Lean automatically checks the correctness of terms declared to be of this type. The Lean type checker thus now provides us with a syntax checker for propositional logic.
Let's dive down into literal, variable, and application (operator application, if you want) expressions and how we represent them in Lean.
Literal Expressions
The term literal expression refers to an expression that directly names a value. In propositional logic the two values of interest are the Boolean values, true and false.
Our implementation of propositional logic thus defines two literal expressions, each built by applying the expression constructor called lit to one the two Boolean values (Bool.true or Bool.false). Our language will thus include (lit true) and (lit false) as its two literal expressions. In logical writing it's common to use the concrete syntactic symbols ⊤ (pronounced "top"), as a notation for (lit true), and ⊥ (pronounced "bottom") for the expression (lit false).
Variable Expressions
Variable expressions are a little more complicated, but not much. Just as a lit expression is built from a Boolean value (and incorporated as a value into the resulting term), a variable expression is built from a variable (a value of a type we'll call var). Each var object in turn is built from a natural number (0, 1, 2, ...). The natural number in one var distinguishes that var from any var having a different natural number "index." This design provides us as future authors of masterpieces in propositional logic an infinite supply of distinct variables to use in writing our logical opus magni. So, again, lets dig down a little more.
Variables
We define var to be the type of variables as we've been using the term: they are not themselves expressions but are necessary values for making variable expressions.
Variables
We will now have both Boolean and arithmetic (Nat-valued) variables, from which we will then be able to build variable expressions.
structure LogicVar : Type where
mk :: (index: Nat)
deriving Repr
Lean detail: If you omit a constructor name, Lean uses mk by default.
structure ArithVar : Type where
(index: Nat)
deriving Repr
-- See: it's defined
#check ArithVar.mk
Concrete syntax for variables (var)
We could define our own concrete notation for variables, but Lean provides one automatically for any "structure" type. A structure type has exactly one constructor. You can give it a name, but Lean defines mk as the default. This constructor will take zero or more argument. So, to build a value of such a type you can use ⟨ a, b, c,... ⟩ notation as a shorthand for, say, "var.mk a b c ....". We do need to let Lean know the result show be a "var".
#check (⟨3⟩ : LogicVar) -- it's a variable (var)
-- #check ⟨3⟩ -- not unique constructor expr
-- But where Lean can infer Var type ⟨3⟩ will suffice
Now we just defined variables expressions as expressions of the form (var_expr v). We define var_expr shortly. View var_expr as the method for constructing a variable expression from a variable. ANd given a variable expression, you can get the underlying variable back out to work with. We'll need that when it comes to defining interpretations as functions that take variables in and that return values from the semantic domain, here just of Boolean values.
Operator Expressions
Next we define the expression-composing operator (also called connectives) of propositional logic. Each such operator takes at least one expression as an argument and uses it in constructing a larger expression.
- applying the not operator to an expression e yields the expression (not e)
- applying a binary operator, op, to e1 and e2, yields the expression (op e1 e2)
- here, op can be and, or, not, implies, iff, and so forth
We call the only unary operator not, and the usual binary operators by their usual names. Remeber, these are meaningless symbols in the formal syntax of propositional logic. We give them names that will reflect their usual meanings when we define semantics for syntact expressions.
-- unary connective (syntactic expression composers)
inductive UnOp : Type
| not
deriving Repr
-- binary connectives (syntactic expression composers)
inductive BinOp : Type
| and
| or
| imp
| iff
deriving Repr
Now we get to the heart of the matter. With those preliminary dependencies out of the way, we now formally specify the complete (abstract) syntax of propositional logic. We do this by defining yet another data type. The idea is that values of this type will be (really will represent) expressions in propositional logic. In fact, in Lean, this type specifies the set of all and only the expressions legal in propositional logic.
Syntax of Propositional Logic with Arithmetic
inductive Expr : Type
| lit_expr (from_bool : Bool) : Expr
| var_expr (from_var : LogicVar)
| un_op_expr (op : UnOp) (e : Expr)
| bin_op_expr (op : BinOp) (e1 e2 : Expr)
| arith_pred_expr (from_pred : natArithmetic.syntax.PredExpr)
deriving Repr
#check Expr.arith_pred_expr
Every type encloses the names of its constructors in a snamespace with the same name as the type. So Expr is now a namespace, and the constructor names (lit_expr, etc.) are referred to as Expr.lit_expr, etc. To avoid having to repeat that Expr. bit all the time, one can "open" the namespace. Just don't do this if it would result in names having multiple different definitions being "directly visible."
open Expr
Concrete Syntax: All the Usual Notations
A well designed, user tested, concrete syntax for a given formal language can be a huge aid to the human of abstract formal definitions in that language. We prefer to write (3 + 4) over (add 3 4), for example. We don't expect you to have justifiable confidence in your deep understanding of this notation stuff at this point! We encourge you to follow it carefully to get the gist.
-- (lit true) and (lit false) expressions
-- a variable expression constructed from from a variable
-- our single unary connective, *not* (¬)
-- we set it to have maximum precedence (binding strength)
Here are concrete notations for our binary connectives. The letter "l" after infix specifies left associativity. The numbers after the colons specify binding strengths. The de-sugared versions follow after the arrows.
notation:max " ⊤ " => (Expr.lit_expr true)
notation:max " ⊥ " => (lit_expr false)
notation:max "{" v "}" => (var_expr v)
notation:max "⟨" e "⟩ " => arith_pred_expr (e : natArithmetic.syntax.PredExpr)
notation:max "¬" p:40 => un_op_expr UnOp.not p
infixr:35 " ∧ " => bin_op_expr BinOp.and
infixr:30 " ∨ " => bin_op_expr BinOp.or
infixr:20 " ↔ " => bin_op_expr BinOp.iff
infixr:25 " ⇒ " => bin_op_expr BinOp.imp
That's it. That's the entire abstract and concrete syntax for predicate logic. Note that some definitions of the syntax of propositional logic do not include the literal expressions, ⊤ and ⊥. In that case one will just use variable expressions instead, taking care to fix the interpretations of these variables as unvaryingly either true or false.
end DMT1.Lectures.theoryExtensions.syntax
Semantics
The idea of semantics in Propositional Logic is simple: provided that we have a function that maps each variable expression to a Boolean value, then every expression in propositional logic has a Boolean value as its meaning. We will call such a function a variable interpretation.
- Fixed Interpretation of Unary Connectives
- Fixed Interpretation of Binary Connectives
- Structures: Over Which Expressions Are Evaluated
- Semantic Evaluation
For literal expressions the mapping to Boolean values is fixed: the meaning of ⊤ is Boolean true, and for ⊥ it is Boolean false. For variable expresssions, given an additional variable interpretation function, the meaning of a variable expression is the meaning assigned to it by a variable interpretation function.
Next, the connectives of propositional logic also have meanings, which are Boolean functions. For example, the syntactic symbol ∧ has as its semantic meaning the Boolean and function, often written as &&. The meanings of the other connectives (operators) of predicate logic are defined similarly: ∨ means ||, ¬ means !, and so forth.
Finally, expressions built from smaller expressions using the logical connectives have meanings that are determined compositionally. Given an expression, e1 op e2 (let's call it e), its meaning is determined by first getting the Boolean meanings of e1 and e2 and by then applying the Boolean function that is the designated meaning of op. That's it!
import DMT1.Lectures.L05_theoryExtensions.syntax
import DMT1.Lectures.L05_theoryExtensions.domain
import DMT1.Lectures.L04_natArithmetic.syntax
import DMT1.Lectures.L04_natArithmetic.semantics
namespace DMT1.Lectures.theoryExtensions.semantics
open theoryExtensions.syntax
open theoryExtensions.semantics
--open natArithmetic.syntax
Fixed Interpretation of Unary Connectives
The first thing we'll do is define what Boolean operators we mean by the names of our unary and binary "conenctives".
-- function takes unary operator and returns *unary* Boolean function
-- (Bool -> Bool) means takes *one* Bool argument and "returns" a Bool
def evalUnOp : UnOp → (Bool → Bool)
| (UnOp.not) => Bool.not
Fixed Interpretation of Binary Connectives
- takes a binary operator and returns corresponding binary Boolean function
- (Bool → Bool → Bool) is the type of function that takes two Bools and returns one
def evalBinOp : BinOp → (Bool → Bool → Bool)
| BinOp.and => Bool.and
| BinOp.or => Bool.or
| BinOp.imp => domain.imp -- DMT1.lecture.propLogic.semantics.domain.imp
| BinOp.iff => domain.iff -- likewise
Structures: Over Which Expressions Are Evaluated
We now have to evaluate expressions over two-element structures, providing an interpretation for Boolean-valued variables, and an interpretation for Nat-valued, i.e., arithmetic, variables.
structure Interp where
(logical : LogicVar → Bool)
(arithmetic : natArithmetic.semantics.Interp)
open Expr
Semantic Evaluation
def eval : Expr → Interp → Bool
| (lit_expr b), _ => b
| (var_expr v), i => i.logical v
| (un_op_expr op e), i => (evalUnOp op) (eval e i)
| (bin_op_expr op e1 e2), i => (evalBinOp op) (eval e1 i) (eval e2 i)
| (arith_pred_expr e), i => natArithmetic.semantics.evalPredExpr e i.arithmetic
end DMT1.Lectures.theoryExtensions.semantics
import DMT1.Lectures.L05_theoryExtensions.syntax
import DMT1.Lectures.L05_theoryExtensions.semantics
Examples
namespace DMT1.Lectures.theoryExtensions.axioms
open theoryExtensions.syntax
open theoryExtensions.semantics
--open natArithmetic.syntax
open Expr
Variable Expressions
-- Propositional variables expressions
def P : Expr := {⟨0⟩}
def Q : Expr := {⟨1⟩}
def R : Expr := {⟨2⟩}
-- Arithmetic variables
def v₀ : natArithmetic.syntax.Var := ⟨0⟩
def v₁ : natArithmetic.syntax.Var := ⟨1⟩
def v₂ : natArithmetic.syntax.Var := ⟨2⟩
-- Arithmetic variable expressions
def K : natArithmetic.syntax.OpExpr := natArithmetic.syntax.OpExpr.var v₀
def N : natArithmetic.syntax.OpExpr := natArithmetic.syntax.OpExpr.var v₁
def M : natArithmetic.syntax.OpExpr := natArithmetic.syntax.OpExpr.var v₂
-- Some expressions in "propositional logic with arithmeic"
#check (K + M)
#check (K == M)
-- predicate expression in arithmetic
#check (K == M)
-- predicate expression in propositional logic with arithmetic
def bar := Expr.arith_pred_expr (K == M)
-- and expression (conjunction) in PL with arithmetic
def x := bar ∧ bar
-- a two-interpretation structure
def i : Interp := ⟨ (λ v => true), (λ v => 0) ⟩
def newI : Interp := ⟨ (λ v => true), (λ v =>
match v with
| natArithmetic.syntax.Var.mk 0 => 0 --K
| natArithmetic.syntax.Var.mk 1 => 1 -- N
| natArithmetic.syntax.Var.mk 2 => 1
| _ => 0) ⟩
-- evaluating PL with arithmetic over this structure
#reduce eval x i
#reduce eval x newI
end DMT1.Lectures.theoryExtensions.axioms
Satisfiability Modulo Theories
Induction
Can we easily imagine a function that computes value of functions, such as factorial n, for any natural number argument, n, from 0 all the way up. As such a function consumes a natural number value and returns another natural number value, it's type must be Nat → Nat. But can we compute the value of the function for any n?
- A Manual Approach
- Specification: Base and Step Functions
- Induction
- Another Example
- Notation
- Lean preferred notation
- Unrolling recursions
- Forget about unrolling recursions
- Exercises
namespace DMT1.Lectures.induction.induction
A Manual Approach
First, we could fill in a table of function values starting with, in the 0th row, n = 0 in the first column, and the value of (factorial n), which is 1, in the in the second column.
n | n! |
---|---|
0 | 1 |
1 | |
2 | |
3 | |
4 | |
5 |
Having thereby "initialized" the table, let's figure out how to complete the second row. What do we know at this point? What can we assume? We know that for n = 0, n! = 1. Those entries are already in the table. Now we need to compute 1!. We could say, we know it's 1, because someone told you, and just fill it in, but it's better to find the general principal.
That principle (now that the table is initialized) is that you can always fill in the right value in the next row to be completed multiplying n (in the left column) by the factorial of n' in the previous row (on right).
You do it. Fill in the answers in the table above.
Here's another easy computation that can help you to start to think inductively:
- 54321*1 =
- 5*(43211) =
- 5*4!
So, to compute 5! for the right hand column in row n = 5, you just multiply 5 by the result you must already have for n' = 4. That's it, unless n = 0, in which case, the answer is already there from the initialization.
The preceding demonstration and analysis should leave you convinced that in principle we could compute n! for any n. Fill in the first row, then repeat the procedure to get the answer for the next row until you've down that for rows all the way up to n. In other words, do the base step to start, then repeat the "inductive" step n times, and, voila, the answer right there in the righthand column of the table.
That's pretty cool. To make use of it in computer science, we need to formalize these ideas. Here we'll formalize them in the logic of Lean, as propositional logic is too simple to represent these rich ideas.
Specification: Base and Step Functions
So let's now look at what we're doing here with precision.
We envisioned a table. The rows are indexed (first column) by natural numbers. The second row should contain the factorials of these arguments. We don't really have to include the idea of a table in our specification. The only really interesting values are those in the second column, fac n.
So, first, we'll specify the value of fac n for the base case, where n = 0. The value is one, and we'll call it facBase.
def facBase := 1
Now comes the interesting part. To compute each subsequent value, we repeatedly applied the same formula, just moving the index up by one each time, and building a new answer on the answer we already had for the preceding value of n (the preceding row). What computation did we carry out exactly? We represent it as a function, facStep.
Suppose we're trying to fill in the value for row n, where n ≠ 0. As n ≠ 0, there is a natural number one less than n; call it n'. Clearly n = n' + 1, and n' is the index of the preceding row. We know we can fill in that table as high as we want, so when we're trying to fill it in for some row, n, we can assume that we known both n' (easy) and fac n', and from those we can compute fac n.
Crucial idea: What do assumptions turn into in code? They are arguments to other definitions. When you're writing a function that will take two arbitrary arguments, a and b, and combine them into a result in some what, what is your mindset when you write the code? You assume that you actually have two such arguments, and you write the code accordingly. It's going to be the same for us. When you think of assumptions, think of code from the perspective of someone who's writing it. You can always assume something and then act accordingly. This is what programmers do!
We thus turn the assumptions that we know n' and fac n' when trying to compute fac n, into arguments to a function. The person who "implements" the function assumes they get the named values as arguments and then computes the right result and returns it accordingly.
def facStep : Nat → Nat → Nat
| n', facn' => (n' + 1) * facn'
-- Applying facStep to n' and fac n' yields fac n
-- The base case, that fac 0 = 1, gives us a place to start
--
-- n' fac n' n fac n
#eval facStep 0 facBase -- n = 1, 1
#eval facStep 1 1 -- n = 2, 2
#eval facStep 2 2 -- n = 3, 6
#eval facStep 3 6 -- n = 4, 24
#eval facStep 4 24 -- n = 5, _
Induction
Ok, that's all super-cool, and formalizing it in Lean has let us to semi-automatically compute our way up to fac n for any n. The crucial idea is: start with the base values and then apply the step function n times, always feeding it the results of the previous step, or the base values, as the case may be.
Yes, that's fine, but how do we graunch our base and step "machines" into a new machine, a function, that takes any n and automatically computes n!? We'll, at this point, we do not have a way, even in principle, to do this. Any yet we're convinced that having base and step machines should suffice for the construction of a machine that automates what we just said: start with the base then step n times.
The answer is that we need, and have, a new principle: an induction principle, here for the natural numbers. What it says, semi-formally, is that if you have both a value of a function, f, for n = 0, and you have a step function that takes n' and f n' and returns f n, then you can derive and then use a function that takes any n and returns f n for it.
You can think of the induction principle (for Nat) as taking two small machines--one that answers (f 0) and one that takes any n' and f n' and answers (f n)--and that combines them into a machine that takes any n and answers (f n). In a nutshell, the induction principle automates the process of first using the base machine then applying the step machine n times to get the final answer.
In Lean, the induction principle (function!) in Lean for the natural numbers is called Nat.rec.
def fac : Nat → Nat := Nat.rec facBase facStep
Let's break that down. We are defining fac as the name of a function that takes a Nat argument and returns a Nat results. That's the type of the factorial function, as we discussed above. The magic is on the right. The function that fac will name is computed by passing the base value and the step function to the induction function for Nats!
And by golly, it works.
#eval fac 0 -- expect 1
#eval fac 1 -- expect 1
#eval fac 2 -- expect 2
#eval fac 3 -- expect 6
#eval fac 4 -- expect 24
#eval fac 5 -- expect 120
Another Example
Compute the sum of all the natural numbers up to any n.
What is the sum of all the numbers from 0 to 0? It's zero. Now specify your step function: a function that takes n' and sum n' and that returns sum n. Finally, pass these two "little machines" to the Nat.rec to get a function that will work for all n. Then have fun!
def baseSum := 0
def stepSum (n' sumn' : Nat) := (n' + 1) + sumn'
def sum : Nat → Nat := Nat.rec baseSum stepSum
-- Voila!
#eval sum 0 -- expect 0
#eval sum 1 -- expect 1
#eval sum 2 -- expect 3
#eval sum 3 -- expect 6
#eval sum 4 -- expect 10
#eval sum 5 -- expect 15
#check Nat.rec
Nat.rec.{u} {motive : Nat → Sort u} (zero : motive Nat.zero) (succ : (n : Nat) → motive n → motive n.succ) (t : Nat) : motive t
#check List.rec
List.rec.{u_1, u} {α : Type u} {motive : List α → Sort u_1} (nil : motive []) (cons : (head : α) → (tail : List α) → motive tail → motive (head :: tail)) (t : List α) : motive t
Notation
From now on, think of induction as building forward from a base case, at each step using the results from previous steps as arguements to step functions that compute results for the next inputs up.
Moreover, when you're figuring out how to write such a function don't just think of it this way but write down your base value and inductive step functions. What you no longer need deal with is the awkward separate definition then combination of the smaller machines.
Let's break this definition down.
We define a function, sum', that returns the sum of the natural numbers from 0 to any given argument, n.
This function is of coures of type Nat → Nat
The value to which "sum" will be bound is (of course) a function, here specified to take an argument, n, that then acts in one of two ways depending on whether n is 0 (Nat.zero) or the successor (n' + 1) of some one-smaller number (Nat.succ n').
The two cases here correspond directly to the two small machines! In the case of n = 0, we return 0. In the case n = n' + 1 (n greater than 0), we return (n' + 1) + sum n. That is, we apply the step function to n' and sum n', just as we did earlier. And it all works.
def sum' : Nat → Nat :=
fun n => match n with
| Nat.zero => Nat.zero
| Nat.succ n' => Nat.succ n' + sum' n'
#eval sum' 0 -- expect 0
#eval sum' 5 -- expect 15
Lean preferred notation
When writing such definitions, however, it's preferred to use Lean notations for natural numbers. This avoids having to take extra steps when writing proofs later to simply translate and forth between, say, 0 and Nat.zero.
So, instead of Nat.zero, write 0 in practice. And instead of Nat.succ n', write (n' + 1). Here it is important that the + 1 be on the right. When it is, Lean interprets it as Nat.succ 1, whereas on the left it'd be Nat.add 1 n'. The latter is not what you want in a case analysis pattern where you intend to match with Nat.succ n' for some n'
So here's the cleaned up code you should actually write.
def summ : Nat → Nat
| 0 => 0
| n' + 1 => (n' + 1) + summ n'
#eval summ 100
Lean's preferred notation for specifying such functions really does involve specifying the two smaller machines. Just look carefully at right sides of the two cases! The base case is 0. On the right for the inductive case is the step function! summ n = summ n' + 1 = (n' + 1) + summ n'.
Unrolling recursions
Ok, so the induction principle seems wildly useful, and it is. It reduces the problem of writing a function for any n to two simpler subproblems: one for base case (constant), and one to define the step function. Then wrap them both up using "the induction machine" to get a function that works automatically for any n.
But, you ask, what actually happens at runtime? Well, let's see! Here we iteratively evaluate the application of the sum function to 5, to compute the sum of the numbers from 0 to 5, which we can easily calculate to be 15. Be careful to read the notes about which cases in the function definition the different arguments match.
sum 5 -- matches n'+1 pattern; reduces to following: 5 + (sum 4) -- same rule: keep reducing nested applications 5 + (4 + (sum 3)) -- 5 + (4 + (3 + (sum 2))) -- 5 + (4 + (3 + (2 + (sum 1)))) -- 5 + (4 + (3 + (2 + (1 + (sum 0))))) -- now reduce these expressions by addition 5 + (4 + (3 + (2 + (1 + 0)))) -- by apply Nat.add to 1 and 0 5 + (4 + (3 + (2 + (1)))) -- all the rest is just addition 5 + (4 + (3 + (3))) 5 + (4 + (6)) 5 + (10) 15 -- the result for 5
Forget about unrolling recursions
Now that you've seen how a recursion unfolds during actual computation, you can almost forget about it. The induction principle for natural numbers is valid and can be used and trusted: all you need to define are a base case and a step function (of the kind required, taking n' and the answer for n' as arguments and producing the answer for n), and induction will iterate the step function n times over the result from the "base function." The only "hard" job for you is usually to figure out the right step function.
Exercises
Sum of Squares of Nats Up To n
Define a function two ways: using Nat.rec applied to two smaller "machines" (base value and step function), and then using Lean's preferred notation as described above. The function should compute the sum of the squares of all the natural numbers from 0 to any value, n.
Start by completing a table, in plain text, just as we presented above, but now for this function. As you fill it in, pay close attention to the possibility of filling in each row using the values from the previous row. That will tell you what your step function will be.
-- base value machine
def baseSq : Nat := 0
-- step up answer machine
-- from n' and sumSq n' return (n' + 1)^2 + sumSq n'
def stepSq : Nat → Nat → Nat
| n', sum_sq_n' => _
-- here's how the stepping up works
#eval stepSq 0 0 -- return answer for n = 1; expect 1
#eval stepSq 1 1 -- return answer for n = 2; expect 5
#eval stepSq 2 5 -- return answer for n = 3; expect 14
#eval stepSq 3 14 -- return answer for n = 4; expect 30
#eval stepSq 4 30 -- return answer for n = 5; expect 55
-- apply induction to construct desired function
def sumSq : Nat → Nat := Nat.rec baseSq stepSq
#eval sumSq 0 -- expect 0
#eval sumSq 1 -- expect 1
#eval sumSq 2 -- expect 5
#eval sumSq 3 -- expect 14
#eval sumSq 4 -- expect 30
#eval sumSq 5 -- expect 55 (weird: also sum of nat 0..10)
def sumSq' : Nat → Nat
| 0 => 0
| (n' + 1) => let n := n' + 1
n^2 + sumSq' n'
#eval sumSq' 0 -- expect 0
#eval sumSq' 1 -- expect 1
#eval sumSq' 2 -- expect 5
#eval sumSq' 3 -- expect 14
#eval sumSq' 4 -- expect 30
#eval sumSq' 5 -- expect 55 (weird: also sum of nat 0..10)
Format your table here
Now write test cases as above, including "expect" comments based on your table entries above, and be sure your tests (using #eval) are giving the results you expect. Example: sumSq 2 = 2^2 + 1^2 + 0^2 = 5.
Nat to BinaryNumeral
Define a function that converts any natural number n into a string of 0/1 characters representing its binary expansion. If n = 0, the answer is "0". If n = 1, the answer is "1". Otherwise we need to figure out how to deal with numbers 2 or greater.
Let's make a table
n | binary |
---|---|
0 | "0" |
1 | "1" |
2 | "10" |
3 | "11" |
4 | "100" |
5 | "101" |
What do we notice? The righmost digits flip from 0 to 1 to 0 to 1, depending on whether the number is even or odd. Another way to think about it is that if we divide the number by 2 and compute a remainder, we get the rightmost digit. 4/2 = 0. 0 is the right digit of "100". And 5/2 = 1, the rightmost digit of "101". The remainder when dividing by 2 is also called n mod 2, or n % 2 in many popular programming languages.
#eval 0 % 2
#eval 1 % 2
#eval 2 % 2
#eval 3 % 2
#eval 4 % 2
#eval 5 % 2
So given any n we now have a way to get its rightmost digit in binary by taking the number mod 2. As an aside, we can easily convert such a 0/1 natural number to a string using toString.
#eval toString 5
But what about converting the rest of the input to binary digits? What even is "the rest" of the input. Well, in this case, it's n/2, right? When we divide n by 2 using natural number arithmetic, we get the quotient and thow away the remainder. So let's look at how to covert 5 to a its binary expansion.
n | n/2 | n%2 | str |
---|---|---|---|
5 | 2 | 1 | "1" |
2 | 1 | 0 | "0" |
1 | 0 | 1 | "1" |
0 | 0 | 0 | "" |
At each step, we get the right most digit using %2 and we get the rest of the number to covert on the left of that last binary digit (bit) using /2. We're done at the point where the number being converted is 0, for which we return "". Finish the definition of the function here, and write test cases for n up to 5 to see if it appears to be working.
By the way, you can append to strings in Lean using the String.append function, with notation, ++.
#eval "10" ++ "1"
notation based on your known base and step "machines". We provide most of an answer. Do not guess. Figure it out! After the ++ comes the length-one "0" or "1" string for the rightmost digit in the binary numeral. What completes the answer by appearing to the left of the ++ ((String append)?
def binaryRep : Nat → String
| 0 => "0"
| 1 => "1"
| n' + 2 => let n := n' + 2
_ ++ toString (n % 2)
-- Complete the definition. The tests will work,.
#eval binaryRep 0 --expect "0"
#eval binaryRep 5 --expect "101"
Other Induction Axioms
Interesting. We've got ourselves a recursive function, but it doesn't quite fit the schema we've seen to now. Nat.rec allows us to build a general purpose function from a machine that returns an example for one based case and a machine that builds an answer for n = (n' + 1) from the one for n' (which is obviously n - 1). But in our function, we construct an answer for n (for n ≥ 2) from an answer for n/2, not n-1. It works but it won't work to pass this kind of step function to Nat.rec.
As you might guess there are several induction axioms. The form we've studied lets you assume that when computing an answer for n = (n' + 1) that you can assume you have n' and the answer for n'. From those you compute the answer for n.
A different form of induction let's you assume when computing an answer for n that you have n' and answers for all values from n' down to the base value, here 0.
We have implicitly used this other axiom in this case, as we're accessing an answer "earlier" in our table, but not at position n - 1, rather at position n / 2. The string representation of that will be all of the binary digits to the left of the final digit, determined separately by the remainder, n % 2.
When we write recursive functions in practice, we can usually assume access to answer for *all smaller" values of n' (e.g., lesser by one, or quotient by 2), by way of recursive calls with these values as actual parameters.
Specifying the Fibonacci Function
Haha. So we finally get to the exercise. You are to specify in Lean and test a function to compute values of the Fibonacci function for any Nat argument, n. Here's how you might see it defined in a book.
For 0, fib should answer 0 For 1, fib should answer 1 For any n = (n' + 2), fib should answer fib n' + fib (n' + 1)
You can see here that the definition assumes that when computing an answer for n, that answers are available for both fib n' [fib (n - 2)] as well as for fib (n' + 1) [fib (n - 1)]. Nat.rec won't work here. Just use the recommended syntax and notations for writing such functions.
Oh, the table. It helps! Fill in enough answers to grok it! Complete the table in your notes.
n | fib n |
---|---|
0 | 0 |
1 | 1 |
2 | 1 |
3 | 2 |
4 | 3 |
5 | 5 |
6 | 8 |
7 | 13 |
8 | __ |
9 | __ |
10 | __ |
You see how you build an answer for n from answer not just one but both one and two rows back.
Final hint: Define two base cases, for n = 0 and n = 1, then a third case for the indutive construction, for any n = (n' + 2).
def fib : Nat → Nat
| 0 => _
| 1 => _
| n' + 2 => _
Write test cases for 0, 1, 2, and 10. Does it work?
What we see in the definition of the Fibonacci function are two base cases and a "step" function that uses the results of the prior two computations for all inputs greater than or equal to two.
The Nat induction principle we've see up to now does not rely on multiple prior values. This function does. So it must be using a different notion of induction, and that's exactly right. In strong induction, at each step one can assume one has not only the result for the immediately preceding argument, but for the answers for all prior argument values.
The Fibonacci function is a special case. It assumes it has, and it uses, the prior two outputs to compute the output for the next larger input value. The variant of simple induction (for Nat values) used here is called strong induction. We're not yet prepared to use it directly to define recursive functions like fib, but you should now be able at least to read and understand it. Your assignment here, then, is simply to express the formal definition in easy to understand English. In Lean, strong induction is supported (for Nat) by the axiom called Nat.strongRecOn.
--
#check Nat.strongRecOn
open Nat
end DMT1.Lectures.induction.induction
mathlib
Lean's mathlib is where the mathematical community's formalizations of lots of different parts of mathematics live. There are riches here, for almost any application.
The upshot for you at this point is that, once you master the next part of the course, on predicate logic in Lean 4, you can pick nearly any area of mathematics and reason in it, now with the full support of Lean, its infrastructure, and vibrant community.
The great thing is that, unlike first-order theory, Lean admits the practical, abstract, expressive, and verified embedding of an incredible diversity of logical and mathematical structures into its foundational logic, formulated in the abstract concepts of the mathematical domains, and made much more useful with concrete notations appropriate to the particular field being formalized. In Lean, one can speak easily about such things as properties of relations, or real real numbers. Neither is possible in the first-order that we mostly teach in traditional courses.
Make no mistake, first-order predicate logic with first-order theory of sets and relations is still an utterly indispensable language. It's syntax is used in many important reasoning systems, incluiding Z3, Dafney, Alloy, and others. But with knowledge of Lean, you also have immediate access to this incredible treasure trove of verified mathematical knowledge, and it's nearly trivial to understand first-order logic once you have seen the bigger picture.
If you're interested in a visual overview of areas of mathematics that Lean 4's mathlib provides, the overview of mathlib in Lean 3 is the best right now. The Lean 4 version is evolving and contributions are welcome.
Predicate Logic
- Limited Expressiveness of Propositional Logic
- Enhanced Expressiveness of Predicate Logic
- Semantic Domains for Predicate Logic
- Limited Expressiveness of First-Order Predicate Logic
- The Enhanced Expressiveness of Higher-Order Logic in Lean
In propositional logic, syntactic variable expressions are interpreted only over sets of Boolean state values.
- PressureTooHigh /\ ORingsFailing
- (GreenLight \/ RedLight) /\ ~(GreenLight /\ RedLight)
Limited Expressiveness of Propositional Logic
Within the logic one can thus only speak about real world situations in terms of sets of Boolean variables and their values. The choice of propositional logic as a reasoning language limits one to representing real-world conditions in these very spartan, sometimes inadequate, and sometimes misleading terms.
As an example, suppose you want to reason about the safety of a driving vehicle. It has a brake and a steering system. Each is deemed 70.5% likely to be operational. That's high (by some low standards), so suppose we model this world in Boolean terms as { BrakeOk := true, SteeringOk := true }.
Now of course we want both brakes and steering for safety, so we evaluate BrakesOk /\ SteeringOk over this structure (intepretation) and get true. We might make the mistake of believing that the result soundly reflects reality. If the threshold for being Ok is a greater than 50% chance of success, this condition still doesn't hold: assuming that the failures are independent, we find that the combination of the probabilities yields a likelyhood of the overall system being ok is less than 50%.
Enhanced Expressiveness of Predicate Logic
A way out of this bind is to pick a more expressive logic: one that enables us to represent worlds in richer terms so that we might be enabled to reason about real worlds more effectively. The varieties of predicate logic provide such improved expressiveness.
In a predicate logic, we can speak not only in terms of Boolean variables and values, and the usual operations on them, but also in terms of sets of objects, relations between objects, properties (via predicates) of objects, functions that take objects as arguments and return them as results, and about whether all, or respectively at least one, of the elements of a given set has a given property.
It's on the foundation of a simple predicate logic that most of contemporary mathematics rests. To give a sense how this could work, note that one can encode the natural numbers as: either the empty set, encoding zero, or as a set containing the set representing a one-smaller number. The nesting level represents the value, just as for Nat in Lean.
The larger point here is that the choice of any given logic in which to reason about the world brings with it a form of structure in terms of which one can represent specific states of the real world, over which expressions are evaluated.
Semantic Domains for Predicate Logic
In particular, predicate logic brings along with it a much richer form of structure (than vector of Boolean) that one use to represent real world conditions of interest. A set of possible world situations no longer has to be encoded just as a vector of binary Boolean values. We can represent a world now in essentially relational terms. Predicate logic is the core theory underpinning relational databases. Our world state representation structures can now have:
- objects representing corresponding objects in the real domain (e.g., "Tom", "Mary")
- functions from objects to objects in the real world (e.g., motherOf Tom = Mary)
- relations among objects in the real world (e.g., youngerThan Tom Mary)
- sets of objects (e.g., { "Tom", "Mary" }) [unary relation]
To know how to use predicate logic effectively, it really helps to think about how you want to represent the space of all of the possible worlds about which you might speak, and how to represent specific individual worlds in that space. Think in relational terms. @@@ -/
/- @@@ The syntax of predicate logic is then set up to provide nice ways to talk about such world representations. The syntax provides constant symbols (referring to fixed domain objects); (free) variables, also interpreted as referring to objects; function symbols (and arguments, interpreted as referring to functions in the world); and predicate symbols refering to relations in the domain. Predicate logic inherits the connectives of propositional logic, adds two kinds of quantified expressions, and puts it all together into a syntax of well formed formula in predicate logic.
Limited Expressiveness of First-Order Predicate Logic
The variant of predicate logic taught in typical DMT1 courses is called first-order predicate logic. The logic of Lean is a higher-order logic. The difference is in the generality of what can be expressed in each of these logic.
To see the difference concretely, let's consider what properties a friendOf relation should have on some new social network. It could be symmetric, as it is on FB; or one might prefer for it not necessarily to be symmetric. Maybe a follows relation would be better. I follow Bill Gates but he doesn't follow me.
In first-order logic we can use the universal quantifer syntax to specify that our friendOf relation is (to be) symmetric. We can write this as ∀x∀y(friendOf(x,y)↔friendOf(y,x)). The variables, x and y, are bound by the quantifer to range over all objects in the semantic domain. The predicate after the comma then checks whether all such pairs of objects satisfy the following predicate.
A major restriction on expressiveness imposed by first-order logic is that quantified variables can be range only over objects. One cannot quantify over all sets of objects, functions, or relations. In first order theory we can express the idea that some particular relation is symmetric.
What we can't express in first-order logic is the concept of symmetry as a generalized property that any given binary relation on any set of objects of any kind might or might not have. And yet being able to reason fluently with concepts at this level of generality is essential for any literate mathematician. in terms of We cannot express the property of a relation of being symmetric, a crucial degree of mathematical generality*, in first order theory because we cannot talk about all relations.
The Enhanced Expressiveness of Higher-Order Logic in Lean
The most crucial property of Lean for this course, and for research worldwide on the formalization of advanced mathematics, is that Lean implements a higher-order logic in which you can express concepts of great generality by quantifying not only over elementary values but over such things as types, functions, propositions and predicates, and so forth.
As an one example, in higher order predicate logic in Lean, one can define generalized properties of relations: for example the property of a binary relation r on a set s of values of some type T that r relates any two values in one direction only if it it also relates them in the other order.
def symmetric :=
∀ (T : Type)
(R : T → T → Prop)
(t1 t2 : T),
r t1 t2 ↔ r t2 t1
That is, for any type of objects, T (quantification over types), and for any binary relation, R, on values of this type (quantification over relations), what it means for R to be symmetric is that for any two objects, t1, t2 of type T (a first-order quantification), if R relates t1 to t2 then it also relates t2 to t1.
We can then apply such generalized definition to any particular binary relation on any type of values (let's say, isFriend, on values of type, Person) to derive the proposition that that relation is symmetric. The expression, symmetric Person isFriend, would then reduce to the first-order proposition, *∀ t1 t2 : Person, isFriend t1 t2 <-> isFriend t2 t1.
For the working mathematician it's crucial to be able to reason and speak in terms of such abstract and generalized properties of complex things. We speak of relations being symmetric, reflexive, transitive, well founded, and so on, without having to think about specific relations. We think of operation being commutative, associative, invertible, again in precise but abstract and general terms, independent of particular operations. In the first-order logic of the usual CS2: DMT1 class, that's just not possible. In Lean 4, it is completely natural and incredibly useful.
The result has been an explosion in the community of mathematicians using Lean 4 to formalize and verify theorems in many branches of advanced mathematics. That in turn has sparked deep interest in computer science in the use of Lean 4 for research and development in trustworthy computing, among many other areas. The rest of this chapter skips over first-order logic to introduce predicate logic through its higher-order, much more useful and relevant variant, in Lean.
Introduction
- Overview
- Examples
- Differences From Propositional to Predicate Logic
- Propositions as Types: Predicate Logic in Lean 4
In predicate logic, whether first- or higher-order (as we discuss later), one speaks of worlds characterized in terms of entities (and in some predicate logics types of entities); functions in the world that take and return entities; and relations over objects represented by predicates. A predicate in turn is simply a proposition with placeholders (parameters) where specific objects can be plugged in to yield a proposition about them that in turn could be judged as either true or false. We say that an object or a tuple of objects satisfies a given predicate (is in the relation it specifies) when the resulting proposition about them is true. Predicate logics also support set theory: sets of objects and ways of speaking about all of the elements in a given set, or at least one element of a given set as satisfying some condition.
Overview
Predicate logic provides syntactic symbols to refer to such things, including constant and variable symbols (to refer to domain entities)); function names, referring to functions in the domains; and predicate names referring to properties of individual objects, or to relations among entities.
Prredicate logic adopts the logical operators and their usual truth-functional meanings from propositional logic, but now as composing smaller expressions in predicate logic.
Examples
We'll consider two examples.
A String Length Property
- character strings and natural numbers as entities
- a set of strings, e.g., containing three particular strings
- a function, called length, from any string to its length
- a relation, lexicographic less than or equal, on strings
Using this vocabulary we might then want to express the idea that all of the strings in the set have the same length, In the real predicate-logic-speak we would say that there is some natural number, n, such that n is the length of the first string, and of the second string, and of the third string in the set.
But rather than enumerating thos three explicitly (because, what if it were a million of them) we'll just use a universal ("for all") quantifier. The ∀ quantifier can be thought of as a generalized (n-ary) and (∧) operation. Thus, there is some n such that n is the length of every string in the set of them.
∃ (n : Nat), (∀ s ∈ strings), s.length = n
The truth of a proposition is evaluated over structure representing such worlds. One often views a proposition as specifying a property that one can then test individual worls for, yielding propositions that are then subject to ordinary proof construction efforts.
No Largest Natural Number
As an example in first-order predicate logic, we can write the expression, ∀n ∃m (m > n + 1). In English we'd pronounce this as "for every n there's an m such that m is greater than n."
To give semantic meaning to such an expression, we need to specify a semantic domain (the world about which the expression speaks), along interpretations that map the syntactic symbols in this expression to values in the domain. Only then could we evaluate the expression as being true or not in that domain.
To illustrate, we'll informally describe two different domains and interpretations for our example expression.
One Interpretation
First, we will take the domain to be the universe of natural number arithmetic. Natural numbers, functions, and relations will be the entities in the domain. We will then interpret the names, n and m as variables with natural number values, + as the natural number two-argument addition function, and > as the binary greater than relation on natural numbers.
Under this interpretation (to this semantic domain), the expression means the proposition that for any natural number, n, there is some natural number m, such that m is greater than one more than n. It's easy to see that the expression can be judged true when evaluated over this domain structure.
A Different Interpretation
On the other hand, the entities of the domain could be people; m > n could be the relation that holds exactly when m is nicer than n; 1 could mean a litte bit of additional niceness; and + a function that adds up niceness levels. Under this interpretation, the expression would assert that in this domain, for every person, n, there is someone person, m, who's more than a bit nicer than they are. Here one can judge the expressions to be false, as it'd require an infinite supply of ever nicer people to be true, but the number of people is finite, so there must be maximally nice people beyond which there are none who are nicer.
In higher-order predicate logic as typically defined in Lean, interpretations are more tightly bound to names in such expressions. We would distingish the two propositions as their very syntax:
- ∀ (n : Nat), ∃ (m : Nat), (m > n + 1)
- ∀ (n : Person), ∃ (m : Person), (m > n + 1)
In Lean, the meanings of the symbols, + and > would then be inferred from context. In the first expression, Lean would know to take the function, Nat.add, as the meaning of +, and the Nat.lt relation as the meaning of <.
Something About People
As an example, domain could have people as entities. It could have a function, motherOf, that maps any give person, p, to the person who is the mother of p. And there could also be a property of a person expressed as a predicate, isBlue, that takes a person and indicates whether that person is blue.
With this semantic domain, Ote could then assert the following using the language of predicate logic: the mother of everyone who's blue is blue, too. A literal reading would say if p and q are people and then if p is blue and then if q is the mother of p then q is blue.
- ∀ (p q: Person), isBlue p → isMother q p → isBlue q
In first order logic, it'd be more tedious to express, and there would be more room for uncaught implicit type errors.
- ∀ p, q, isPerson p → isPerson q → isBlue p → isMother q p → isBlue q
Differences From Propositional to Predicate Logic
With that introduction, we now highlight key differences between propositional and predicate logic, focusing on differences that hold whether one is speaking of simple first-order predicate logic or the much mroe expressive higher-order predicate logic provide by the Lean prover.
Universal and Existential Quantifers
In predicate logic, the set of logical operators is extended to include existential and universal quantifier expressions. The universal quantifer expression, ∀x Px is used to assert that every object x satisfies the one-argument relation, P. The existential quantifier expressions, ∃x Px is used to express the proposition that there is some object, x that satisfies the predicate P.
In Lean, with its strong typing, these expressions would be written, ∀ (x : T), P x and ∃ (x : T), P x. There are a few minor differences in syntax. When Lean can infer the type, T, of domain entities, from context, one can omit the explicit type judgments, (x : T) and (y : T) and write, ∀ x, P x and ∃ x, P x making the syntax close to that of first-order predicate logic. In this book we focus heavily on predicate logic in Lean, viewing first-order logic as a special case.
Unbounded Diversity of Semantic Domain Structures
Propositional logic has Boolean algebra as its fixed semantic domain. One evaluates expressions in this language over simple structures that bind propositional variables to Boolean values.
Predicate logic, by contrast, admits many semantic domains. You can use it to talk about whatever domain you care to talk about, provided you can represent it in terms of objects; sets of objects (in first-order logic with set theory), or types of objects (in the higher-order logic of Lean); relations among objects; and functions that take objects as their arguments and return other objects as results.
As an aside, first-order predicate logic with sets as a theory extension (first-order set theory) speaks in terms of sets of objects. The higher-order predicate logic of Lean speaks in terms of types of objects. For now, you can think of a type in Lean as defining a set of objects, namely all and only the objects of that type.
Typed Higher-Order vs. Untyped First-Order
First-order predicate logic can be considered as either an untyped, or equivalently (and better) as a monomorphic (one-type) language. Every entity has the same type: we'll call it Object. In this sense, first-order logic is like Python. You can apply any function to any object and it is the job of the function definition to (1) figure out if it is really the right kind of object for that function to process,m and (2) decide what to do if it's not.
Here's a simple version of first-order logic embedded in Lean 4.
Object is the type of every entity in first-order logic. Here we use Lean to allow objects of what we might call different "runtime types" but all having Object as their "static types". In particular, here we define (Object.person n) to be the n'th person in whatever world we might be modeling, and (rock n) to be the n'th rock.
inductive Object
| person (n : Nat)
| rock (r : Nat)
In first-order logic, the only way to know what kind of object on has been given is to apply a predicate. So here are two predicates, the first true for objects deemed to be people and the second, rocks.
def isPerson : Object → Bool
| Object.person _ => true
| _ => false
def isRock : Object → Bool
| Object.rock _ => true
| _ => false
Now we'd like to assert that everyone is mortal. So we'll define a predicate, which is to say, a proposition with one or more arguments that, when applied to arguments, yields a proposition. We'll think of predicates then as functions that applied to arguments reduce to propositions. However, as there are only Object-type entities in first-order theory, we need a function that takes any Object value and answers accordingly. This function will answer true if the argument is a person, as all people are in fact mortal. But if applied to, say, a rock, the function will still have to answer with a Boolean value. We define it here to answer false in such cases, but the reality is the the question itself doesn't make sense. It's just like in Python you can pass a string to a function that expects a number and nothing will go wrong--until you run the program, at which point the argument can be runtime-tested to determine what kind of thing it is really meant to represent.
def isMortal : Object → Bool
| o => if isPerson o then true else false
We're now ready to use our first order logic. First we define some objects: a person named socrates and a rock that we'll simple call, someRock.
def socrates : Object := Object.person 0
def someRock : Object := Object.rock 0
Now we can ask whether either of these objects satisfies the isMortal predicate.
#eval isMortal socrates -- yes/true
#eval isMortal someRock -- no/false
But, again, it doesn't even make sense to ask if a rock is mortal. If it is then one might conclude that at some point in time the rock was alive and it is or eventually will be dead, and if it's not then what? It is alive and will live forever? I don't know about you, but to me, these statements don't "type check."
Essentially the same world but expressed in the strongly and statically typed language of Lean 4.
First, Person and Rock are now separate types, not just differently shaped terms of the same type, Object.
structure Person where
(n : Nat)
structure Rock where
(n : Nat)
Second, we define isMortal' (with a tick mark so as not to have a name conflict) as a property of values of type Person only. In more detail, here we define isMortal' as a predicate parameterized by a person, with a single constructor for proofs of propositions obtained by apply8ing the predicate to a Person. The constructor takes any Person, p, as an argument and yields a proof of the proposition, isMortal' p.
inductive isMortal' : Person → Prop
| everyoneMortal: (p : Person) → isMortal' p
open isMortal'
Next we define a person entity and a rock entity, but now they are of completely unrelated types.
def quine : Person := Person.mk 0
def doorStop : Rock := Rock.mk 0
#check quine -- type Person
#check someRock -- type Rock
-- We can ask if Quine is mortal
def p0IsMortal : isMortal' quine := everyoneMortal quine
-- But the question whether a rock is mortal is a type error
def r0IsMortal : isMortal' doorStop := sorry
application type mismatch
isMortal' doorStop
argument
doorStop
has type
Rock : Type
but is expected to have type
Person : Type
Now in first-order logic, one would typically use natural language to set up a context in which a formal expression makes sense. One might say this, for example.
We postulate a world inhabited by entities of two kinds, namely person and rock, with predicates defined to tell objects of these kinds apart: isPerson and isRock. In this setting we define the predicate isMortal to take a single object. If it's a person, the predicate is true of that object; and if it's a rock, it's false for that object. In this context, we can express the proposition that all people are mortal, as follows:
#check ∀ x, isPerson x → isMortal x
But this predicate is true for rocks, isn't it! You can see how things can easily go awry when writing complex specifications in first-order logic. In the typed logic of Lean we can say it easier and better:
#check ∀ (p : Person), isMortal' p
No runtime ("reasoning-time") test is needed to tell if the argument really represents a person or not. The type checker of Lean 4 does that for us right up front.
Older Version of this Subsection
The next major difference between the first-order theory of a traditional discrete math course, and the predicate logic that students first learn in this class, is that first-order logic is untyped. It's akin to Python. Everything is just an object in the first instance. It't only by explicitly testing an object at runtime that you tell what kind of object you've been handed.
To express the idea that all people are mortal, in first-order thus untyped logic, for example, you'd say, For any object, p, if p "is a person" (if p satisfies the isPerson predicate), then p satisfies the isMortal predicate.
#check ∀ p, isPerson p → isMortal p
The "∀ p" in effect loops over every object in the universe, tests each to see if it's a person, and in that case it asserts that that particular object, p, also satisfies the isMortal predicate.
In Lean, by contrast, you would have an inductive type defined, called Person, and you would say, given any object, p, "of type" Person, p is mortal. Here it is in Lean.
#check ∀ (p : Person), isMortal' p
This proposition asserts that if p is any Person, then p is mortal. There is no possibility that p could refer to any object other than a Person object here, nor than isMoral could be applied to any object other than a Person. The Lean syntax and type checkers will not allow it. By constrast there would be nothing wrong, in first-order predicate logic, with applying the isMNortal predicate to a cheese or some gas, as everything is in the first instance just an object.
From Model-Theoretic (Semantic), to Proof-Theoretic, Validity
One of the most notable changes from propositional to predicate logic is that in the former, one can assess the validity of a proposition by evaluating it over each of a finite number of interpretations. As soon as one can interpret a variable in predicate logic as having a natural number as its meaning, one is into the realm of domains of infinite size.
A new kind a reason will be necessary to replace semantic evaluation. It will be instead by deduction from certain propositions accepted as axioms of the logic, which is to say, accepted valid, without proof. In fact, propositional logic is incorporated into deductive reasoning in predicate logic adoption of the propositions in the earlier axioms as the axioms of predicate logic.
Suppose for example that we have two propositions in predicate logic, P and Q, and we want to show deductively that P /\ Q -> Q /\ P. To do this, we notice the top-level connective is implies. Taking a very computational perspective, we read the proposition as asserting that if one assumes that one is given a proof of P /\ Q, then one can derive from it a proof of Q /\ P. Thus, if P /\ Q is true then so is Q /\ P.
So let's assume we do have a proof of P /\ Q, and let's call it pq. We can apply and_elim_left to pq to derive a proof of p from it, and similarly a proof of q. We can then apply the proof constructor, and.intro to these proofs in q-then-p order to have a proof of Q /\ P.
Propositions as Types: Predicate Logic in Lean 4
Prediate logic is a richer and more expressive language that propositional logic. Moreover, the higher order logic of Lean is richer and more expressive than the first-order logic of the traditional course in discrete mathematics.
In this class we will meet predicate logic through what we could call a shallow embedding of the logic into Lean We will implement the syntax and rules of deductive reasoning in predicate logic in Lean as a set of ordinary inductive type and function definitionss, with no single type expressing the over syntax or semantics of the logic.
Propositions as Computing Types
- Represent Propositions as Types, Specified ...
- ... So That Values Encode Proofs Down to Axioms
- Representing The Logical Connectives
- Example: How And Distributes Over Or
- Homework
In this chapter you'll see how elegantly one can embed a higher-order predicate logic into Lean by representing:
- elementary propositions as ordinary inductive data types
- and and or connectives as inductive type builders
- negation as the function type from any P to Empty
- All and only the values of such types type check as proofs
To prove a proposition, you represent it as a type in the way we are about to explain, then you specify a program that constructs any value of that type. And that works as a proof.
This chapter covers a few fundamental programming concepts:
- the polymorphic product and sum types
- empty (uninabited) types, including Empty
- function types to and from empty types
Represent Propositions as Types, Specified ...
We can represent elementary propositions, and their truth or falsing, by defining types that either do or do not have any values. Here we define three true propositions, P, Q, R, and one false one, N, the negation of which will then be true.
inductive P : Type where | mk deriving Repr -- has value
inductive Q where | mk deriving Repr -- has value
inductive R where | mk deriving Repr -- has value
inductive N where /-nothing!-/ deriving Repr -- no values
Lean Detail: The deriving Repr annotation simply asks Lean to generate code to pretty print values of the given type, e.g., when one uses #eval/#reduce to produce a reduced value to be displayed.
... So That Values Encode Proofs Down to Axioms
Correspondingly we will define our types so that their values represent bona fide proofs of the propositions they represent, all the way down to axioms, including the constructors, that we take as introduction rules, of the types representing our basic propositions: P, Q, R, N. We will routinely ask Lean to check that a proof term really does encode a correct proof, which it does by checking that the proof term is a value of the type representing the proposition to be proved.
Here are two examples where we ask Lean to confirm that we have a good proof term. Here p is a name bound to a term, P.mk, that typechecks as a proof of P. The example construct also forces typechecking without binding a name.
def p : P := P.mk
example : Q := Q.mk
We defined the type N to be uninhabited to illustrate the idea that we represent the falsity of a proposition by the uninhabitedness of the type that represents it. So if we try to prove N we get stuck being unable to give term of this type, because there are none.
def r : N := _ -- No. There's no proof term for it!
Representing The Logical Connectives
We see how to represent elementary propositions, such as P and Q, and N as types. But what about building larger, compound propositions such as P ∧ Q, P ∨ Q, P → Q, or ¬P from the individual smaller ones? We will now show how this is done for each of these connectives.
Represent P ∧ Q as a Product Type P × Q
We will represent the proposition, P ∧ Q, as the type, Prod P Q in Lean. This is the type that represents all ordered pairs of values of types P and Q respectively, If values are proofs, then a pair with a proof of P as its first value and a proof of Q as its second value will suffice as a proof of P ∧ Q.
Here's Lean's definition of the polymorphic pair type in Lean 4, enclosed in a namespace so as not to conflict with the standard Library Prod type.
namespace hide
structure Prod (α : Type u) (β : Type v) where
mk ::
fst : α
snd : β
end hide
The Prod polymorphic type builder takes two types as its arguments. For our purposes here we assume they will represent the two propositions being conjoined. Now, by the definition of structure in Lean, if one has a value, h, of type Prod P Q, then h.fst is the contained proof of P and h.snd is that for Q. Finally, Lean provides × as concrete syntactic notation for Prod, reflecting the standard notion of a product of types or sets in mathematics.
Product types have one constructor with multiple arguments, and so can only be instantiated if one has arguments of each of the required types. The constructor of a type Prod P Q, or equivalently P × Q, is called Prod.mk. So let's look at some examples.
abbrev PAndQ := P × Q -- Representing the proposition, P ∧ Q
def pandq : P × Q := Prod.mk P.mk Q.mk -- Representing proof!
example : P × Q := ⟨ P.mk, Q.mk ⟩ -- Notation for Prod.mk
Comparing the setup we've contstructed here, we see that the and_intro proposition, which we validated in propositional logic, remains true here. That rule said P → Q → P ∧ Q. That rule is realized in our new setup by the Prod constructor! If given a value of P and one of Q, it returns a value of P × Q, which, here, we're viewing as a proof of P ∧ Q.
Similarly, the elimination (elim) rules from predicate logic work just as well here. They are P ∧ Q → P and P ∧ Q → Q. Given a value, here a proof, h : P × Q, again representing a proof of P ∧ Q, you can derive a proof of P as h.fst and a proof of Q as h.snd. (Note: it's because Prod is defined as a structure that you can use its argument names as field names to access the component values of any such structure.)
#eval pandq.fst
#eval pandq.snd
Not only have we thus embedded the three "axioms" for ∧ in propositional logic into Lean 4, but we can now also prove theorems about ∧, as defined in proposition logic in the identities file.
For example, we confirmed semantically in propositional logic, using our validity checker, that (P ∧ Q ↔ Q ∧ P) is valid. Let's consider just the forward direction, i.e., P ∧ Q → Q ∧ P. For us, a proof of that is a function: one that takes a value of type (a proof of) P ∧ Q as an argument and that returns a proof of Q ∧ P. Using Prod for ∧, what we need to show is P × Q → Q × P.
That we can define this function shows that if we're given a proof (value) of P ∧ Q represented as a value of P × Q, then we can always turn it into a proof of Q ∧ P in the form of a value of type Q × P. All that we have to do in the end is flip the order of elements of the proof of P ∧ Q to get a term that will check as proof of Q ∧ P. Here it is, in three equivalent versions: fully written out; using Lean's ⟨_, _⟩ notation for the default mk constructor; and finally all on one line, as an explicit function term.
def andCommutative : P × Q → Q × P
| Prod.mk p q => Prod.mk q p
def andCommutative' : P × Q → Q × P
| ⟨ p, q ⟩ => ⟨ q, p ⟩
def andCommutative'' : P × Q → Q × P := λ ⟨ p, q ⟩ => ⟨ q, p ⟩
Represent P ∨ Q as a Sum Type P ⊕ Q
As we represented the conjunction of propositions as a product type, we will represent a disjunction as what is called a sum type. Whereas a product type has but one constructor with multiple arguments, a sum types has two constructors each taking one argument. A value of a product type holds one of these and one of those, while a sum type holds one of these or one of those. We thus used the polymnorphic Prod type to represent conjunctions, and now we do the same, using the polymorphic Sum type to represent disjunctions and their proofs.
#check Sum
Here is the definition of the polymorphic Sum type (type builder) in Lean.
inductive Sum (α : Type u) (β : Type v) where
| inl (val : α) : Sum α β
| inr (val : β) : Sum α β
-- Proof of *or* by proof of left side
def porq1 : Sum P Q := Sum.inl P.mk
-- Proof by proof of right side, with notation
def porq2 : P ⊕ Q := Sum.inr Q.mk
You should be able to construct your own simple examples from here, as in the previous section, but let's go ahead and formulate a prove as a theorem one direction of one of the equivalences, namely P ∨ Q → Q ∨ P. But before we get formal, why should this be true? How would you reason through this? Try it on your own first, then proceed.
The trick is to see that you have to deal with two possible cases for any given proof of P ∨ Q: one constructed from a proof of P on the left and one constructed from a proof of Q on the right. What we need to show is that *we can derive a proof of Q ∨ P in either case. In the first case we can have a proof of P from which we can prove Q ∨ P on the right. In the second case we have a proof of Q on the right, and from that we can prove Q ∨ P with that proof of Q moved to the left.
example : P ⊕ Q → Q ⊕ P
| Sum.inl p => Sum.inr p
| Sum.inr q => Sum.inl q
Represent P → Q as the Function Type P → Q
We can now represent a logical implication, P → Q as the corresponding total function type, P → Q, viewing P and Q now as types. Indeed, they are the types of their proofs. So P → Q is a type, namely the type of a function that takes any proof of p as an argument and and from it derives and finally returns a proof of Q. So if P is true, this function can then that so is Q,
Represent ¬N as The Function Type N → Empty
If a proposition, P, has any proofs, it is judged to be true (valid). The way represent a false proposition is as a type with no values. Here, N is such a type. We say N is an uninhabited type, and we would just N to represent a false proposition.
Now comes the fun part: Given that it's false, we would expect ¬N to be true. So what will we take to represent a proof of ¬N? The proximate answer is that we will take a proof that N is uninhabited to be a proof of ¬N. But what will constitute a proof of uninhabitedness? The answer is any function of type, N → Empty.
The idea is that if a type, say N, has one or more values, then no (total) function from N to empty can be defined, as there will be some value of N for which some value of type Empty will have to be returned, but there are no such values. It's only when N is empty that it will be possible to define such a total function to Empty. That's because there are no values/proofs of N for which a value of the Empty type needs to be returned.
-- Can't prove that P is false, as it has a proof
def falseP : P → Empty
| P.mk => _ -- can't return value of Empty type!
-- But *N* is empty so this definition works
def notr : N → Empty := fun r => nomatch r
The upshot of all of this is that we can prove that a proposition, say N, is false by proving that it has no proofs, and we do that by proving that there is a function from that type to Empty. We can even define a general purpose neg connective to this end, and give it a concrete notation, such as ~.
def neg (A : Type) := A → Empty
notation: max "~"A => neg A
example : ~N := λ (h : N) => nomatch h
Example: How And Distributes Over Or
With that, we've embedded most of the propositional part of predicate logic into Lean, and are now able to write (and even prove) interesting propositions. Here's a last example before you set off on your own homework. We'll prove that and distributes over or in much the same way that multiplication distributes over addition in ordinary arithmetic. In partiulcar, P ∧ (Q ∨ R) → P ∧ Q ∨ P ∧ R.
Be sure to take time to see not only what's being stated, but why it's true. If you have a proof of a disjunction, you can do a case analysis and then reason about each case separately.
example : P × (Q ⊕ R) → (P × Q ⊕ P × R)
| ⟨ p, Sum.inl q ⟩ => Sum.inl ⟨ p, q ⟩
| ⟨ p, Sum.inr r ⟩ => Sum.inr ⟨ p, r ⟩
Homework
Write and prove the following propositions from the identities file in the propositional logic chapter. Use the space below. If you ever get to the point where you're sure there's no possible proof, just say so and explain why. Use ×, ⊕, and ~ as notations for logical and, or, and not when translating these propositions into our current embedding of predicate logic in Lean (just as we did in the preceding example).
- P ∧ (Q ∧ R) → (P ∧ Q) ∧ R -- and is associative
- P ∨ (Q ∨ R) → (P ∨ Q) ∨ R -- or is associative
- ¬(P ∧ Q) → ¬P ∨ ¬Q
- ¬(P ∨ Q) → ¬P ∧ ¬Q
- ¬(P ∧ N)
- (P ∨ N)
-- Your answers here
Extra credit:
Not all of the axioms that are valid in propositional logic are valid in our embedding of constructive logic into Lean. One that's not is negation elimination: that is, ¬¬P → P. Try to prove it in the stype we've used here here and explain exactly where things go wrong (in a comment). -/
Propositions as Logical Types
- Types in Prop Replace Types in Type
- (False : Prop) Replaces (Empty : Type)
- (True : Prop) replaces (Unit : Type)
- Proofs Are Now Values of "Reasoning" Types
- And and Or Connectives Are Polymorphic Types (in Prop)
- Implications Are Represented as Function Types
- Negations Are Represented as Function Types to False
- Summing Up
- Homework
In the previous chapter, we saw that we could represent propositions as computational types, and proofs of them as various programs and data structures. Reasoning is thus reduced to programming!
However, there are some problems with the approach so far:
- it doesn't distinguish logical from computational types
- it enables one to distinguish between proofs of propositions
What we would like, then, is to have a slightly different sort of type, differing from the usual data types in these two ways:
- connectives can only accept types representing propositions
- the choice of a proof to show validity is entirely irrelevant
- and of course we'd like to use the usual logical notations
To this end, Lean and similar logics define a new sort of type, called Prop, dedicated to the representation of propositions, and having these additional properties.
In this chapter, we run through exactly the same examples as in the last, but now using Prop instead of Type as the type of propositions.
Types in Prop Replace Types in Type
We can represent elementary propositions, and their truth or falsing, by defining types that either do or do not have any values. Here we define three ropositions, P, Q, R, each of which has a proof term, and one proposition, N, that has no constructors and thus no proofs, and which we would thus judge to be false.
inductive P : Prop where | mk
inductive Q : Prop where | mk
inductive R : Prop where | mk
inductive N : Prop where
(False : Prop) Replaces (Empty : Type)
In Lean, False is represented as an uninhabited type in Prop. Be sure to visit the definition of False to see that it's just like Empty except that it's a "reasoning" type rather than a computational type (such as Bool → Empty).
#check Empty
#check False
-- inductive False : Prop
Introduction
As there can be no proof of False, there is no introduction rule for it. In Lean that means it has no constructors. The only way to derive a proof of False is if one is in a context in which one has already made conflicting assumptions. In the following example, you can see that from the inconsistent assumption, 1 = 0, we can derive a proof of False.
example : 0 = 1 → False :=
fun h =>
let (f : False) := nomatch h
-- this example continues below
Now having derived a proof of false, from our inconsistent assumption, we can use the axiom of false elimination (False.elim in Lean) to succeed≥ The underlying reasoning is that *we are in a a situation that can never actually occur -- you can never have a proof of 1 = 0 to pass as an argument -- so we can just ignore reasoning any further in this situation and declare success.
-- Here then is the last line of the proof
False.elim f
(True : Prop) replaces (Unit : Type)
True in Lean is a proposition, a logical reasoning type, analogous to the computational type, Unit, but in Prop.
#check Unit
#check True
Introduction
There's always a constant (unconditional) proof of True, as the constructor, True.intro. There's always a proof of True.
Elimination
There's nothing useful one can do with a proof of True other than to signal that a computation has completed. There's thus no really useful elimination rule for true. Exercise: explain what the recursor/induction axiom says about it.
#check True.rec
Proofs Are Now Values of "Reasoning" Types
We continue to represent proofs as values of a given type, and we can use Lean to check that proofs are correct relative to the propositions they mean to prove. It's just type checking! We do have a new keyword available: theorem. It informs the reader explicitly that a value is intended as a proof of some proposition.
def p' : P := P.mk
example : Q := Q.mk
theorem p : P := P.mk
The same principles hold regard false propositions represented as types. They are logical types with no proofs. Therefore you can't prove them in Lean.
theorem r : N := _ -- No. There's no proof term for it!
And and Or Connectives Are Polymorphic Types (in Prop)
Lean 4 defines separate logical connectives just for types in Prop.
Replace (P × Q) with (P ∧ Q)
Here as a reminder is Lean's definition of the polymorphic pair type in Lean 4, followed by its definition of And.
#check And
namespace hide
structure Prod (α : Type u) (β : Type v) where
mk ::
fst : α
snd : β
structure And (a b : Prop) : Prop where
intro ::
left : a
right : b
end hide
We now make the following replacements:
- replace × with ∧
- replace Prof.mk with And.intro
- replace Prod.fst and Prod.snd with And.left and And.right
#check P
#check @And
abbrev PAndQ : Prop := P ∧ Q -- Representing the proposition, P ∧ Q
theorem pandq : P ∧ Q := And.intro P.mk Q.mk -- Representing proof!
example : P ∧ Q := ⟨ P.mk, Q.mk ⟩ -- Notation for Prod.mk
#check pandq.left
#check pandq.right
All of the usual theorems then go through as before. Here we're actually seeing the form of a proof of an implication in type theory: and it's a function from proof of premise to proof of conclusion.
def andCommutative : P ∧ Q → Q ∧ P
| And.intro p q => And.intro q p
def andCommutative' : P ∧ Q → Q ∧ P
| ⟨ p, q ⟩ => ⟨ q, p ⟩
def andCommutative'' : P ∧ Q → Q ∧ P := λ ⟨ p, q ⟩ => ⟨ q, p ⟩
Replace P ⊕ Q (Sum Type) with P ∨ Q
As we represented the conjunction of propositions as a product type, we will represent a disjunction as what is called a sum type. Whereas a product type has but one constructor with multiple arguments, a sum types has two constructors each taking one argument. A value of a product type holds one of these and one of those, while a sum type holds one of these or one of those. We thus used the polymnorphic Prod type to represent conjunctions, and now we do the same, using the polymorphic Sum type to represent disjunctions and their proofs.
#check Sum
#check Or
inductive Sum (α : Type u) (β : Type v) where | inl (val : α) : Sum α β | inr (val : β) : Sum α β
inductive Or (a b : Prop) : Prop where | inl (h : a) : Or a b | inr (h : b) : Or a b
def porq := P ∨ Q
-- Proof of *or* by proof of left side
def porq1 : Or P Q := Or.inl P.mk
-- Proof by proof of right side, with notation
def porq2 : P ∨ Q := Or.inr Q.mk
All the theorems from before also go through just fine.
example : P ∨ Q → Q ∨ P
| Or.inl p => Or.inr p
| Or.inr q => Or.inl q
Implications Are Represented as Function Types
Implications continue to be represented by function types.
To prove an implication, we simply exhibit a function of the specified type. Such functions might not exist, and in the case that would show the implication to be false. On the other hand, any function definition of the given type will do to prove an implication.
Introduction
Here for example we prove the implication stating that if both P and Q are true then P is. The reasoning is just as before: assume we're given a proof of P ∧ Q, and from it, derive a proof of P. That shows that it P ∧ Q is true then so much be P.
def pandQImpP : P ∧ Q → P := fun (h : P ∧ Q) => h.left
example (A : Prop) : False → A := fun f => nomatch f
Elimination
To use a proof of an implication, we just apply it, as a function, to an argument of the right type. It will ll then reduce to term that will serve as a proof of the conclusion of the implication.
In other words, the way to use a proof of an implication is to apply it. Function application is the elimination rule for proofs of implications. Such proofs are in the form of functions. Here we show that if we assume we have a proof of P ∧ Q → P and we have a proof of P ∧ Q then we can derive a proof of P by applying the former to the latter.
example (P Q : Prop) : (P ∧ Q → P) → (P ∧ Q) → P :=
λ pq2p pq => (pq2p pq)
-- Test yourself: what are the types of pq2p and pq?
Negations Are Represented as Function Types to False
Negation is the most complex of the connectives that we have seen so far. First, we represent a propostion, ¬P as the function type, P → False, where False is Lean's standard empty (uninhabited) reasoning type.
Introduction
To prove a negation, such as the proposition, ¬P, we assume P, show that that leads to a contradiction, in the sense that it's shown that there can be no proof of P. That proves P → False. The negation introduction axiom then let's us conclude ¬P.
-- You can prove a falsehood
def oneNeZero : ¬(1 = 0) := fun (h : 1 = 0) =>
let f : False := nomatch h
False.elim f
-- It's not true that P is false, as defined above
example : P → False
| P.mk => _ -- can't return value of Empty type!
-- But *N* is empty so this definition works
def notr : N → False := fun r => nomatch r
Elimination
A proof of a negation is a special case of a proof of an implication. Both proofs are in the form of functions. The way we use a proof of a negation, as with a proof of any implication, is by applying it. This is generally done in the context of conflicting assumptions that let us derive a proof of false. In Lean one then generally uses False.elim
def noContra (A B : Prop) : A → ¬A → B :=
λ (a : A) (na : ¬A) => (na a).elim
Summing Up
In class exercise. Take this example from last time and fix it to use Prop.
example : P ∧ (Q ∨ R) → (P ∧ Q ∨ P ∧ R)
| ⟨ p, Or.inl q ⟩ => Or.inl ⟨ p, q ⟩
| ⟨ p, Or.inr r ⟩ => Or.inr ⟨ p, r ⟩
-- you write the second missing case
- ∧
- ∨
- ¬
- →
- ↔
#check Iff
structure Iff (a b : Prop) : Prop where intro :: mp : a → b mpr : b → a
-- our example is set up so that we have proofs of P and Q to return
example : P ↔ Q := Iff.intro (fun _ : P => Q.mk) (fun _ : Q => P.mk)
Universal quantifier
def allPQ : ∀ (_ : P), Q := fun (_ : P) => Q.mk
-- P → Q
-- Wait, what?
-- Hover over #reduce.
#reduce ∀ (p : P), Q
-- (∀ (p : P), Q) literall *is* P → Q
So that's our first taste of the two quantifiers of a predicate logic: for all (∀) and there exists (∃). What we've seen here is a special case of the more general form of a universally quantified proposition.
To see the general form of quantified propositions, we now need to meet predicates: as a concept, and as that concept is embedded (very naturally) in Lean. That takes us into the next chapter, on predicates.
Homework
Write and prove the following propositions from the identities file in the propositional logic chapter. Use the space below. If you ever get to the point where you're sure there's no possible proof, just say so and explain why. Use the standard logical notations now, instead of the notations for Prod and Sum. That is, just use the standard logical notations in which the propositions are written here.
- P ∧ (Q ∧ R) → (P ∧ Q) ∧ R -- and associative (1 way)
- P ∨ (Q ∨ R) → (P ∨ Q) ∨ R -- or associative (1 way)
- ¬(P ∧ Q) → ¬P ∨ ¬Q
- ¬(P ∨ Q) → ¬P ∧ ¬Q
- ¬(P ∧ N)
- (P ∨ N)
-- Your answers here
Proof by Negation and by Contradiction
- Introduction
- Proof by Negation
- Proof by Contradiction
- Negation Elimination is Not an Axiom in Lean
- Classical Reasoning
Introduction
We will now look further into reasoning about negations. There are two related but fundamentally distinct axioms, (often called proof strategies in traditional courses) at play. With P standing for any proposition, the first proof strategy applies the axiom of negation introduction (P → False) → ¬P), while the second applies the axiom of negation elimination: ¬¬P → P.
Proof by Negation
In propositional logic we semantically validated the proposition that (P → ⊥) → ¬P and called it negation introduction. That is what logicians would call the way of reasoning. It's common (though not widely used) name is proof by negation.
Let's translate the propositional logic version into English to get a lock on what it's saying. Reading from let to right it says that if assuming P is true implies False (leads to a contradiction), then it one can validly conclude ¬P. The so-called strategy of proof by negation is just the use of this rule. If you're goal is to show ¬P then this is the way you do it: by showing that if you assume that P is true you can then show that False is true, which it isn't so P isn't, and ¬P is a valid conclusion.
As an example, just consider our earlier proof that, say, 1 is not even. To prove it we assumed that 1 is even, as shown by an assumed proof, h, and then we showed by nomatch that there are no such proofs, which is to say that 1 is even demonstrably cannot be proved, and so is demonstrably false, We conclude ¬Ev 1.
Proof by Contradiction
Proof by contradiction, on the other hand, corresponds to the elimination rule for negation. Refer back to the propositional logic axioms file. This is the axiom that says that two nots cancel out: ¬¬P → P. An example of this form of reasoning is, if it's not not raining, then it is raining.
To understand proof by contradiction you just have to unpack this proposition. In particular, unfold the outer ¬ sign. ¬ de-sugars to not P and that is defined as P → False, so unfolding the outer instance of ¬, reducing ¬(¬P) to (¬P → False). The negation elimination is thus equivalent to (¬P → False) → P. In other words, this "axiom" asserts that to prove a proposition P (not the negation of P), it suffices to show that assuming that P is false (¬P) leads to a contradiction.
As an example, consider the proposition, *the sum of any even natural number, n, and any odd natural number, m, is odd. Proof. We will assume that n + m is not odd (that it is even); will then show that this assumption leads to an impossibility, leading to the conclusion (by proof by negation!) that it's not the case that a + b is not odd. Finally, and here's the clincher, we'll using negation eliminiation to infer from that that a + b is odd. If we didn't have negation elimination it's exactly that last reasoning step that is blocked.
So suppose n + m is not odd. Then it's even. As it's even we can write n + m as 2 * k for some integer k. In the same vein, as m is odd, we can write it as 2 * j + 1 for some j. Their sum, m + n is thus *2*k + 2*j + 1. This in turn is 2 * (k + j) + 1. That's odd, which is directly at odds with our assumption that m + n is not odd. By the rule of negation introduction (proof by negation) we conclude that m + n is not odd is false. That is m + n is not not odd (the double negation is not a typo). Then by the axiom of negation elimination (double negation cancellation) we finally conclude that m + n really must be odd after all.
Negation Elimination is Not an Axiom in Lean
We use negation introduction extensively when working in Lean: it's just how you prove negations of propositions. Perhaps shockingly, though, negation elimination is not an axiom in Lean. In other words, it is not, be default, a way that you can reason in the constructive logic of Lean. The is that from a given/assumed proof of ¬¬P there's no way to derive a proof of P. Having a proof that ¬P is false, i.e., of ((P → False) → False) is not enough to get a proof of P. Here's how you get stuck if you try to prove that it is a valid reasoning principle.
example (P : Prop) : (¬¬P) → P :=
-- assume ¬¬P, defed as ((P → False) → False)
fun nnp =>
-- now try to show (return a proof of) P
_ -- stuck!
-- you can't prove P from ((P → False) → False)
It's usually counterintuitive at first that knowing ¬P to be false is not enough to conclude that P is true, but that's exactly the case in constructive logic.
Classical Reasoning
On the other hand, if you want to reason classically, to includ proof by contradiction, with negation elimination as an axiom, then you just have to tell Lean that that is an axiom. The axiom doesn't contradict Lean's logic, so it can be added to or left out at will. It's non-constructive and so is left out by default.
Adding Negation Elimination as an Axiom to Lean
Here's it looks like to add it as an axiom to Lean. The axiom has a name and a proposition that is henceforth taken to be valid without proof. We hide the defunition inside a namespace so as not to pollute our reasoning environment with an axiom we might or might not want to use.
namespace myClassical
axiom negElim : ∀ (P : Prop), ¬¬P → P
That's it. In English this says, "Assume as an axiom, without further proof, that for any proposition, P, if you have a proof of ¬¬P you can derive a proof of P.
As a trivial but still illustrative example, we can prove the proposition, True is valid, not by a direct proof (True.intro), by rather by contradition: applying our new axiom of negation elimination to a proof that we will construct of ¬¬True.
example : True :=
negElim -- eliminate double negation obtained by
True -- by proving the negation of True to be false (¬¬True)
(fun (h : ¬True) => (nomatch h)) -- which is what happens here
-- Note that *P* in our definition is an *argument* to *negElim*
The Equivalent Axiom of the Excluded Middle
Another way to enable classical reasoning in Lean is to add a different non-constructive axiom, namely the law of the excluded middle. What does it say? That there are only two possibilities for any proposition: it's either valid or it's not valid, and so if you know that one of these states does not hold, then you know for sure that the other one must. In other words, for any proposition, P, you can have a proof of P ∨ ¬P for free, without presenting either a proof of P or a proof of ¬P. One can then reason from this proof of a disjunction by case analysis, where you can assume a there's a proof of P in the first case and there is a proof of ¬P in the second, and only other, case.
axiom em : ∀ (P : Prop), P ∨ ¬P
From this axiom we can in fact derive negation elimination.
example :
(∀ (P : Prop), (P ∨ ¬P)) →
(∀ (P : Prop), ¬¬P → P) :=
fun hEm =>
(
fun P =>
(
fun nnp =>
(
-- use em to get a proof of P ∨ ¬P
let pornp := hEm P
-- then use case analysis
match pornp with
| Or.inl p => p
| Or.inr np => False.elim (nnp np)
)
)
)
fun hEm =>
fun P =>
fun nnp =>
match (em P) with
| Or.inl _ => by assumption -- a lean "tactic!"
| Or.inr _ => by contradiction -- a lean "tactic!"
In fact it's an equivalence, in that you can prove that negation elimination implies excluded middle, as well. You can thus add either one as an axiom, derive the other and then use either classical reasoning principle as you wish. That proof is left as an exercise for the curious reader.
Moreover, each of these axioms is in fact derivable from of even more fundamental axioms, such as the so-called axiom of choice, which holds from any non-empty set of objects you can always choose an element, even if there is no constructive way to specify how that will be done, and so from any infinite collection of sets you can always construct a new set with one element from each of the given sets. For more information, you might see the Wikipedia article on this topic.
Demorgan's Law Example: ¬(P ∧ Q) → (¬P ∨ ¬Q)
We've already gotten stuck trying to prove that for any propositions, P and Q, ¬(P ∧ Q) → ¬P ∨ ¬Q. We can however prove that this formula is valid in classical logic by using the axiom of the excluded middle. It is available in Lean 4 in the Classical namespace as Classical.em. We'll thus now switch to using Lean's standard statement of this axiom.
end myClassical
#check Classical.em
-- Classical.em (p : Prop) : p ∨ ¬p
The axiom of the excluded middle takes any proposition, P, for which we might have a proof of P, or of ¬P, or neither, and it eliminates that third, middle, possibility. It forces any proposition to have a Boolean truth value. That in turn returns us to our earliest form of reasoning about the validity of a proposition over a some finite number of combinations of the Boolean truth values of its constituent elementary propositions. Here we will show that DeMorgan's Law for negation over conjunction is true under each of the four possible combinations of Boolean truth values for P and Q.
example (P Q : Prop) : ¬(P ∧ Q) → ¬P ∨ ¬ Q :=
fun (h : ¬(P ∧ Q)) =>
At this point we're constructively stuck, as h is just a proof of a function to False and there's no way to derive a proof of either ¬P or ¬Q from that.
But we have the juice proposition, P, to work with, and from that, by excluded middle, we can have free proof of P ∨ ¬P, at which point we only have two cases to consider. Wd do that by case analysis as when reasoning from the truth of any disjunction.
let pornp : P ∨ ¬P := Classical.em P
-- TRICK! Do case analysis on *pornp*
match pornp with
-- Case P is true (and we have a proof of it)
| Or.inl p => -- we now do case analysis on Q ∨ ¬Q
match (Classical.em Q) with
-- case P is valid and so is Q
| Or.inl q =>
let f : False := h (And.intro p q)
False.elim f
-- impossible case: finish it!
-- case ¬P is true, so ¬P ∨ ¬Q is
| Or.inr nq => Or.inr nq
-- Case P is false, in which case ¬P ∨ ¬Q is, too
| Or.inr np => Or.inl np
Predicates
In this chapter and from now on we'll be working with Lean's standard embedding of predicate logic, with propositions encoded as types of the Prop (rather than Type) sort. But let's start with the even more basic question, what is a predicate?
A predicate in a predicate logic is a proposition parameterized in a way that lets it speak about different objects: those that can be filled in for these placeholders.
If KisFromCville and CisFromCVille are both propositions, for example, represented in Lean by types of these names in Prop, with analogous proof terms, then we can factor out person as subject to variation, as a parameter. The proposition, in Prop, becomes a function, of type Person → Prop, that when applied to a particular person yields the original proposition about that person.
Example: Being From Charlottesville
Our example postulates a few different people, two of whom are from Charlottesville (CVille) and one of whom is not.
Propositions as Types
Here are types in Prop representing two propositions, each coming with the same two constant proof/constructor terms. Informally, someone is proved to be from CVille if they have a birth certificate or drivers license that says so.
inductive KevinIsFromCville : Prop where
| birthCert
| driversLicense
inductive CarterIsFromCville : Prop where
| birthCert
| driversLicense
Domain of Application
To reduce repetition, we can abstract the variation in these two results to a variable-valued formal parameter, here of a type we will now call Person. Our Person type defines just three people (Carter, Kevin, and Tammy).
inductive Person : Type where | Carter | Kevin | Tammy
open Person
Generalization
Now we define IsFromCville as a predicate on people (on terms of type Person), represented as an inductive family of propositions, one for each person, with the specified ways to prove such propositions. The proof constructors are the introduction rules for constructing proofs of any given proposition of this kind. Given any Person, p, birthCert p will typecheck as proof that p isFromCville, and so will driversLicense p.
-- Generalization: proposition that <p> is from CVille
inductive IsFromCville : Person → Prop where
| birthCert (p : Person) : IsFromCville p
| driversLicense (p : Person) : IsFromCville p
open IsFromCville
Specialization
Whereas abstraction replaces concerete elements with placeholders, specialization fills them in with particulars. Given a predicate, we apply it to an actual parameter to fill in missing information for that argument. We apply a universal, over all people, to any particular person, to specialize the predicate to that argument.
#check IsFromCville Kevin -- specialization to particular proposition
#check IsFromCville Carter -- pronounce as "Carter is from Cville"
#check IsFromCville
Proofs
We can now see how to "prove" propositions obtained by applying predicates to arguments. You apply IsFromCville a Person, it gives you back a proposition. In addition, as an inductive type, it gives a set of constructors for proving such propositions. The following code defines pfKevin and pfCarter as proofs of our propositions.
def pfKevin : IsFromCville Kevin := birthCert Kevin
def pfCarter : IsFromCville Carter := driversLicense Carter
Summary
So there! We've now represented a predicate in Lean, not as a type, per se, but as a function that takes a Person as an argument, yields a proposition/type, and provies general constructors "introduction rules" for contructing proofs of these propositions.
The Property of a Natural Number of Being Even
As another example, we define a very different flavor of predicate, one that applies not to people but the numbers, and that indicates not where one is from but whether one is even or not. This is an indictive definition, in Prop, of the recursive notion of evenness. It's starts with 0 being even as a given (constant constructor), and the includes an indictive constructor that takes any number, n, and proof that it is even and wraps it into a term that type checks as a proof that n+2 is even. Note that term term accepted as a proof that n+2 is even has embedded within it a proof that n is even. These recursives structures always bottom out after some finite number of steps with the proof that 0 is even. Note that we have Ev taking numbers to propositions in Prop.
inductive Ev : Nat → Prop where
| evZero : Ev 0
| evPlus2 : (n : Nat) → Ev n → Ev (n + 2)
open Ev
Constructing Proofs of Evenness (Introduction)
And here (next) are some proofs of evenness:
- 0 is even
- 2 is even
- 4 is even
- 6 is even (fully expanded)
def pfZeroEv : Ev 0 := evZero
def pfTwoEv : Ev 2 := evPlus2 0 pfZeroEv
def pfFourEv : Ev 4 := evPlus2 2 pfTwoEv
-- hint: find the base proof then read this inside out
def pfSixEv : Ev 6 :=
evPlus2
(4)
(evPlus2 -- proof that 4 is even
(2)
(evPlus2 -- proof that 2 is even
(0)
evZero -- constant proof that 0 is even
)
)
Using Proofs of Evenness
We should expect to be able to use proofs of evenness in reasoning. For example, if we have a proof that 6 is even, then we should be able to obtain from it a proof that, say, 4 is even, we we have just seen that sitting inside a proof term showing that 6 is even is a proof term showing that 4 is even. The trick will be to pattern match on the proof that 6 is even to get at the proofs nested inside it.
example : Ev 6 → Ev 4
| (Ev.evPlus2 4 h4) => h4
EXERCISE: Show that if 6 is even so is 2 using this method. Hint: Pattern match deeper into the proof of Ev 6.
Proofs of Negations of Evenness
Why can't we build a proof that 5 is even? Well, to do that, we'd need a proof that 3 is even, and for that, a proof that 1 is even. But we have no to construct such a proof. In fact, we can even prove that an odd number (we might as well just start with 1) is not even in the sense defined by the Ev predicate.
example : ¬Ev 1 :=
Recall: ¬Ev 1 is defined to be (Ev 1 → False). To prove ¬Ev 1 we need a function of this type. Applied to a proof Ev 1, it return a proof a False. Read it out loud: if Ev 1 then False, with the emphasis on if. But Ev 1 is not true, it's false, so the entire implication is true as explained by the fact that it's true for all proofs of Ev 1 (of which there are none).A total function from an uninhabited type to any other type is trivial. Here, it's:
fun (h : Ev 1) => nomatch h
example : ¬Ev 1 := fun (h : Ev 1) => nomatch h
example : ¬Ev 3 := fun (h : Ev 3) => nomatch h
example : ¬Ev 5 := fun (h : Ev 5) => nomatch h
TEMPORARILY UNDER CONSTRUCTION
Suppose:
- α is any type (such as Nat or A ∨ B)
- Pr : α → Prop is predicate expressing a property of α values (e.g., evennness)
universe u
axiom α : Sort u -- α can be in Prop (Sort 0), Type (Sort 1), or Type 1, 2, ...
axiom Pr : α → Prop
Then in predicate logic we also have two forms of quantifier expressions
- ∀ (a : α), Pr a
- ∃ (a : α), Pr a
#check ∀ (a : α), Pr a -- this is a proposition
#check ∃ (a : α), Pr a -- this is a proposition
Universal Quantifier: ∀ (a : α), Pr a
Here we have the form of a "universally quantified" proposition. It can be pronounced and is to be understood as asserting that "every (a : α) satisfies Pr", or "Pr is true of every (a : α)", or "every (a : α) has property Pr", or "for any a there's a proof of (rP a)", or just "for all a, Pr a".
Introduction
To prove that every (a : α) has property Pr it will suffice to show that there'ss a way to turn any such (a : α) into a proof of Pr a. In constructive logic, this is a job for a function. If α is any type (including Prop), a proof of ∀ (a : α), Pr a, is a function, pf : (a : α) → Pr a. That is the type of a function that takes any argument of type α and that returns proofs the corresponding propositions Pr a, one proposition/type for each a. That's it! It's the same as for any implication.
def pf : ∀ (a : α), Pr a := (fun (a : α) => sorry)
Now to use a proof of an implication, apply it, as a function to any value (b : α) to get a proof of Pr b.
axiom b : α
#check pf b
We can call constructing a proof of (∀ a, Pr a) a universal generalization. We can call the application of a generliation to a value to get a value-specific proof universal specialization.
Existential Quantifier: ∃ (a : α), P a
Now we meet the form of an "existentially quantified" proposition. It can be read as saying "there exists an a with property P", or "some a satisfies the predicate P."
Introduction
To prove it, apply Exists.intro to some value (a : α) and to a proof (pf : P a).
def aPf : ∃ (x : α), Pr x := Exists.intro b sorry -- you also need a proof of P b
Elimination
The rule for using a proof of existence is a little strange. If you have one, you cannot ask for the value of the thing that exists, all you can get is that is one and you can give it a name and along with you, finally, you get a proof that that named thing satisfies the predicate. Those are the new ingredients you can use in subsequent steps of reasoning.
Here's an example. We give most of a proof of a trivial proposition that let's us assume a proof of an existentially qualitified proposition so that we see how to use one. In this example we could of course just ignore the proof argument and just return True.intro. The point, rather,. is to see how to use Exists.elim and what it does. The key point here is to see, by hovering over the remaining hole, that applying the elim rule has given you two new context elements to work with: (1) some value, (a : α), and a proof that that value, a, satisfies the predicate, Pr. In a more interesting proof, you would then use these elements to help build the proof you seek (here of True.intro).
example : (∃ (x : α), Pr x) → True :=
fun h =>
Exists.elim
h
(
fun a =>
fun pra => _
)
A few examples
example : ∃ (n : Nat), n = 0 := Exists.intro 0 rfl
example : ∀ (n : Nat), n + 1 ≠ 0
| _ => (fun c => nomatch c)
example : ∃ (n : Nat), n ≠ 0 :=
Exists.intro 1 (fun c => nomatch c)
axiom Ball : Type
axiom Red : Ball → Prop
axiom Hard : Ball → Prop
#check Exists.elim
Exists.elim.{u} {α : Sort u} {p : α → Prop} {b : Prop} (h₁ : ∃ x, p x) (h₂ : ∀ (a : α), p a → b) : b
example : (∃ (b : Ball), Red b ∧ Hard b) → (∃ (b : Ball), Red b) := by
intro h
apply Exists.elim h _
intro b
intro rh
apply Exists.intro b rh.left
Quantifiers: Universal Generalization (∀)
Quantifiers are part of the syntax of predicate logic. They allow one to assert that every object (∀) of some type has some property, or that there exists (∃) (there is) at least one (some) object of a given type with a specified property. The syntax of such propositions is as follows:
- ∀ (x : T), P x
- ∃ (x : T), P x
The first proposition can be read as asserting that every value x of type T satisfies predicate P. Universal quantification is a generalized form of a logical and operation: it is used to assert that the first value of a type has some property, and so does the second, and so does the third, through all of them.
In this chapter we address universal generalizations (∀ propositions). We cover existential quantification in the next chapter.
Introduction Rule (How to Prove ∀ (x : T), P)
In predicate logic, the way to prove ∀ (a : A), P a is to (1) assume that you've got an arbitrary value, a : A, and then in that context, (2) give a proof of P a. The reasoning is that if any arbitrary value a satisfies P then every value of a must do so.
In Lean 4, a proof of a universal generalization has exactly the same sense, and is presented as a function: one that takes an arbitrary value, (a : A), and that, in that context constructs and returns a proof of P a. The existence of such a function (which must be total in Lean) shows that from any a one can construct a proof of P a. That shows that every a satisfies P.
Example
Here's a trivial example. We assert that every natural number, n, satisfies the proposition, True. This is of course true, but let's see a proof of it.
example : ∀ (n : ℕ), True :=
fun n => -- assume an arbitrary n
True.intro -- show that that n satisfies True
So we see that the logical proposition, ∀ (n : Nat), True, is equivalent to the function type, Nat → True. Given any natural number, n, such a function returns a proof of (a value of type) True.
#check ∀ (n : Nat), True -- Literally Nat → True!
Here's yet another example: we define the natural number squaring function, declaring its type using ∀ rather than →. When we #check it's type, Lean reports it as Nat → Nat, using its default notation, →, for this type.
def square : ∀ (n : Nat), Nat := fun n => n^2
#check (square) -- Nat → Nat
#reduce square 5 -- 25
Here's a logical example proving *∀ (f : False), False.
def fimpf : ∀ (f : False), False := fun f => f
#check (fimpf) -- a value/proof of type False → False
Discussion
#check Nat → False
example : Nat → False := fun n => _ -- stuck
example : ¬(Nat → False) :=
fun (h : Nat → False) =>
h 0
Elimination Rule
Suppose you have a proof of ∀ (a : A), P a. How do you use it? The answer is that you apply it to a particular value, *a : A), to get a proof of P a. The reasoning is that if every value of type A satisfies P, then for any particular value, (a : A), we should be able to obtain a proof of P a.
variable
(Person : Type) -- there are people
(Mortal : Person → Prop) -- property of being mortal
(Socrates : Person) -- socrates is a person
(AllAreMortal : ∀ (p : Person), Mortal p) -- all people mortal
-- We can now have a proof that socrates in particular is mortal
#check AllAreMortal Socrates
That brings us to the conclusion of this section on universal generalization and specialization. Key things to remember are as follows:
- Universal generalizations are function types
- Introduction: define a function of this type
- Elimination: Apply such a function to a particular
Quantifiers: Existential Quantification (∃)
We now turn to the second of the two quantifiers in predicate logic: the existential operator, ∃. It is used to write propositions of the form, ∃ (x : T), P x. This proposition is read as asserting that there is some (at least one) value of type, T, that satisfies P. As an example, we repeat our definition of the is_even predicate, and then write a proposition asserts that there is (there exists) some even natural number.
-- Predicate: defines property of *being even*
def isEven' : Nat → Prop := λ n => (n % 2 = 0)
inductive isEven : Nat → Prop where
| ev0 : isEven 0
| ev2 : ∀ (n : Nat), isEven n → isEven (n + 2)
open isEven
-- λ means the same thing as fun: a function that ...
-- Proposition: there exists an even number
#check ∃ (n : Nat), isEven n
Introduction
In the constructive logic of Lean, a proof of a proposition, ∃ (x : T), P x, has two parts. It's a kind of ordered pair. What's most interesting is that type of value in the second element depends on the value of th element in the first position.
The first elementis a specific value, w : T (a value, w, of type T). What's new is the idea that the type of the second element, (P w), the proposition obtained by applying P to w, depends on w. The return type, here, depends on the value, w, of the argument. It must be a proof of P w, which in turn is read as stating that w has property P (e.g., of being even, equal to zero, prime, odd, a perfect square, a beautiful number).
So there you have the exists introduction rule. Apply Exists.intro to a witness (of the right type), and a proof that particular witness does have that property, as demonstrated by a formally checked proof of it. -/
#check Exists.intro
```lean
Exists.intro.{u}
{α : Sort u} -- Given any type α
{p : α → Prop} -- Given any predicate, p, on α
(w : α) -- Provide a witness, a, of type α
(h : p w) : -- And a proof of the proposition (p a)
Exists p
example : ∃ (n : Nat), isEven n :=
Exists.intro 0 ev0
example : ∃ (n : Nat), isEven n :=
Exists.intro
4
(
ev2
2
(
ev2
0
ev0
)
)
/-
In type theory, proofs of existence are *dependent pairs*,
of the form, *⟨a : α, h : p a⟩. Note carefully that the type
of the second element, namely a proof of *(p a)*, depends on
the value of the first element, *a*.
### Example: There is some even number
Here's a simple example showing that there exists an even
number, with *4* as a witness.
```lean
example : exists (n : Nat), isEven' n := Exists.intro 4 rfl
The witness is 4 and the proof (computed by rfl) is a proof of 4 % 2 = 0, which is to say, of 0 = 0. Try 5 instead of 4 to see what happens.
Lean provides ⟨ _, _ ⟩ as a notation for Exists.intro.
example : ∃ (n : Nat), isEven n := ⟨ 4, sorry ⟩
We will study the equality relation shortly. For now, know that rfl produces a proof that a value equals itself, and that's exactly what we need to construct the second element of this pair.
English language rendering: We are to prove that some natural number is even. To do so we need to choose a number (will will cleverly pick 4) and then also give a proof that 4 is even, which we formalizes as the proposition resulting from the application of isEven (a predicate taking a Nat) to 4.
Example: There is some Blue Dog
Another example: Suppose we have a proof that Iris is a blue dog. Can we prove that there exists a blue dog?
namespace bluedog
variable
(Dog : Type) -- There are dogs
(Iris : Dog) -- Iris is one
(Blue : Dog → Prop) -- The property of being blue
(iris_is_blue : Blue Iris) -- Proof that Iris is blue
-- A proof that there exists a blue dog
example : ∃ (d : Dog), Blue d := Exists.intro Iris iris_is_blue
example : ∃ (d : Dog), Blue d := ⟨ Iris, iris_is_blue ⟩
end bluedog
Elimination
Now suppose you have a proof of a proposition, ∃ (x : α), P x. That is, suppose you have pf : ∃ (x : α), P x. How can you use such a proof to derive a proof of some other proposition, let's call it b. The goal is to understand when ∃ x, P x → b.
Here's the key idea: if you know that ∃ (x : T), P x, then you can deduce two facts: (1) there is some object, call it (w : T), for which, (2) there is a proof, pw, that w satisfies P (a proof of P w); and then, if in addition to having such a witness, we know that if all objects of type α satisfy P implies b, then b must be true, by application of ∀ elimination to w. The elimination rule gives us these objects to work with.
#check Exists.elim
Exists.elim.{u}
{α : Sort u} -- Given any type, α
{p : α → Prop} -- Given any predicate on α
{b : Prop} -- Given a proposition to prove
(h₁ : ∃ x, p x) -- If there's an x satisfying p
(h₂ : ∀ (a : α), p a → b) : -- If every a satisfies p implies b
b -- then b
Example
Here's an example. We want to show that if we have a proof, pf, that there's a natural number, n, that satsifies True and isEven, then there's a natural number, f, that satisfies just isEven.
def ex1 :
-- Prove:
(∃ (n : Nat), True ∧ isEven n) → (∃ (f : Nat), isEven f) :=
-- Proof: by "arrow/function introduction" (from premise, prove conclusion)
-- assume some proof h, of (∃ (n : Nat), True ∧ isEven n)) and thern ...
fun (h: (∃ (n : Nat), True ∧ isEven n)) =>
-- show (∃ (f : Nat), isEven f).
-- The proof is by exists elimination ...
-- ... essential here because it gives us a witness to use in proving the conclusion
Exists.elim
-- applied to h, a proof (∃ (n : Nat), True ∧ isEven n) ...
h
-- a proof that
(
-- from any natural number, a, and ...
fun (a : Nat) =>
(
-- a proof that a satisfies (True ∧ (Even n))
fun tea =>
-- there is a proof of (∃ (f : Nat), isEven f).
-- the proof is by the rule of exists introduction ...
Exists.intro
-- using the "abstracted" witness obtained by elimination of the premise ...
a
-- and a proof of (isEven a), obtained by right and elimination applied to
-- obtained from the proof of (True ∧ isEven a) by the rule of right and elimination
(tea.right)
)
)
def ex1' :
(∃ (n : Nat), True ∧ isEven n) →
(∃ (f : Nat), isEven f)
| ⟨ w, pf_w ⟩ => Exists.intro w pf_w.right
If There's Someone Everyone Loves then Everyone Loves Someone
Formalize and prove the proposition that if there's someone everyone loves, then everyone loves someone.
An informal, English language proof is a good way to start.
Proof. Assume there exists someone, let's call them Beau, whom every person, p, loves. What we need to show is that everyone loves someone. To prove this generaliation, we'll assume that p is an arbitrary person and will show that there is someone p loves. But everyone loves beau so, by universal specialization, p loves Beau. Because p is arbitrary, this shows (by forall introduction) that every person loves someone (namely beau).
namespace cs2120f23
variable
(Person : Type)
(Loves : Person → Person → Prop)
example :
(∃ (beau : Person), ∀ (p : Person), Loves p beau) →
(∀ (p : Person), ∃ (q : Person), Loves p q)
-- call the person everyone loves beau
-- call the proof everyone loves beau everyone_loves_beau
| ⟨ beau, everyone_loves_beau ⟩ =>
-- prove everyone loves someone by ∀ introduction
-- assume you're given an arbitrary person, p
fun (p : Person) =>
-- then show that there exists someone p loves
-- with beau as a witness
-- and a proof p loves beau (by universal specialization)
⟨beau, (everyone_loves_beau p)⟩
end cs2120f23
Here's the same logical story presented in a more abstract form, using T instead of Person and R : T → T → Prop to represent the binary relation (previously Loves) on objects of type T.
variable
(T : Type)
(R : T → T → Prop)
-- Here
example : (∃ (p : T), (∀ (t : T), R t p)) →
(∀ (p : T), (∃ (t : T), R p t))
| ⟨ w, pf_w ⟩ => (fun (p : T) => ⟨ w, pf_w p ⟩)
In mathematical English: Given a binary relation, R, on objects of type T, if there's some p such that forall t, R t p (every t is related to p by R), then for every p there is some t such that R p t (every p is related to some t). In particular, every p is related to w, the person everyone loves. So everyone loves someone.
An Aside on Constructive Logic
The term constructive here means that to prove that something with a particular property exists, you have to actually have such an object (along with a proof). Mathematicians generally do not require constructive proofs. In other words, mathematicians are often happy to show that something must exist even if they can't construct an actual example.
We call proofs of this kind non-constructive. We saw a similar issue arise with proofs of disjunctions. In particular, we saw that a constructive proof of a disjunction, X ∨ ¬X, requires either a proof of X or a proof of ¬X. Accepting the law of the excluded middle as an axiom permits non-constructive reasoning by accepting that X ∨ ¬X is true without the need to construct a proof of either case.
What one gains by accepting non-constructive reasoning is the ability to prove more theorems. For example, we can prove all four of DeMorgan's laws if we accept the law of the excluded middle, but only three of them if not.
So what does a non-constructive proof of existence look like? Here's a good example. Suppose you have an infinite sequence of non-empty sets, *{ s₀, s₁, ...}. Does there exist a set containing one element from each of the sets?
It might seem obvious that there is such a set; and in many cases, such a set can be constructed. For example, suppose we have an infinite sequence of sets of natural numbers (e.g., { {1, 2}, {3, 4, 5}, ... }). The key fact here is that every such set has a smallest value. We can use this fact to define a choice function that, when given any such set, returns its smallest value. We can then use this choice function to define a set containing one element from each of the sets, namely the smallest one.
There is no such choice function for sets of real numbers, however. Certainly not every such set has a smallest value: just consider the set {1, 1/2, 1/4, 1/8, ...}. It does not contain a smallest number, because no matter what non-zero number you pick (say 1/8) you can always divide it by 2 to get an even smaller one. Given such a set there's no choice function that can reliably returns a value from each set.
As it turns out, whether you accept that there exists a set of elements one from each of an infinity of sets, or not, is your decision. If you want to operate assuming that there is such a set, then you accept what mathematicians call the axiom of choice. It's another axiom you can add to the constructive logic of Lean without causing any kind of contradictions to arise.
The axiom of choice is clearly non-constructive: it gives you proofs of the existence of such sets for free. Most working mathematicians today freely accept the axiom of choice, and so they accept non-constructive reasoning.
Is there a downside to such non-constructive reasoning? Constructive mathematicians argue yes, that it leads to the ability to prove highly counter-intuitive results. One of these is called the Banach-Tarski paradox: a proof (using the axiom of choice) that there is a way cut up and reassemble a sphere that doubles its volume! (Wikipedia article here.)[https://en.wikipedia.org/wiki/Banach%E2%80%93Tarski_paradox]
As with excluded middle, you can easily add the axiom of choice to your Lean environment to enable classical (non-constructive) reasoning in Lean. We will not look further into this possibility in this class.
import Mathlib.Util.Delaborators
This section should be left as an option for interested students in DMT1. It can be included in an introductory graduate course or late undergraduate elective.
Dependent Types
Lean implements the logic of dependent type theory. The key idea in dependent type theory is that the type of one term can depend on the value of another type.
Example and Discussion
A good example of a dependent type in Lean 4 is called Vector. It's type is Vector.{u} (α : Type u) (n : Nat) : Type u. For any element type, α, and any natural number, n, Vector α n is the type of arrays (of α values) of length n. The type depends on the value of n. *** -/
#check Vector String 2 -- A type #check Vector String 5 -- A different type
It's important to distinguish between merely polymorphic
types, on one hand, and dependent types, on the other. In
Lean, *Array α* is a polymorphic type (builder) but it is
not a dependent type. Parametrically polymorphic (generic)
types depend on other *types* but not on *values* of other
types.
#check Array String -- a type #check Array Nat -- a different type #check Array Bool -- yet another type
Dependent types are different. They give rise to a distinct type
for each *value* of some other type. In the following examples,
we fix *α = String* but let the length vary, giving rise to a new
type for each possible natural number *value* of *n*.
#check Vector String 2 -- a type #check Vector String 4 -- a different type #check Vector Nat 4 -- yet another type
Dependent types vastly increase the expressiveness of a logic
such as that of Lean 4, enabling complex specifications to be
represented as types. Among other things, they are at the heart
of Lean's formalization of the *∀* and *∃* quantifiers in Lean,
which correspond directly to what we call *dependent function
types* and *dependent pair types*, as we now explain.
/- ***
Dependently Typed Function Types
To see that you've already been using dependent types, just consider an example of a universally quantified proposition. Consider the proposition, every person is mortal.
Every Person is Mortal
To start, we introduce the variable keyword in Lean. We can use it to declare variables in one place that can then be used in multiple subsequent definitions with no need for explicit declarations in each individual case.
variable
(Person : Type)
(Mortal : Person → Prop)
With that, we can now formalize the proposition that every person is mortal.
#check ∀ (p : Person), Mortal p
We've seen that a proof term for such a proposition will be a function definition of this type. Here's a proof skeleton that makes the point clear again.
example : ∀ (p : Person), Mortal p :=
fun (p : Person) => -- assume p is an arbitrary person
_ -- show that this person is mortal
Such a function takes any person, p, as an argument, and (if the proof were complete) returns a proof of Mortal p. The key point is that this return type, Mortal p, depends on the value, p.
The core logic of Lean 4 supports the definition of dependent function types. Dependent function types enable one to express universal generalizations (∀ propositions) in Lean 4. As we see in the example, if A is any type and P : A → Prop is any predicate on A, then ∀ (a : A), P a is the dependent type of a function that takes any value a : A as an argument and that returns a proof (a value of type) P a as a result.
Ordinary (non-dependent) function types in Lean are just special cases of dependent function types where the return type isn't defined in term of the argument value at all. The following types are thus equivalent: ∀ (n : Nat), Nat, and Nat → Nat. The latter notation is preferred for non-dependent function types. See how Lean reports each type. Lean even warns that the argument values are unused, which is to say the return type (Nat) doesn't depend on the argument value.
#check ∀ (n : Nat), Nat
#check Nat → Nat
Dependent Pairs (Sigma/Σ-Types)
Sigma types (Σ types) represent dependent pair types: a type of pair where the type of the second component in a pair depends on the value of the first. Among other things, a proof of an existentially quantified propositions in Lean is a value of a dependent pair type. The first element of such a pair is a natural number, n, and the second is a proof that that particular n is even. The type of the second element, Ev n, thus depends on the value of the first element of the pair.
-- Here's a simple definition of our evennness predicate
def Ev (n : Nat) : Prop := n % 2 = 0
example : ∃ n, Ev n := ⟨ 0, rfl ⟩
As another example, the Vector α n type in Lean is a type of dependent pair values, where the first element of such a pair is a value of type, Vector α, and the second element is a proof that that particular Vector value has length n.
#check Vector
The Vector type incorporates an Array element by inheritance and then adds the second element as a new field.
structure Vector (α : Type u) (n : Nat) extends Array α where size_toArray : toArray.size = n
As a final example, suppose we want to have a type of lists of α values of some specific length, say 5. We can define such a type as what in Lean is called a subtype, and it will be a type of dependent pairs, where the first element is a list and the second element is a proof that that list has length 5. Here's the notation.
def List5 (α : Type) : Type := { l : List α // l.length = 5}
example : List5 Nat := ⟨ [1,2,3,4,5], rfl ⟩
It's easy to check that if a list isn't of length 5, it cannot be used to construct a value of the List5 type, because there will be no way to construct the required second element of such a dependent pair.
example : List5 Nat := ⟨ [1,2,3,4], rfl ⟩ -- Error: length != 5
Sets Relations and Properties Thereof
import Mathlib.Data.Set.Basic
namespace DMT1.Lectures.setsRelationsFunctions.sets
Theory of Sets
A set is intuitively understood as a collection of objects. Such a collection can be finite or infinite. Infinite sets can vary in the degrees of their infinite sizes. For example, the set of natural numbers less than five is finite, the set of all natural numbers is countably infinite, and the set of real numbers is said to be uncountably infinite.
Like Boolean algebra or arithmetic, set theory is algebraic in that it has both objects (sets) and operations involving them. Your aim in this chapter is to understand the following:
- (1) the language of sets, their properties, and operations
- (2) how sets can be represented as one-argument predicates
- (3) understand the logical specifications of set operations
- (3) understand how to prove propositions about sets
The first section of this chapter introduces sets and how they can be specified, and are represented in constructive logic, by logical predicates. The second section specifies set operations as in entirely logical terms. The good news is that the notions of sets, properties of sets, and set operations all reduce to logical operations, which you already understand!
Sets
In what's sometimes called naive set theory, one thinks of a set as a collection of objects. Some objects are "in" the set. In type theory, we consider only sets of objects of one specific type. So we can talk about a set of natural numbers or a set of people, but we won't have sets that contain numbers and people.
Two examples of sets of natural numbers would be the set of all even numbers, and the set of all natural numbers less than five. An example of a set of people would be the set of those people who are taking this class for credit this semester.
In each of these examples, we define a set by first saying what type of objects it contains, and then by stating the specific property of any such value that determines whether it's in the set or not. For example, rather than the set of even numbers we can say the set of natural numbers, n, such that n is even. Everything before the second comma introduces the type of object in the set (natural numbers) and give a name to some arbitrary value of that type. The rest, such that n is even, defines the property of any given n that determines whether it is to be considered in, or not in, the set. Here the condition is that n is even.
Specification and Representation
In Lean, a set, a, is represented by a membership predicate: one that takes a single argument, let's call it a, of some type, α, and that reduces to a proposition about that a (such that a is even), where such a value, a, is defined to be in the set if it satisfies the predicate--that is, if there's a proof of the proposition, *(s a)*m; otherwise a is defined not to be in the set.
For example, we've seen several definitions of evenness as a predicate on natural numbers. A small turn of the imagination lets us now talk about the set of even numbers, call it evNum, as comprising all of those numbers, n, for which there is a proof of (evNum n). The predicate defines the set in question. And in Lean we represent a Set as its membership predicate.
It's important to note that we are now operating entirely in the realm of logical specifications, not implementations. Sets in Java are data structures that contain the values considered to be in a set. Sets are definitely not represented as data structures in Lean. Among other things we'd not be able to represent sets, such as the natural numbers, with infinite numbers of elements. Rather sets are defined by logical specifications, in Lean in the form of predicates, define in just the right way so that all the elements you want in a set satisfy the predicate and no other values do.
You can see the actual definition of Set in Lean by going to its definition. Right click on Set and select go to definition.
#reduce Set
-- fun α => α → Prop
-- a polymorphic type
-- specializations
#reduce Set Nat
#reduce Set Bool
#reduce Set Prop
In this class we distinguish two uses of the same predicate when defining a set in Lean. First, a one-place predicate can be understoood to specify a set: the set of all and only the objects that can be proven to satisfy it.
Exercises:
- What predicate would specify the set of all natural numbers?
- What predicate would specify the empty set of natural numbers?
- Define a predicate that would specify the set of even numbers.
Second, Lean also uses a predicate to represent any given set, but this fact should be understood as an inessential design decision that is abstracted away by the Set API in Lean.
#check Set
-- def Set (α : Type u) := α → Prop
Quoting from mathlib: A set is a collection of elements of some
type α
. Although Set
is defined as α → Prop
, this is an
implementation detail which should not be relied on. Instead,
setOf
and membership of a set (∈
) should be used to convert
between sets and predicates.
Example: Can't directly treat Prop as Set
def aNatProp : Nat → Prop := λ n => True
-- #check 1 ∈ aNatProp -- won't work
def s : Set Nat := setOf aNatProp -- can coerce prop to set
#check 1 ∈ s -- gain set language and notations
#check (s 1) -- this "works" but is unpreferred
def t : Nat → Prop := s -- can treat set as prop
Good to know Lean details.
- Define a set, s, by applying setOf to a predicate: α → Prop
- Beyond α → Prop, being a set brings operations and notations
- Check membership of object a in set s using a ∈ s, not (s a)
The real advantage, in Lean, of representing sets as predicates is that it confers the ability to strip set theory abstractions to their underlying logical representations, at which point one can then use all the machinery of predicate logic, now well understood, to reason about propositions in set theory. If one likes to think in proof strategy terms, this one could be called proof "by the definition of," though proof "by the underlying representation of" is probably a better term.
Set Notations
In the language of set theory, there are two especially common notations for represeting sets. They are display and (set) comprehension notation.
Display Notation
To represent a finite set of objects in mathematical writing, you can give a comma-separated list of members between curly braces. The set of small numbers (0 to 4) can be represented in this way as { 0, 1, 2, 3, 4 }. Sometimes we will want to give a set a name, as in, let s = { 0, 1, 2, 3, 4 }, or let s be the set, { 0, 1, 2, 3, 4 }.
Lean supports display notation as a set theory notation. One is still just definining a membership predicate, but it looks like the math you'll see in innumerable books and articles. Moreover, when you look at such notations from now on, even if you've seen them before, you can think about how they can be seen as expressions of membership predicates.
The corresponding predicate in this case, computed by Lean, is fun b => b = 0 ∨ b ∈ {1, 2, 3, 4}. In the following example, Lean doesn't infer that the set type is Set Nat, so we have to tell it so explicitly.
def s1 : Set Nat := { 0, 1, 2, 3, 4 } -- repped as ...
#reduce s1 -- fun b => b = 0 ∨ b ∈ {1, 2, 3, 4}
Comprehension Notation
Sets can also be specified using what is called set comprehension notation. Here's an example using it to specify the same small set.
def s2 : Set Nat := { n : Nat | n = 0 ∨ n = 1 ∨ n = 2 ∨ n = 3 ∨ n = 4 }
-- The empty set (of natural numbers)
def noNat' : Set Nat := { n : Nat | False}
def noNat : Set Nat := ∅ -- set theory notation!
--
def allNat' : Set Nat := { n : Nat | True}
def allNat : Set Nat := Set.univ -- Univeral set (of all Nats)
-- Uncomment the next line to see the error
-- example : 3 ∈ noNat := (_ : False) -- unprovable, stuck
example : 3 ∉ noNat := fun h => nomatch h -- proof by negation
We pronounce the expression (to the right of the := of course) as *the set of values, n, of type Nat, such that n = 0 ∨ n = 1 ∨ n = 2 ∨ n = 3 ∨ n = 4. The curly braces indicate that we're defining a set. The n : Nat specifies the set of set members. The vertical bar is read such that, or satisfying the constraint that. And the membership predicate is then written out.
You can check that this set, s2, has the same membership predicate as s1.
#reduce s2
Example: Assume there's a type of objects call Ball and a predicate, Striped, on balls. Use set comprehension notation to specify the set of striped balls. Answer: { b : Ball | Striped b }. Read this expression in English as the set of all balls, b, such that b is striped, or more concisely and naturally simply as the set of all striped balls.
axiom Ball : Type
axiom Striped : Ball → Prop
def sb : Set Ball := { b : Ball | Striped b}
-- Question: Can we define sets of sets? Yes! Example
def ssb : Set (Set Ball) := { sb } -- a set of sets
Homogenous vs Heterogeneous Sets
The preceding example involved a set of natural numbers. In Lean, such a set, being defined by a predicate on the natural numbers, cannot contain elements that are not of the natural number type. Sets in Lean are thus said to be homogeneous. All elements are of the same type. This makes sense, as sets are represented in Lean by predicates that take arguments of fixed types.
A heterogeneous set, by contrast, can have members of different types. Python supports heterogeneous sets. You can have a set containing a number, a string, and a person. The track in Python is that all objects actually have the same static type, which is Object. In the end, even in Python, sets are homogeneous in this sense.
In Lean, and in ordinary mathematics as well, sets are most often assumed to be homogenous. In mathematical communication, one will often hear such phrases as, Let T denote the set of natural numbers less than 5. Notice that the element type is made clear.
In support of all of this, Set, in Lean, is a type builder polymorphic in the element type. The type of a set of natural numbers is Set Nat, for example, while the type of a set of strings is Set String.
The following example shows that, in Lean, the even and small predicates we've already defined can be assigned to variables of type Set Nat. It type-checks! Sets truly are specified by and equated with their membership predicates in Lean.
def ev := λ n : Nat => n % 2 = 0
def small := λ n : Nat => n = 0 ∨ n = 1 ∨ n = 2 ∨ n = 3 ∨ n = 4
def ev_set' : Set Nat := ev -- ev is a predicate
def small_set' : Set Nat := small -- small is too
In mathematics, per se, sets are not equated with logical predices. Rather, to represent sets (to implement them, as it were) as predicates in Lean is just a very nice and convenient way to go. So, really, there are two things on your plate in this chapter: (1) understand the language of set theory and how set operations are defined logically, and (2) understand how representing sets as membership would be to use either display or set comprehension notation. Here are stylistically improved definitions of our sets of even and small natural numbers. We will use these definitions in running examples in the rest of this chapter.
def ev_set : Set Nat := { n : Nat | ev n }
def small_set : Set Nat := { n | small n }
#reduce (types := true) small_set 4
example : 4 ∈ small_set :=
-- 4 = 0 ∨ 4 = 1 ∨ 4 = 2 ∨ 4 = 3 ∨ 4 = 4
Or.inr
(
(Or.inr
(
Or.inr
(
Or.inr
(
rfl
)
)
)
)
)
example : ∃ (n : Nat), n ∈ small_set :=
Exists.intro 0 (Or.inl rfl)
The take-away is that, no matter one's choice of notation, sets are truly represented in Lean by logical predicates. The great news is that you already understand the logic so learning set theory is largely reduced to learning the set algebraic concepts (the objects and operations of set theory) and in particular how each concept reduces to underlying logic.
Universal and Empty Sets
With membership notation under our belts, we can now better present the concepts and notations of the universal and the empty set of elements of a given type.
Universal set
The universal set of a values of a given type is the set of all values of that type. The membership predicate for the universal set is thus true for every element of the set. True is the (degenerate, parameterless) predicate that satisfies this condition. It is true for any value, so every value is in a set with True as its membership predicate.
To be precise, the membership predicate for the universal set of objects of any type T, is λ (a : T) => True. When it is applied to any value, t, of type T, the result is just the proposition, True, for which we always have the proof, True.intro.
In Lean, the universal set of objects of a given type is written as univ. The definition of univ is in Lean's Set namespace, so you can use univ either by first opening the Set namespace, or by writing Set.univ.
open Set
#reduce univ -- fun _a => True
#reduce univ 0 -- True
#reduce univ 123456 -- True
Empty set
The empty set of values of a given type, usually denoted as ∅, is the set containing no values of that (or any) type. It's membership predicate is thus false for every value of the type. No value is a member. Formally, the membership predicate for an empty set of values of type T is λ (t : T) => False.
Again we emphasize that set theory in Lean is built on and corresponds directly with the logic you've been learning all along. We've now seen that (1) sets are specified by membership predicates; (2) the universal set is specified by the predicate that is true for any value; (3) the empty set is specified by the predicate that is false for any value; (4) the ∈ operation builds the proposition that a given value satisfies the membership predicate of a given set; (5) proving propositions in set theory reduces to proving corresponding underlying logical propositions.
At an abstract level, Set theory, like arithmetic, is a mathematical system involving objects and operations on these objects. In arithmetic, the objects are numbers and the operations are addition, multiplication, etc. In Boolean algebra, the objects are true and false and operations include and, or, and not. In set theory, the objects are sets and the operations include set membership (∈), intersection (∩), union (∪), difference (), complement (ᶜ) and more. We now turn to operations on sets beyond mere membership.
Operations on Sets
Specifying sets, from set theory, as predicates in propositional logic, paves the way to:
- (1) specifying operations on sets as definitions in predicate logic,
- (2) proving propositions in set theory by proving the propositions to which they desugar.
To acquire the skill of proving propositions in set theory you must learn how each operation is formally defined in predicate logic, and then be able to prove the corresponding the logical propositions.
To that end, here's a table that summarizes the correspondence between operations in set theory, on one hand, and their specifications in the language of predicate logic (as implemented in Lean), on the other.
Name | Notation | Specification | Logical Specification |
---|---|---|---|
Set | set α | axioms of set theory | (α → Prop) |
membership | x ∈ a | a satisfies predicate | (a x) |
intersection | s ∩ t | { a | a ∈ s ∧ a ∈ t } | fun a => (s a) ∧ (t a) |
union | s ∪ t | { a | a ∈ s ∨ a ∈ t } | fun a => (s a) ∨ (t a) |
complement | sᶜ | { a | a ∉ s } | fun a => ¬(s a) |
difference | s \ t | { a | a ∈ s ∧ a ∉ t } | fun a => (s a) ∧ ¬(t a) ) |
subset | s ⊆ t | ∀ a, a ∈ s → a ∈ t | fun a => (s a) → (t a) |
proper subset | s ⊊ t | s ⊆ t ∧ ∃ w, w ∈ t ∧ w ∉ s | ... ∧ ∃ w, (t w) ∧ ¬(s w) |
product set | s × t | { (a,b) | a ∈ s ∧ b ∈ t } |
powerset | 𝒫 s | { t | t ⊆ s } |
#reduce Set
#reduce Set.Mem
#reduce Set.inter
#reduce Set.union
#reduce Set.compl
#reduce Set.diff
#reduce @Set.Subset
#reduce Set.prod
#reduce Set.powerset
Exercise: What precisely are the elements of the powerset of the product set of two finite sets, S and T?
Exercise: How many elements are in the powerset of the product set of two finite sets, S and T, of sizes s and t, respectively?
Let's elaborate on each of these concepts now.
Membership Predicates
Let's start by building on our understanding of predicates. Here are two predicates on natural numbers. The first is true of even numbers. The second is true of any number that is small, where that is defined as the number being equal to 0, or being equal to 1 or, ..., or being equal to 4. The first predicate can be understood as specifying the set of even numbers; the second predicate, a set of small numbers.
Self test: What proposition is specified by the expression, small 1? You should be able to answer this question without seeing the following answer.
Answer: Plug in a 1 for each n in the definition of small to get the answer. There are 5 places where the substitution has to be made. Lean can tell you the answer. Study it until you see that this predicate is true of all and only the numbers from 0 to 4 (inclusive).
#reduce (types := true) (small 1)
The result is 1 = 0 ∨ 1 = 1 ∨ 1 = 2 ∨ 1 = 3 ∨ 1 = 4. This proposition is true, of course, because 1 = 1. So 1 is proved to be a member of the set that the predicate specifies. Similarly applying the predicate to 3 or 4 will yield true propositions; but that doesn't work for 5, so 5 is not in the set that this predicate specifies.
To formally prove that 1 is in the set, you prove the underlying logical proposition, 1 = 0 ∨ 1 = 1 ∨ 1 = 2 ∨ 1 = 3 ∨ 1 = 4. A proof of set membership thus reduces to a proof of an ordinary logical proposition, in this case a disjunction. Again an insight to be taken from this chapter is that set theory in Lean reduces to correspondinglogic you already understand and know how to deal with.
As a reminder, let's prove 1 = 0 ∨ 1 = 1 ∨ 1 = 2 ∨ 1 = 3 ∨ 1 = 4.
First, recall that ∨ is is right associative, so what we need to prove is (1 = 0) ∨ (1 = 1 ∨ 1 = 2 ∨ 1 = 3 ∨ 1 = 4). It takes just a little analysis to see that there is no proof of the left side, 1 = 0, but there is a proof of the right side. The right side is true because 1 = 1. Our proof is thus by or introduction on the right applied to a proof of the right side, which we can now slightly rewrite as (1 = 1) ∨ (1 = 2 ∨ 1 = 3 ∨ 1 = 4).
Be sure to see that using right introduction discards the left side of the original proposition and requires only a proof of the right. A proof of it, in turn, is by or introduction on the left applied to a proof of 1 = 1. That proof is by the reflexive property of equality (it's always true that anything equals itself). This idea is expressed in Lean using rfl.
Exercise: Give a formal proof that 1 satisfies the small predicate. We advise you to use top-down, type-guided structured proof development to complete this simple proof. We give you the or introduction on the right to start.
example : small 1 := (Or.inr (Or.inl rfl))
example : small 3 := Or.inr (Or.inr (Or.inr (Or.inl (Eq.refl 3))))
Membership Again
TODO: Combine with preceding section
We've already seen that we can think of a predicate as defining a set, and that a value is a member of a set if and only if it satisfies the membership predicate.
That said, set theory comes with its own abstractions and notations. For example, we usually think of a set as a collection of objects, even when the set is specified by a logical membership predicate. Similarly set theory gives us notation for special sets and all of the operations of set theory.
As an example, the proposition that 1 is a member of small_set would be written as small_set 1 if we're thinking logically; but in set theory we'd write this as 1 ∈ small_set. We would pronounce this proposition as 1 is a member of small_set.
From now on you should try to interpret such an expression in two ways. At the abstract level of set theory, it asserts that 1 is a member of the collection of elements making up small_set. At a concrete, logical, level, it means that small_set 1, the logical proposition that 1 satisfies the small_set predicate, is true, and that you can construct a proof of that.
The very same proof proves 1 ∈ small_set. All these notations mean the same thing, but set theory notation encourages us to think more abstractly: in terms of sets (collections), not predicates, per se.
Nevertheless, to construct proofs in set theory in Lean, you must understand how the objects and operations in set theory are defined in terms of, and reduce, to propositions in pure logic. What you will have to prove are the underlying logical propositions.
Here, for example, we state a proposition using set theory notation, but the proof is of the underlying or proposition.
#check 1 ∈ small_set -- membership proposition in set theory
#reduce 1 ∈ small_set -- this proposition in predicate logic
example : 1 ∈ small_set := Or.inr (Or.inl rfl) -- a proof of it
The lesson is that when you look at an expression in set theory you really must understand its underlying logical meaning, for it's the underlying logical proposition that you'll need to prove.
So we're now in a position to see the formal definition of the membership operation on sets in Lean. In the Lean libraries, it is def Mem (a : α) (s : Set α) : Prop := s a, where α is a type. The notation ∈ reduces to corresponding logic. More conretely, the set theory proposition a ∈ s reduces to applying the set, s, viewed as a membership predicate, to the argument, a (thus the expression, s a) to yield a proposition, (s a), that is true if and only if a is in s.
Exercises.
(1) We expect that by now you can construct a proof of a disjunction with several disjunctions. But practice is still great and necessary. Try erasing the given answer and re-creating it on your own. By erase we mean to replace the answer with _. Then use top-down, type-guided refinement to derive a complete proof in place of the _.
#reduce 3 ∈ small_set
example : 3 ∈ small_set := Or.inr (Or.inr (Or.inr (Or.inl rfl)))
A take-away is that the set theory expression, x ∈ X, simply means, that x satisfies the membership predicate that defines the set X. To prove x ∈ X, substitute x for the formal parameter in the membership predicate (apply the predicate to x) and prove the resulting proposition.
Intersection
Given a type, T, and two sets, s1 and s2 of T-valued elements (members), the intersection of s1 and s2 is the set the members of which are those values that are in both s1 and s2. The intersection of s1 and s2 is written mathematically as s1 ∩ s2.
The intersection operation is defined in Lean as inter (s₁ s₂ : Set α) : Set α := {a | a ∈ s₁ ∧ a ∈ s₂}. Given two sets of alpha values, the result is the set of values, a, that satisfy both conditions: a ∈ s₁ ∧ a ∈ s₂. Set intersection (∩) is defined by predicate conjunction (∧).
Intersection of sets corresponds to logical conjunction (using and) of the respective set membership predicates. The similarity in notations reflects this fact, with ∩ in the language of set theory reducing to ∧ in the language of predicate logic. The following Lean codeillustrate the point.
#reduce Set.inter
-- fun s₁ s₂ a => s₁ a ∧ s₂ a
variable (α : Type) (s t : Set α)
#check s ∩ t -- the intersection of sets is a set
#reduce s ∩ t -- its membership predicate is formed using ∧
As another example, the intersection of our even (ev) and small sets, corresponding to the conjunction of their membership predicates, contains only the elements 0, 2, and 4, as these are the only values that satisfy both the ev and small predicates.
def even_and_small_set := ev_set ∩ small_set -- intersection!
#reduce (0 ∈ even_and_small_set) -- membership proposition
As an example, let's prove 6 ∈ even_and_small_set. We'll first look at the logical proposition corresponding to the proposition in set theory assertion, then we'll try to prove tha underlying logical proposition.
#reduce 6 ∈ even_and_small_set
-- to prove: 0 = 0 ∧ (6 = 0 ∨ 6 = 1 ∨ 6 = 2 ∨ 6 = 3 ∨ 6 = 4)
example: 6 ∈ even_and_small_set := sorry
The proposition to be proved is a conjunction. A proof of it will have to use And.intro applied to proofs of the left and right conjuncts. The notation for this is ⟨ _, _ ⟩, where the holes are filled in with the respective proofs. We can make a first step a top-down, type-guided proof by just applying this proof constructor, leaving the proofs to be filled in later. The Lean type system will tell us exactly what propositions then remain to be proved.
example: 6 ∈ even_and_small_set := ⟨ sorry, sorry ⟩
On the left, we need a proof of 6 ∈ ev_set. This can also be written as ev_set 6, treating the set as a predicate. This expression then reduces to 6 % 2 = 0, and further to 0 = 0. That's what we need a proof of on the left, and rfl will construct it for us.
example: 6 ∈ even_and_small_set := ⟨ rfl, sorry ⟩
Finally, on the right we need a proof of 6 ∈ small_set. But ah ha! That's not true. We can't construct a proof of it, and so we're stuck, with no way to finish our proof. Why? The proposition is false!
Exercise: Prove that 6 ∉ small_set. Here you have to recall that 6 ∉ small_set means ¬(6 ∈ small_set), and that in turn means that a proof (6 ∈ small_set) leads to a contradiction and so cannot exist. That is, that 6 ∈ small_set → False.
This is again a proof by negation. We'll assume that we have a proof of the hypothesis of the implication (h : 6 ∈ even_and_small_set), and from that we will derive a proof of False (by case analysis on a proof of an impossibility using nomatch) and we'll be done.
example : 6 ∉ even_and_small_set :=
fun (h : 6 ∈ even_and_small_set) => nomatch h
#check Set
Union
Given two sets, s and t, the union of the sets, denoted as s ∪ t, is understood as the collection of values that are in s or in t. The membership predicate of s ∪ t is thus *union (s₁ s₂ : Set α) : Set α := {a | a ∈ s₁ ∨ a ∈ s₂}. As an example, we now define even_or_small_set as the union of the even_set and small_set.
#reduce @Set.union
-- fun {α} s₁ s₂ a => s₁ a ∨ s₂ a
def even_or_small_set := ev_set ∪ small_set
Now suppose we want to prove that 3 ∈ even_or_small_set. What we have to do is prove the underlying logical proposition. We can confirm what logical proposition we need to prove using reduce.
#reduce 3 ∈ even_or_small_set
Exercises. Give proofs as indicated. Remember to analyze the set theoretic notations to determine the logical form of the underlying membership proposition that you have to prove is satisfied by a given value.
example : 3 ∈ even_or_small_set := Or.inr sorry
example : 6 ∈ even_or_small_set := sorry
example : 7 ∉ ev_set ∪ small_set := sorry
-- uncomment the following line to see error
-- example : 7 ∈ ev_set := _ -- stuck
example : 7 ∉ ev_set := λ h => sorry
Complement
Given a set s of elements of type α, the complement of s, denoted sᶜ, is the set of all elements of type α that are not in s. Thus compl (s : Set α) : Set α := {a | a ∉ s}.
So whereas intersection reduces to the conjunction of membership predicates, and union reduces to the disjunction of membership predicates, the complement operation reduces to the negation of membership predicates.
#reduce sᶜ -- fun x => x ∈ s → False means fun x => x ∉ s
-- fun x => x ∈ s → False
variable (s : Set Nat)
#check sᶜ -- Standard notation for complement of set s
Exercises:
(1) State and prove the proposition that 5 ∈ smallᶜ. Hint: You have to prove the corresponding negation: ¬(5 ∈ small_set).
example : 5 ∈ small_setᶜ := sorry
Difference
#reduce Set.diff
-- fun s t a => s a ∧ (a ∈ t → False)
-- fun s t a => a ∈ s ∧ a ∉ t (better abstracted expression of same idea)
example : 6 ∈ ev_set \ small_set := ⟨ rfl, λ h => nomatch h ⟩
#reduce 6 ∈ ev_set \ small_set
Subset
#reduce @Set.Subset
-- fun {α} s₁ s₂ => ∀ ⦃a : α⦄, a ∈ s₁ → s₂ a
Product
#reduce Set.prod
-- fun s t p => p.fst ∈ s ∧ p.snd ∈ t
Powerset
#reduce @Set.powerset
-- fun {α} s t => ∀ ⦃a : α⦄, a ∈ t → s a
Powerset of Product
This set is important because its elements, being subsets of the product set on sets s and t can be seen as representing the set of all binary relations on these two sets. An element of this set is isomorphic to and can be taken as specifying a binary relation from s to t.
When mathematicians want to assume that, r is some binary relation on sets, s and t, one can therefore write either r ⊆ s × t, or r ∈ 𝒫 (s × t.)
#reduce @Set.powerset (Set.prod _ _)
-- fun s t => t ⊆ s
end DMT1.Lectures.setsRelationsFunctions.sets
import Mathlib.Data.Rel
import Mathlib.Data.Set.Basic
import Mathlib.Logic.Relation
import Mathlib.Data.Real.Basic
namespace DMT1.Lectures.setsRelationsFunctions.relations
Binary Relations
- Specification and Representation as Predicates
- Rel α β: The Type of Binary Relations From α to β
- Example: The Relation { (0, 1), (1, 1), (1, 0) }
- Proving Membership and Non-Membership Propositions
- The Complete Binary Relation from α to β
- The Empty Binary Relation From α to β
- Relations Neither Empty or Complete
- Finite Relations
- Elements of a Binary Relation
- The Inverse of a Binary Relation on Types/Sets α and β
- The Image of a Set Under a Binary Relation
- The Preimage of a Set under a Relation
- A Most Fundamental Relation: Equality
- Example: The Unit Circle in the Cartesian Plane
- Tactics: Automating Steps in Proof Construction
- What's Next?
Just as a set can be viewed as a collection of individual objects of some type, α, specified by a membership predicate on α, so we will now view at a binary relation on α and β as a collection of ordered pairs of objects, ( a : α, b : β ). It is standard practice to specify y such relation with a two-argument predicate on α and β, satisfied by any pair of objects, a and b, that is defined to be in the relation, and otherwise not.
Specification and Representation as Predicates
You'll recall from the section on sets that we not only specify sets with membership predicates, but that Lean also represents sets as unary predicates. It thus defines the type, (Set α), as α → Prop. Not every predicate is meant to represent a set, so Lean provides the (Set α) definition so that our code expresses the intended abstraction when we mean for such a predicate to represent a set.
variable
{α : Type}
{β : Type}
-- Again, sets are represented as predicates in Lean
#reduce (types := true) (Set α) -- α → Prop
-- The type, Set α, is defined as the type α → Prop
-- (types := true) tells Lean to reduce types
Rel α β: The Type of Binary Relations From α to β
It is also commplace to specify binary relations as binary predicates. Moreover, just as Lean typically represents sets as unary predicates, it represents binary relations as binary predicates. To this end, Lean defines (Rel α β) as α → β → Prop, as the type of binary relation from objects of type α to objects of type β.
#reduce (types := true) Rel α β -- is α → β → Prop
Example: The Relation { (0, 1), (1, 1), (1, 0) }
Let's consider a simple example, of a finite binary relation on the natural numbers. In this example we will bypass Rel Nat Nat and use Nat → Nat → Prop to make it clear that all we're really dealing with are two-argument predicates.
Suppose we want to specify and represent the relation, let's call it tiny, with the following pairs: { (0,1,), (1,1), (1,0) }. We do this by defining the membership predicate satisfied by all and only these pairs.
def tinyMembershipPred : Nat → Nat → Prop :=
fun fst snd =>
fst = 0 ∧ snd = 1 ∨
fst = 1 ∧ snd = 1 ∨
fst = 1 ∧ snd = 0
When applied to a pair of Nat values, this membership predicate yields a proposition about the given pair of values that might or might not be true. If it's true, that proves the pair is in the relation. If it's false, that proves the pair is not in the relation.
Fr example, applying tiny it to the pair of values, such as 1 and 1, produces a a proposition, computed by substituing the actual parameters (1 and 1) for the formal parameters (fst, snd) in its definition. Here we ask Lean to do this reduction for us. To see the result, hover over #reduce or use Lean's InfoView panel.
#reduce (types := true) tinyMembershipPred 1 1
1 = 0 ∧ 1 = 1 ∨
1 = 1 ∧ 1 = 1 ∨
1 = 1 ∧ 1 = 0
example : tinyMembershipPred 1 1 := Or.inr (Or.inl (And.intro rfl rfl))
We can now define tiny as a binary relation on Nat, giving tinyMembershipPred as the specification of its membership predicate.
def tiny : Rel Nat Nat := tinyMembershipPred
Now we're working across two levels of abstraction: that of a binary relation, and that of the logical predicate that specifies and represents it.
The tiny relation still acts just like a predicate. We can apply it to a pair of arguments to produce a membership proposition, the truth of which determines whether the given pair of values is in relation or not. It should be obvious that the membership proposition is true for (tiny 1 1) and false for, e.g., (2, 2).
#reduce (types := true) tiny 2 2
-- 2 = 0 ∧ 2 = 1 ∨ 2 = 1 ∧ 2 = 1 ∨ 2 = 1 ∧ 2 = 0
Relation-definitng predicates can defined using all the various forms of logical expression: ∧, ∨, ¬, →, ↔, ∀, and ∃. Proving membership or not in an arbitrary binary relation thus requires facility with proving the full ranges of these types of proposition.
Proving Membership and Non-Membership Propositions
Now just as we proved set membership and non-membership propositions by reducing these propositions to logical propositions determined by the set membership predicate, so now we do the same for binary relations.
Suppose we want to prove that (informally speaking) that (0, 1) is in the tiny relation. We can start the proof by saying this: *By the definition of tiny, what we are to prove is 0 = 0 ∧ 1 = 1 ∨ 0 = 1 ∧ 1 = 1 ∨ 0 = 1 ∧ 1 = 0. The remainder of the proof is then just an exercise in logical proof construction, which you should now have fully mastered.
#reduce (types:=true) tiny 0 1
-- Prove (0, 1) is in tiny
example : tiny 0 1 :=
By the definition of tiny what is to be proved is 0 = 0 ∧ 1 = 1 ∨ 0 = 1 ∧ 1 = 1 ∨ 0 = 1 ∧ 1 = 0. The proof is by or introduction on the left with ...
Or.inl
-- ... a proof of fst = 0 ∧ snd = 1
(
-- this proposition is proved by "and" introduction
And.intro
-- with a proof of 0 = 0 (parens needed here)
(Eq.refl 0)
-- and a proof of 1 = 1 (parens needed)
(Eq.refl 1)
)
Proving that a pair is not in a relation is done in the usual way: by negation. That is, one assumes the pair is in the relation and then shows that that cannot actually be true, leading one to conclude that the negation of that proposition is true. For example, let's show that (0, 0) is not in the tiny relation.
#reduce (types:=true) tiny 0 0
-- 0 = 0 ∧ 0 = 1 ∨ 0 = 1 ∧ 0 = 1 ∨ 0 = 1 ∧ 0 = 0
example : ¬tiny 0 0 :=
-- The proof is by negation: we assume (0, 0) is in tiny ...
fun (h : tiny 0 0) =>
-- and show that this proof can't reallky exist
-- the proof is by case analysis on h (Or.elim)
match h with
-- case (0, 0) = (0, 1)? I.e., 0 = 0 ∧ 0 = 1? Nope.
-- by case analysis on a proof of 0 = 1 (no proofs)
| Or.inl h01 => nomatch h01.right
-- analyze the two remaining cases
| Or.inr h11orh10 =>
match h11orh10 with
-- case (0, 0) = (1, 1)? I.e., 0 = 1 ∧ 0 = 1? Nope.
| Or.inl h11 => nomatch h11.left
-- case (0, 0) = (1, 0), i.e., 0 = 1 ∧ 0 = 0? Nope.
| Or.inr h10 => nomatch h10.left
As there can be no proof of (tiny 0 0), i.e., of 0 = 0 ∧ 0 = 1 ∨ 0 = 1 ∧ 0 = 1 ∨ 0 = 1 ∧ 0 = 0, we conclude that (0, 0) is not in the tiny relation: ¬tiny 0 0. QED.
We now explore a range of particular relations to illustrate concepts, specifications, propositions, and proofs involving them. To avoid having to declare α and β as types and r as a binary relation on them, we do it once here using the Lean variable construct.
variable
(r : Rel α β)
(a : α)
(b : β)
#check r a b
The Complete Binary Relation from α to β
Given sets (in type theory, types) α and β, the complete relation from α to β relates every value of type α to every value of type β.
The complete relation from α to β is "isomorphic" to the product set/type, α × β, having every (a : α, b : β) pair as a member. The corresponding membership predicate takes two arguments, ignores them, and reduces to the membership proposition, True. As there's always a proof of True, the membership proposition is true for every pair of values, so every pair of values is defined to be in the relation. Here's a general definition of the complete relation on any two types, α and β.
def completeRel (α β : Type) : Rel α β := fun _ _ => True
This relation imposes no constraints on which String-Nat pairs are in the relation, so they all are. The predicate that defines this relation has to be true for all pairs of values. The solution is for the predicate to yield the proposition, True, for which there is always a proof, for any pair of String and Nat values. In paper and pencil set theoretic notation we could define fullStringNat as { (s,n ) | True }. In the type theory of Lean, specify the relation using by defining its membership predicate.
As an example, we define completeStrNat as the complete relation from String to Nat values. Be sure you see what completeRel String Nat reduces to. What predicate is it, exactly, as expressed formally in Lean. Do not proceed if you're not sure, rather go back and figure it out.
def completeStrNat : Rel String Nat := completeRel String Nat
We can now apply this membership predicate to pairs of argument values to yield membership propositions that claim that such pairs are in the specified relation. Here we claim that the pair, "Hello" and 2, is in this relation. You must fully understand what proposition completeStrNat "Hello" 2 reduces do. Go look at the definition of completeStrNat if you're sure!
example : completeStrNat "Hello" 2 := True.intro
Given this definition we can show that any pair of values is in the relation, or related to each other through or by it. As one example, let's show that the pair of values, "Hello" and 7, is in this relation. Hint. Start by remembering the definition of completeStrNat, then start natural language proof by saying, "By the definition of completeStrNat, what is to be proved is True." Then prove that membership proposition.
-- Prove ("Hello", 7) is in the complete relation on String and Nat
example : completeStrNat "Hello" 7 :=
-- By the definition of completeStrNat, all we have to prove True
-- The proof is by True introduction
True.intro
-- Prove that *every* Nat-Nat pair is in completeStrNat
example : ∀ (a : String) (b : Nat), completeStrNat a b :=
fun a b => True.intro
To know to "unfold" definitions into their underlying definitions is an incredibly important and useful move in in proof construction. I'd recommend that if you're ever stuck proving a proposition, just look to see if you can unfold any definitions to reveal more clearly what actually needs to be proved. Sometimes even Lean will get stuck computing proofs automatically until you tell it to unfold a definition in the midst of a proof construction.
The Empty Binary Relation From α to β
The empty relation from any type/set α to and type/set β is the relation that relates no values of α to any values of β. It's the relation that contains no pairs.
We specify the empty on α and β with a membership predicate that is false for any pair of values. It's a predicate that ignores its arguments and yields the membership proposition, False. No pair satisfies this membership proposition (makes it true), so the specified relation has no pairs at all.
def emptyRel {α β : Type*} : Rel α β := fun _ _ => False
The domain of definition of the empty relation is still the set of all values of type α, and the co-domain is still the set of all β values; but the domain and range are both empty, because the set of pairs of r is empty.
Let's now see some example. First we claim and show that no pair, (a : α, b : β) can be in an empty relation. We should be able to state and prove this proposition formally. Recall that we've already defined (a : α) and (b : β) as arbitrary values. The proof is by negation.
We assume(emptyRel a b) is true with a proof, h. But by the definition of emptyRel, the membership proposition, (emptyRel a b), is the proposition False. This makes h a proof of False, which is what we need to conclude our proof of ¬(emptyRel a b). QED.
example (a : α) (b : β) : ¬emptyRel a b :=
fun h => h
As a minor note, if we handn't already assumed that a and b are arbitrary objects, we could introduce them as such using ∀. You can read this proposition as something to the effect, no pair of objects, (a, b), can belong to and empty relation on any α and β.
example : ∀ {α β : Type*}, ∀ (a : α), ∀ (b : β), ¬emptyRel a b :=
Proof by ∀ introduction. We assume arbitrary and and b and then show that this pair cannot be in an empty relation. The rest of the proof is by negation. We assume (h : emptyRel a b) and produce a proof of False. By the definition of emptyRel, (h : emptyRel x y) is a proof of False. Therefore, we can conclude that ¬(emptyRel x y).
fun a b => -- assume arbitrary a and b (can use _'s here)
fun h => -- assume a proof of (emptyRel a b), call it h
h -- It *is* a proof of False, thus ¬emptyRel a b.
Next we claim and prove the claim that no pair can Be in the empty relation from String to Nat, in particular.
The empty relation on String and Nat is just the polymorphic (with type arguments) empty relation specialized to the types, α = String and β = Nat. EmptyRel does take two Type arguments, but they are defined to be implicit: Lean infers them from the type of emptyStrNat, namely Rel String Nat.
def emptyStrNat : Rel String Nat := emptyRel
In the following proof by negation, we assume we have a proof, h of the proposition that the pair, s and n, is in the relation and we need a proof of False. Study this example until you see with absolute clarity that h is assumed to be exactly that. Go back and study the definition of emptyStrNat if you're not sure why!
example : ∀ s n, ¬emptyStrNat s n := fun s n h => h
Relations Neither Empty or Complete
The empty and complete relation aren't very informative. The interesting ones are proper but non-empty subsets of the complete relation on their argument types. Let's see examples of some more interesting, less trivial, relations.
As an example, consider the "subrelation" of our full String to Nat relation with a pair (s, n), is in the relation if and only if n is equal to the length of s. In Lean, the String.length function takes a string and returns its length. We can use it to specify the relation we want, as follows.
def strlen : Rel String Nat := fun s n => n = s.length
We want to take a moment at this point to note a critical distinction between relations in Lean and functions that are represented as lambda expressions (ordinary computable functions in Lean). Functions in Lean and related systems are (1) computable, and (2) complete, which is to say for any value of the declared input type a function must return some value of the output type.
By contrast, relations in Lean need not be compelte. We have already seen an example in (with a defined output for every input); and they can be multi-valued, with several outputs for any given input.
Concerning completeness, a quick look back at the empty relation makes the point: the domain of definition is every value, but the relation has no pairs: it does not define an output for any of them. As far as being multi-valued, consider the complete relation defined about. It defines every value of the output type as an output for each and every value of the input type.
Finally, consider computability. We could easily define a computable function that computes to our strlen relation.
def strlenFun : String → Nat := fun s => s.length
#reduce strlenFun "Hello" -- computes 5
However, our strlen relation is not a computable function, but rather a declarative specification. When you apply it to values of its input types, you get not the output of the relation for those value, but a proposition that might or might not have a proof. If there is a proof (which you have to confirm yourself), you have a member; otherwise no.
In a nutshell, if you need to specify a partial function in Lean, or a multi-valued relation, then you have to do it with a declarative specification, not a computable function.
Here's yet another example. While we were able to prove that ("Hello", 7) is in the complete relation from String to Nat, there is no proof that this pair is in strlen, which has a much more restrictive membership predicate. Simply put, the length of "Hello" (defined in the Lean libraries), is 5, and 7 is not equal to 5, so this pair can't be a member.
-- uncomment this example to see error
-- example : strlen "Hello" 7 :=
-- What we need to prove is 7 = "Hello".length, i.e., 7 = 5.
-- _ -- We're stuck, as there can be no proof of that.
We can of course prove that ("Hello", 7) is not in the strlen relation. The proof is by negation. We'lll assume this pair is the relation. That'll lead to the assumption that 7 = 5, which is absurd. From that contradiction, we'll conclude that the pair is not in the strlen relation.
example : ¬strlen "Hello" 7 :=
The proof is constructed by negation. Assume strlen "Hello" 7; show that leads to a contradiction; conclude ¬strlen "Hello" 7. You must know this concept, be able to state it clearly, be able to see when you need it; and be able to use in to construct proofs whether formal or informal. So here we go. (1) By the definition of ¬, what we are really to prove is (strlen "Hello" 7) → False. This is an implication. To prove it we will assume the premise is true and that we have a proof of it (h : strlen "Hello" 7), and we will then show that that can't happen (as revealed by the fact that we'll have created a contradiction, showing that the initial assumption was wrong).
fun (h : strlen "Hello" 7) =>
Now that we have our assumed proof of (strlen "Hello" 7) we have to show that there's a contraction from which we can derive a proof of False. The only information we have is the proof, h. But how can we use it? The answer, for you, is that you again want to see the need to expand a definition, here of the term, (strlen "Hello" 7). What does that mean? Go back and study the definition of strlen! It means (7 = "Hello".length), which is to say, it means (7 = 5), as Lean will "run" the length function to reduce "Hello".length to the result, 5. There can be no proof of 7 = 5, because the only constructor of equality proofs, Eq.refl, can only construct proofs that objects are equal to themselves.
Another way to say this is that we'll show that we can have a proof of false for every possible proof, h, of 7 = 5; but there are no, so showing that False is true in all cases is trivial, as there is no case that can possibly account for h.
nomatch h
As a final example, we can prove that the pair, ("Hello",5), is in strlen. In English, all we'd have to say is that this pair satisfies the membership predicate because 5 = "Hello".length (assuming we've properly defined "length,." which Lean has. )
example : strlen "Hello" 5 :=
By the definition of strlen, we must prove 5 = "Hello".length. By the computation/reduction of "Hello".length this means 5 = 5. And that is true by the reflecxivity of equality, as for any value, k, whatsoever, we can have a proof of k = k by the rule of equality introduction, Eq.refl k. The "rfl" construct we've used in the past is just a shorthand for that, defined to infer the value of k from context. And Lean takes care of reducing the length function for us. We could write rfl here, but let's use (Eq.refl 5) to get a better visual of the idea that what we're producing here is a proof of 5 = 5.
Eq.refl 5
Finite Relations
A finite binary relation is a binary relation having only a finite set of pairs. For example, let's specify a String-to-Nat relation with just three String-Nat pairsW. e'll call strlen3, and will define it as (essentially) with the following three pairs:
def strlen3 := { ("Hello", 5), ("Lean", 4), ("!", 1) }
To specify this relation formally in Lean all we have to do is to figure out how to write the membership predicate. In this example, we'll do it as a three-clause disjunction, one clause for each pair.
def strlen3 : Rel String Nat :=
fun s n =>
(s = "Hello" ∧ n = 5) ∨
(s = "Lean" ∧ n = 4) ∨
(s = "!" ∧ n = 1)
We can prove memberships and non-membership of pairs in this relation in the usual way: by proving the proposition that is the result of applying the membership predicate to any pair of values. Here such a proposition will be a disjunction. Again you must be able to reduce a relation membership proposition, such as strlen3 "Hello" 5 to its underlying logical form. If you're not sure, you can use #reduce (types := true).
#reduce (types := true) strlen3 "Hello" 5
We see by the definition of strlen3, it will suffice to show that "Hello" = "Hello" ∧ 5 = 5 ∨ "Hello" = "Lean" ∧ 5 = 4 ∨ "Hello" = "!" ∧ 5 = 1. Copy of the body of strlen3 and for the formal parameters, substitute in the actual parameters. The result is the membership proposition for those parameter values. Here's what it looks like to prove this proposition.
example : strlen3 "Hello" 5 :=
-- we prove the result of applying strlen3 to "Hello" and 5:
-- ("Hello" = "Hello" ∧ 5 = 5) ∨ ("Hello" = "Lean" ∧ 5 = 4) ∨ ("Hello" = "!" ∧ 5 = 1)
-- We can prove (and can only prove) the left disjunct; then it's two equalities
Or.inl -- prove the left disjunct ("Hello" = "Hello" ∧ 5 = 5), using Or.intro
(
And.intro -- proof by and introduction
rfl -- "Hello" = "Hello"
rfl -- 5 = 5
)
We can also prove that ("Hello", 4) is not in the relation. The proof is by negation followed by case analysis on the 3-case disjunction that defines the membership predicate. We will assume (strlen3 "Hello" 4) is in the relation and t then show that it doesn't satisfy any of the three disjuncts and so can't be. From this contradiction we'll conclude that ¬(strlen3 "Hello" 4) is true.
example : ¬strlen3 "Hello" 4 := fun h =>
-- proof by case analysis on assumed proof h : (strlen3 "Hello" 4),
-- which is to say h is a proof of ("Hello" = "Hello" ∧ 4 = 5) ∨ ...
match h with
-- Is ("Hello", 4) the first allow pair, ("Hello", 5)?
| Or.inl (p1 : "Hello" = "Hello" ∧ 4 = 5) =>
let f : 4 = 5 := p1.right
nomatch f -- nope, can't have a proof of 4 = 5
-- case analysis on right side of disjunction
| Or.inr (rest : "Hello" = "Lean" ∧ 4 = 4 ∨ "Hello" = "!" ∧ 4 = 1) =>
match rest with
-- Is ("Hello", 4) the second allowed pair, ("Lean", 4)?
| Or.inl p2 =>
let f : "Hello" = "Lean" := p2.left
nomatch f -- nope, can't have proof of "Hello" = "Lean"
-- Is ("Hello", 4) the third and last allowed pair, ("!", 1)
| Or.inr p3 =>
let f : "Hello" = "!" := p3.left
nomatch f -- nope, can't haver a proof of "Hello" = "!"
Having shown there can be no proof of strlen3 "Hello" 4, we conclude ¬strlen3 "Hello" 4. QED.
-- Lean's smart enough to do all that work for us
example : ¬strlen "Hello" 4 := fun h => nomatch h
Elements of a Binary Relation
In this section, we define important terms and underlying concepts in the theory of relations. To illustrate the ideas, we'll refer back to our running examples.
Domain of Definition and Co-Domain
Given any relation, $r$ on sets of types, α and β, the domain of definition of r (r.dom) is the set, given by a type in Lean, from which all possible inputs to r (left elements pairs) are drawn. The co-domain is the set, in Lean the type, of all possible output values (right elements of pairs).
We will speak in type theoretical terms, and thus have specified the domain of definition and co-domain sets as types. This is nice as Lean can now typecheck values to see if they're in these sets.
Now we can specify any binary relation from α (input side) to β (output side) values, as a *predicate, p, on α and β, thus being of type (p : α → β → Prop). Lean, as we have seen, provides the polymorphic type, Rel α β as an abstraction from α → β → Prop, used particularly when a predicate is representing a binary relation, in particular.
With that representation, we can now see how we can return the domain of definition of r, as the sets of all values (the universals sets) of types, α, and β, respectively.
def domDef (r : Rel α β) : Set α := Set.univ
def codom (r : Rel α β) : Set β := Set.univ
Let's see some applications.
-- The domain of definition of strlen3 is a set of strings
-- The codomain of strlen3 is a set atural numbers
#check (domDef strlen3)
#check (codom strlen3)
-- Its domain of definition is the set of all strings
-- Its codomain is the set of all natural numbers
#reduce (domDef strlen3)
#reduce (codom strlen3)
Domain and Range of a Binary Relation on α and β
We now define what it means for a set to be, respectively, the domain or the range of r. The domain of r is the set of all (a : α) values for which r there is some corresponding β value, b, such that the pair (a, b) is in the relation r. Similarly, the range of r is the set of all output values, (b : β) for which there is some input value, (a : α), where r relates a and b.
def dom (r : Rel α β) : Set α := { a | ∃ b, r a b }
def ran (r : Rel α β) : Set β := { b | ∃ a, r a b }
-- Let's look at some applications
-- set types
#check dom strlen3 -- a set of strings
#check ran strlen3 -- a set of natural numbers
-- set values
#reduce dom strlen3 -- fun a => ∃ b, strlen3 a b
#reduce ran strlen3 -- fun b => ∃ a, strlen3 a b
-- The domain of definition and the codomain of each of the relations -- we've specified as examples are all values of type α = String and -- all values of type β = Nat, respectively.
-- The set of values, (a : α), that r does relate to β values, -- i.e., the set of values that do appear in the first position of -- any ordered pair, (a : α, b : β) in r, is called the domain -- of r. The domain of r is the set of values on which r is said -- to be defined. It's the set of input values, (a : α), for -- which r specifies at least one output value, (b : β).
-- More formally, given a relation, r : Rel α β, the domain of r is -- defined to be the set { x : α | ∃ y : β, r x y }. Another way to -- say this is that the domain of r is the set of α values specified -- by the predicate, fun x => ∃ y, r x y. In other words, x : α is -- in the domain of r if any only if there's some y : β such that -- the pair of values, x, y, satisfies the two-place predicate r.
-- In Lean, if r is a binary relation (Rel.dom r) is its domain set. -- We can define the domain of definition and the domain of any binary -- relation as follows.
-- def dom (r : Rel α β) : Set α := { a | ∃ b, r a b }
-- def domDef (r : Rel α β) : Type := α
-- The domain of definition of a relation is thus the set of -- possible input values, that can appear as first arguments in -- membership propostions. The domain of a relation is the subset -- of its domain of definition for which there are corresponding -- output values. The codomain is the set of values (type in Lean) -- containing all possible output values. The range of a relation -- is the subset of values in the codomaim for which there really -- are corresonding related input values in the domain.
-- def codom (r : Rel α β) : Type := β
-- def ran (r : Rel α β) : Set β := { b | ∃ a, r a b }
Lean's Definitions
We didn's have to define these operations ourselves, as Lean provides them to us from its libraries. In particular, Rel.dom reduces to the domain of any relation, and Rel.codom sadly reduces to its range as we've defined these terms here.
#reduce (types := true) Rel.dom r
-- fun x => ∃ y, r x y
#reduce (types := true) Rel.codom r
-- fun y => ∃ x, r x y
-- Regrettably, Lean defines codom to be what we have called -- the range of a relation. -- As I said in class, these terms are used somewhat inconcistenty -- in the mathematics literature. For this class we'll stick with -- the concepts as we've defined them here, with the range of a -- relation being the set of all output values for which there is -- a corresponding input.
More Examples
We'll start by looking at the domains of a few of the relations we've already introduced.
#reduce Rel.dom strlen
-- fun x => ∃ y, strlen x y
-- think of this as the set { x | ∃ y, strlen x y}
#reduce Rel.dom emptyStrNat
-- fun x => ∃ y, emptyStrNat x y
-- think of this as { x | ∃ y, emptyStrNat x y }
-- equivalently { x | ∃ y, False }
We can now prove, for example, that "Hello" ∈ strlen3.dom. By the definition of the domain of a relation, we need to show (∃ y, strlen3 "Hello" y). As a witness for y, 5 will do.
#reduce (types := true) ("Hello" ∈ strlen3.dom )
example : "Hello" ∈ strlen3.dom :=
-- Prove there is (exist) some y such that ("Hello", y) is in strlen3.
-- A constructive proof requires a witness for y. Clearly y = 5 will work
Exists.intro -- Apply the inference rule
5 -- to y = 5 as a witness
(Or.inl (And.intro rfl rfl)) -- with a proof that ("Hello", y=5) in is strlen3
-- Here's the definition of the range (called *codom*)
#reduce strlen3.codom
-- Here's the definition of the range of strlen3
#reduce (types:=true) strlen3.codom
-- fun y => ∃ x, strlen3 x y
With all of that, it should be clear that we can express propositions about certain values being in or not in the domain or range of a relation, and then try to prove them by reducing the definitions of these terms to their logical meanings. Let's try to prove that 5 is in the codomain of strlen3, for example.
In a natural language informal proof, we could start by saying, "By the definitions of strlen and range (codom), what remains to be proved is"
∃ x, x = "Hello" ∧ 5 = 5 ∨
x = "Lean" ∧ 5 = 4 ∨
x = "!" ∧ 5 = 1
The initial step is thus to apply the introduction rule for exists. And the rest is just basic logic.
#reduce (types := true) 5 ∈ strlen3.codom
example : 5 ∈ strlen3.codom :=
Exists.intro
"Hello" -- Use "Hello" as a witness
(Or.inl -- Now a proof that it works
(And.intro rfl rfl)
)
The Inverse of a Binary Relation on Types/Sets α and β
The inverse of a relation, r, on α and β, is a relation on β and α, comprising all the pair from r but with the first and second element values swapped. Sometimes you'll see the inverse of a binary relation, r, written as r⁻¹.
#reduce Rel.inv
The specification is easy. The membership predicate for a pair, (b, a), to be in the inverse of a relation r is that the pair (a, b) is in r. Think about it and you will see that r.inv has to be the same as r but with all pairs flipped.
def inv (r : Rel α β) : Rel β α := fun b a => r a b
Example: What is the inverse of strlen3? In Lean it's writen either (Rel.inv strlen3) or just strlen.inv. Remeber, This is a predicate being used to represent a relation. The inverse of r is a new predicate. If r takes areguments, a of types α, and then b of type β, then r.inv takes as arguments some (b : β) and (a : α) and returns the proposition that (a, b) is in r. If there's a proof of that, then (b, a) is specified to be in r.inv. To so prove that some (b, a) is in r⁻¹, you just show that (a, b) ∈ r.
Conjecture: (5, "Hello") is in the inverse of strlen3. Proof: it suffices to prove ("Hello", 5) is in r. But we've already done that once and don need to do it again here! Oh, well, ok.
#reduce (types := true) strlen3.inv 5 "Hello"
example : strlen3.inv 5 "Hello"
:=
By the definition of the inverse of a relation, the proposition, (strlen3.inv 5 "Hello"), which we are to prove, reduces to (strlen3 "Hello" 5). By the definition of strlen3, this proposition tne reduces to ("Hello" = "Hello" ∧ 5 = 5) ∨ ... This proposition is now proved by or introduction on the left.
Or.inl ⟨ rfl, rfl⟩
-- Remember ⟨ _, _ ⟩ here is shorthand for And.intro
We can actually now state and prove more general theorems showing that the inverse operation has the property that appying it twice is equivalent to doing nothing at all. Think about it: if (a, b) is in r, then (b, a) is in the inverse of r, but now (a, b) must be in the inverse of this inverse of r. Going the other way, if (a, b) is in the inverse of the inverse of r, then (b, a) must be in the inverse of r, in whcih case (a, b) must be in r. In other words, (a, b) is in r if and only if (a, b) is in the inverse of the inverse of r. We want to know that this statement is true for any values of a and b (of their respective types).
theorem inverseInverseIsId:
∀ {α β : Type} (rel : Rel α β) (a : α) (b : β), rel a b ↔ rel.inv.inv a b :=
-- assume a and b are arbitrary values (by ∀ introduction)
fun rel a b =>
-- prove r a b ↔ r.inv.inv a b
-- by iff intro on proofs of the two implications
Iff.intro
-- prove r a b → r.inv.inv a b
(
-- assume h : r a b
-- show r.inv.inv a b
-- but h already proves it
-- because r.inv.inv is r
fun h : rel a b => h
)
(
-- assume h : r.inv.inv a b
-- show r a b
-- but h already proves it
-- because r is r.inv.inv
fun h : rel a b => h
)
The Image of a Set Under a Binary Relation
Suppose we have a binary relation, (r : Rel α β), and a set of α values, (s : Set α). The image of s under r is the set of values that, in a sense, you get by iterating over all the elements of s, getting one element, e, at a time, then finding all the the "output" values in r for that one "input" value and finally combinining all of these output into output set. We don't write that as a program, though, but as a mathematical specification.
Here you can see the formal definition of the image of a set s under a relation r.
#reduce Rel.image
-- fun r s y => ∃ x ∈ s, r x y
Given a relation, r, a set of input values, s, and a y of the output type, y is specified to be in the image of r when there is some input value, x, in s, such that r relates x to y. In other words, the image of s under r is the set of all output values for any and all ainput values in s.
#reduce Rel.image
-- fun r s y => ∃ x ∈ s, r x y
Given any relation, r, and any set of input values, s, an output value y is (defined to be) in "the image of s under r" exactly when y is the output of r for at least one (some) value x in the set s. The image operator on a relation acts like it's applying the whole relation to a whole set of values and getting back all values related to any value in the argument set.
In this example we consider the image of the two-member set, { "Hello", "!"}, under the strlen3 relation. You can see intuitively that it must be the set {5, 1}, insofar as strlen3 relates "Hello" to (only) 5, and "!" to (only) and there are no other values in s. Let's see what we're saying formally, as reported by Lean. *Be sure you understand all of the outputs of #reduce that we're presenting over in this lesson!
#reduce Rel.image strlen3 { "Hello", "!"}
-- Here's an easier way to write the same thing
#reduce strlen3.image {"Hello", "!"}
-- fun y => ∃ x ∈ {"Hello", "!"}, strlen3 x y
The image, specified by the single-argument predicate, fun y => ∃ x ∈ {"Hello", "!"}, strlen3 x y, is, again, the set of all output (β) values, y, such that, for any given y there is some x in s that r relates to y.
Given this definition and our understanding, we see that it should be possible to show that, say, 1, is in the image of { "Hello", "!"} under strlen3, because there is a string in the set, { "Hello", "!"}, namely "!", that r relates to 1 (for which (strlen3 "!" 1) is true).
example : 1 ∈ strlen3.image { "Hello", "!"} :=
By the definition of Rel.image we need to prove is that there is some string, x, in the set, call it s, of strings, that satisfies strlen3 s 1. This string, in s, will of course be "!", so that will be our witness. The proof is by exits introduction with "!" as the witness.
Exists.intro "!"
(
-- what remains to be proved is "!" ∈ {"Hello", "!"} ∧ strlen3 "!" 1
-- the proof is by and introduction
And.intro
-- prove: "!" ∈ {"Hello", "!"}
-- this is obvious but, sorry, maybe I'll prove it later
(sorry)
-- prove: strlen3 "!" 1
-- this is obvious but, sorry, maybe I'll prove it later
(sorry)
)
We should also be able to show that 4 is not in the image of s under strlen3. The proof is by negation. We'll assume that 4 is in the image, but we have already figured out that the only elements in there are 5 and 1 (from "Hello" and "!"). The problem will be that assuming 4 is in the set isto assume that 4 = 5 ∨ 4 = 1. That's a contraction as each case leads to an impossibiility. Thus 4 ∉ strlen3.image { "Hello", "!"}. QED,
example : 4 ∉ strlen3.image { "Hello", "!"} :=
-- assume, h, that 4 is in this set
fun h =>
-- From 4 ∈ strlen3.image {"Hello", "!"}, prove False
-- By the definition of image, h proves ∃ s, strlen3 s 4
-- The only information we have to work with is h
-- And the only thing we can do with it is elimination
Exists.elim
h
-- ∀ (a : String), a ∈ {"Hello", "!"} ∧ strlen3 a 4 → False
-- we assumed r relates some string in s to 4 call it w
(
fun w =>
(
-- prove: w ∈ {"Hello", "!"} ∧ strlen3 w 4 → False
-- assume premise : w ∈ {"Hello", "!"} ∧ strlen3 w 4
fun p =>
(
let l := p.left
-- TODO
let r := p.right
-- recognize that w proves a disjunction
-- eliminate using Or.elim via pattern matching
-- case analysis
match l with
-- left case, with "hello" a proof of w = "Hello" (in this case)
-- we also have r, a proof of (strlen3 w 4)
-- we can use "hello" to rewrite (strlen3 w 4) to (strlen3 "Hello" 4)
-- by the definition of strlen3 this is a disjuncion that is false
-- using
| Or.inl hello =>
We haven't talked about "tactic mode" in Lean. Without getting
into details, we're arging that because "hello" proves w = "Hello",
we can replace w in r, which proves strlen3 w 4, to get a proof
of strlen3 "Hello" 4. But the pair, ("Hello", 4), is not in the
strlen3 relation, as that pair of arguments doesn't satisfy the
constraint it imposes on membership. The nomatch construct does
a case analysis on this rewritten version of r and finding that
there are no ways it could be true, dismisses this overall case.
(
by
rw [hello] at r
nomatch r
)
-- the second case, w ∈ { "!" } requires deducing from that that w = "!"
-- that then allows us to rewrite (strlen3 w 4) to (strlen3 "!" 4)
-- by the definition of strlen3, that'd mean, inter alia, that 1 = 4
| Or.inr excl =>
(
have wexcl : w = "!" := excl
(
by
rw [wexcl] at r
nomatch r
)
)
)
)
)
In each case, where s = "Hello" or where s = "!", we have a contradiction: that s.length (either 5 or 1) is equal to 4. In each case we're able to derive a contradiction, showing that the assumption that 4 ∈ strlen3.image {"Hello", "!"} is wrong, therefore ¬4 ∈ strlen3.image {"Hello", "!"} is true. QED.
The Preimage of a Set under a Relation
Given a relation, r, the pre-image of a set, t, of values from the codomain, is the set, s, of values from the domain that have related values in t.
#reduce Rel.preimage
-- fun r s y => ∃ x ∈ s, r.inv x y
Example: Mary's Bank Account Numbers
Let's make up a new example. We want to create a little "relational database" with one relation, a binary relation, that associates each person (ok, their name as a string) with his or her bank account number(s), if any.
There's no limit on the number of bank accounts any single person can have. A person could have no bank accounts, as well. To be concrete, let's assume there are three people, "Mary," "Lu," and "Bob", that Mary has accounts #1 and #2; Lu has account #3; and that Bob has no bank account at all.
We can think of the relation (call it acctsOf) as the set of pairs, { Mary, 1), (Mary, 2), (Lu, 3) }. One thing to see here is that the image of { Mary } under acctsOf is not a single value but a set of two values: {1, 2}. This relation is not single-valued. Another point is that it's not complete, as there is no output at all for Bob.
def acctsOf : Rel String Nat := fun s n =>
s = "Mary" ∧ n = 1 ∨
s = "Mary" ∧ n = 2 ∨
s = "Lu" ∧ n = 3
Let's remind ourselves how the image of the set, { "Mary" } under the acctsOf relation is defined. As usual, hover over "#reduce" to see its output. It's the set of output numbers related to any of the values in the set { "Mary"}, represented as a predicate.
#reduce acctsOf.image { "Mary" }
So, acctsOf.image {"Mary"} is the set of output values, represented by the predicate, fun y => ∃ x ∈ {"Mary"}, acctsOf x y. In English, this is the set of output numbers, y for which there are related people in the input set, { "Mary" }.
With that, we can now write propositions about membership of numbers in such an image set.
-- Proof that 1 is in acctsOf.image { "Mary" }
example : 1 ∈ acctsOf.image { "Mary" } :=
By the definition of image, we need to prove ∃ x ∈ {"Mary"}, acctsOf x y. At this point, we have to find/pick a witness---here an input value in the set, { "Mary"} )--- for which 1 is among the outputs. Then we just prove that that pair is in fact in the relation.
The proof is by exists introduction. We show that there is some x in { "Mary" } and for that x, (x, 1) is in acctsOf. The only possible x to choose in this case is "Mary".
-- Pick "Mary" as the witness
(Exists.intro "Mary"
-- now prove "Mary" ∈ { "Mary" } ∧ acctsOf "Mary" 1
-- the proof if of course by and introduction
(And.intro
-- Exercise: prove "Mary" ∈ { "Mary" }
(sorry)
-- Exercise: prove (acctsOf "Mary" 1)
(sorry)
)
)
-- Exercise: Prove that 2 is also in the image of { "Mary"}
example : 2 ∈ acctsOf.image { "Mary" } := sorry
-- Exercise: Prove that 3 is NOT one of Mary's bank accounts
-- HERE:
A Most Fundamental Relation: Equality
The Equality Relation in Lean, called Eq is defined as an inductive type whose values are proofs of equalities between terms up to reduction. The = symbol is an infix notation for Eq, so instead of writing Eq a b we can write a = b.
Such an equality proposition has a proof if and only if both sides reduce to the same term. For example, 1 + 1 = 2 has a proof because 1 + 1 reduces to 2, leaving only 2 = 2 to be proved. The single proof constructor for Eq, called Eq.refl, takes any value of any type, (a : α) and constructs a proof of a = a. Applying it to 2 thus yields a proof of 2 = 2, and that's what we wanted to confirm.
example : 1 + 1 = 2 := rfl
Here's the definition of the equality type in Lean.
#check Eq -- right click and go to definition
inductive Eq : α → α → Prop where
| refl (a : α) : Eq a a
The first line establishes that Eq takes two arguments, a and b, and yields the proposition, Eq a b, which with infix notation is usually written a = b. The second line provides the sole means of proving an equality. It takes 1 argument, a, and return a proof that a = a.
example : 1 + 1 = 2 := Eq.refl 2
So what about rfl? Here's how it's defined.
rfl {α : Sort u} {a : α} : Eq a a := Eq.refl a
It's special power is that it infers both α and a and returns a proof of a = a by reducing to Eq.refl a. It works so long as Lean can infer both values. If not, then use Eq.refl a.
Example: The Unit Circle in the Cartesian Plane
The unit circle in the Cartesian plane is the set of points, represented by ordered pairs of real numbers, (x, y), that satsify the predicate, x² + y² = 1. The points (0, 1) and (0,1) are on the circle, but (0,0) isn't, as 0² + 1² = 1 but 0² + 0² ≠ 1.
Now we can give a formal definition of the relation. See the import at the top of this file for the Real numbers. In Lean, Real is the type of the real numbers. With that, here's the definition. Note that just as we can use ℕ for Nat, so we can use ℝ for Real, mirroring the use of these blackboard font notations by most mathematicians.
def unitCircle : Rel ℝ ℝ := fun x y => x^2 + y^2 = 1
So now let's state and try to prove the proposition that the pair, (0, 1) is in this relation (on the unit circle). Here's the proposition itself.
def zoUnitCircle : Prop := unitCircle 0 1
We might expect that by the definition of unitCircle this proposition would reduce to 0² + 1² = 1, with the expression 0² + 1² then reducing to 1 = 1, with rfl as an easy proof.
-- Uncomment to see the actual error
-- example : unitCircle 0 (-1) := rfl -- doesn't work!
That didn't work! The problem is that the real numbers are not computable (!) so Lean cannot reduce 0² + 1² to 1 computationally. Instead, opne must must reason logically reals based on the axioms used to define them and theorems already proved from those axioms. One must deduce that 0² + 1².
You can do such reasoning entirely on your own, but it is tedious and requires an understanding of definitions and previously proved theorems about real numbers.
The good news is that Lean provides a vast library of programs that automate aspects of proof construction. These programs, themselves written in Lean, take the current proof state, goal, and other inputs and try to prove the goal. These programs are called tactics. To finish off this chapter, we will take the opportunity to give a first taste of tactic-based proving, for the particular problem we face right here.
Tactics: Automating Steps in Proof Construction
A tactic is not a proof term, but rather a program, that someone wrote, in Lean, that attempt to construct a proof term for the current goal. These terms usually have remaining holes to be filled in, which become the new goals to be proved.
The simp Automates Reasoning to Simplify Expressions
One of the most widely used tactics in Lean is called simp. It has a default database of already accepted definitions, e.g., of functions, theorems, etc., and tries to find a way to apply them to simplify a goal so that you don't have to do by hand,
In the best cases, it will simplify a goal to an equality that can finally be proven by Eq.refl or rfl, which the tactic will apply for you. As an example, here is how we can use the simp tactic to obtain a proof term that shows that (0, -1) = 1 in the real numbers, and is thus on the unit circle.
theorem zeroMinusOneOnUnitCircle : unitCircle 0 (-1) :=
by
simp [unitCircle]
The keyword, by, places Lean in tactic mode. It is in this mode that you can run tactics. In general you can mix term and tactic modes in constructing proofs in Lean. We will see more of tactics later on.
Here we run simp, informing it of the definition of the definition of unitCircle. This tactic will then combine this definition with other dedinitions in its database, to try to prove the goal. Here, the tactic succeeds in producing a proof term that Lean then checks and accepts.
Tactics do not always succeed. In that case you will get an error message and your proof state (sequent) will be unchanged. Moreover, even if a mistake was made in the implementation of a tactic, Lean still checks the proof terms it produces, just as if you had produced them by hand. Tactics are thus not of the correctness-critical part of Lean, and even if buggy tactics succeed in producing bad proof terms, Lean will still check, and not accept, them.
You don't ordinarily see the proof terms that tactics construct. You can nevertheless now have confidence that (0, -1) is on the unit circle in the Cartesian plan, as Lean has checked and accepted the generated proof term.
The translation into English of the tactic-built proof that simp finds for you into English is easy. You can just say, by the definition of unitCircle and other rules of real arithmetic, the proposition evidently valid. QED.
You Can See What Reasoning Principles It Used
For those interested in futher study, Lean has a ton of options you can set to have it tell you more or less information as it goes. The following option tells Lean to tell you what facts the simp tactic uses in trying to produce a proof for you. Hover over simp (now with a blue underline in VSCode) to see what facts simp used. Don't worry about details, just be happy you did not have to construct that proof by hand!
set_option tactic.simp.trace true in
-- hover over simp to see all the definitions it used
example : unitCircle 0 (-1) :=
by
simp [unitCircle]
Proving Propositions About the Real Unit Circle
Now that we have the tools we need to prove propositions about membership in the unitCircle relation, we should be all set to prove propositions about membership in the image of a given set of input values under the unitCircle relation.
We can see that if the input values are {x = 0, x = 1} that the corresponding set of output values should be {-1, 1, 0}. If you don't see that, you need to review basic algebra and confirm that (0, -1), (0, 1), and (1, 0) are the points on the unit circle with x = 0 or x = 1.
Here's the expression for the image set (of output values) for the set of input values, { 0, 1}. Hovering over #reduce gives you the predicate that defines the output/image set.
#reduce unitCircle.image { 0, 1 }
-- fun y => ∃ x ∈ {0, 1}, unitCircle x y
So now we're set: Let's prove that -1 is in the image of { 0, 1 } under the unitCircle relation. Here's the claim/proposition, followed by the proof. As usual, you cannot just read this proof. Use Lean to interact with it, to understand the reasoning at each step, what is left to prove after each step, and how the whole thing really does (firmly in your mind) prove that one of the outputs given inputs 0 and 1 is in fact -1.
example : -1 ∈ unitCircle.image { 0, 1 } :=
By the definition of image, what we have to show is that ∃ x ∈ { 0, 1 } such that (x, -1) is in the image. The proof is thus by exists introduction, and we have to pick a witness that will make the remaining proof go through. The only value that x/input value in that set that will work is 0.
Exists.intro
0 -- witness
-- proof
( -- prove (0 ∈ {0, 1}) ∧ (unitCircle 0 (-1))
-- by and introduction
And.intro
-- prove: (0 ∈ {0, 1})
-- { 0, 1 } is represented by the predicate fun n => n = 0 ∨ n = 1
-- applying fun n => n = 0 ∨ n = 1 to n = 0 is 0 = 0 ∨ 0 = 1
-- the proof is by left introduction of a proof of 0 = 0
(
Or.inl rfl
)
-- now what remains to be proved is (unitCircle 0 (-1))
(
-- i.e., 0^2 + (-1)^1 = 1
-- Eq.refl/rfl will *not* work; we can't compute with reals
-- rather we'll ask Lean to help simplify the goal
-- which it does, to the point that there's nothing left to prove
by simp [unitCircle]
)
)
A Few More Useful Tactics
As a final example, let's see how one might construct such a proof in tactic mode.
#reduce (types := true) -1 ∈ unitCircle.image { 0, 1 }
example : -1 ∈ unitCircle.image { 0, 1 } :=
by -- enter tactic mode
We need to show there's an x in { 0, 1 } such that (x, -1) is on the unit circle. Clearly x = 0 will do. So we use the apply tactic to apply the introduction rule for exists with 0 as a witness, leaving the proof argument to be provided separately. We use an _ to make it clear that we're leaving this proof term to be given later. Hovering over the _ (or checking the Info View) confirms that all that remains to be provided is this proof, namely a proof of 0 ∈ {0, 1} ∧ unitCircle 0 (-1).
apply Exists.intro 0 _
The proof that remains to be provided is a proof of the conjunction, 0 ∈ {0, 1} ∧ unitCircle 0 (-1). To prove it we apply the introduction rule for ∧, leaving the two proof arguments to be provided separately. Note that the proof context now has two goals pending, one for each of the conjuncts.
apply And.intro _ _
We can use curly braces and indenting to make tactic-based proofs easier to read. Here we prove each conjunct in its own { ... } construct.
{
The membership predicate here is a disjunction, so we
apply the rule for Or introduction, here on the left,
with only a simple equality to prove thereafter.
apply Or.inl _
apply rfl
}
{
-- The proof of this conjunct is as explained above.
simp [unitCircle]
}
Lean provides tactics to automate the application of the right rules. Here's the same proof again.
example : -1 ∈ unitCircle.image { 0, 1 } :=
by
use 0 -- give witness for proving ∃
constructor -- applies only constructor for goal
{
left -- applies Or.inl
rfl -- applies rfl
}
simp [unitCircle]
Finally, tactics can be composed into larger tactics. Here we sequentially compose the tactics from the proof just given into one big tactic, using ; to compose them.
As you can see, tactics make it easier to give compact proof construction instructions. The results however are not always very easy to understand. You have to know what a lot of tactics do under the hood. What you can do to gain insight is to step through such a tactic-based proof construction and watch how each steps transforms your context. Give it a try for yourself, with the Info View open, put your cursor before each tactic in sequence and see how the proof state changes until the last remaining details are given.
example : -1 ∈ unitCircle.image { 0, 1 } :=
by use 0; constructor; left; rfl; simp [unitCircle]
What's Next?
This lesson has introduced you to the formal definition and principles for reasoning about binary relations. In the next chapter we will cover a broad range of critical properties of relations, formalized as predicates on relations. Such a property will thus have the type, Rel α β → Prop, where, as we've learned here, Rel α β means α → β → Prop. So at bottom we will formalize a property of a binary relation as a predicate of the type, (α → β → Prop) → Prop. Properties of particular interest include those of being:
- reflexive
- symmetric
- transitive
- equivalence
- well-founded
end DMT1.Lectures.setsRelationsFunctions.relations
import Mathlib.Data.Rel
import Mathlib.Data.Set.Basic
import Mathlib.Logic.Relation
import Mathlib.Data.Real.Basic
namespace DMT1.Lectures.setsRelationsFunctions.equality
The Equality Relation in Lean (Eq)
Equality is a binary relation. When we write, a = b, we are asserting that the terms, a and b, refer to exactly the same value.
In Lean, Eq is a binary relation on two values of any single type, α that is used to expres propositions that two values are equal. The term, Eq a b, is such a proposition. We would ordinarily write it as a = b. Lean does provide = as an infix operator for Eq.
-- uncomment to see error
-- #check Eq 1 "foo" -- equality undefined on different types
#check Eq 1 2 -- the proposition, 1 = 2
#check 1 = 2 -- the same proposition infix = notation
#check "hi" = "hi" -- an equality proposition that is true
Formal Definition
The family of equality propositions in Lean is defined by the polymorphic Eq inductive type. Applying it to any two values, a and b of any one type, α, forms the proposition a = b. Here's the formal definition.
inductive Eq : α → α → Prop where
| refl (a : α) : Eq a a
Introduction
To prove an equality proposition, one must apply the constructor, Eq.refl to a single value, a, effectively yielding a proof that a = a. No other equalities can be proved, except that terms are reduced when comparing them for equality.
example : 1 = 1 := Eq.refl 1 -- both sides identical terms
example : 1 + 1 = 2 := Eq.refl 2 -- both sides reduce to 2
example : "Hello, Lean" = "Hello, " ++ "Lean" := -- same here
Eq.refl "Hello, Lean"
Elimination
The elimination rule (derived from the recursor for Eq) basically
says that if you have a proof, heq, of the equality, a = b,
and you have a proof, pa of the proposition, P a (P being a
predicate), then you can derive a proof of P b by applying the
elimination rule for equality, called Eq.subst to heq and pa,
as in the expression, Eq.subst heq pa
*. The result will be a term,
(pb : P b).
variable
(α : Type)
(a b c : α)
(P : α → Prop)
(heq : a = b)
(pa : P a)
#check ((Eq.subst heq pa) : P b)
Rewriting Using Equalities
There is little more natural than the idea that if we have a valid argument using some term, a, and we also know that a = b, then that same argument with a replaced by b will still be valid. In other words, equalities allow us to rewrite any a to b as long as we have and use a proof of a = b.
One can apply the Eq.subst inference rule directly, but it is more common to use Lean's rewrite tactics, namely rw and ←rw. Given heq : a = b, the rw tactic rewrites a to b, while the ←rw tactic rewrites all b to a.
example
(α : Type) -- Suppose α is any type,
(P : α → Prop) -- P is any property of α values,
(a b : α) -- a and be are α values
(heq : a = b) -- a = b
(ha : P a) : -- and a has property P
P b := -- Then so does b
Eq.subst heq ha -- By rewriting a to b justified by a = b
example
(Person : Type) -- Suppose there are people,
(Happy : Person → Prop) -- who can be Happy, and
(a b : Person) -- that a and b are people
(heq : a = b) -- and moreover a = b
(ha : Happy a) : -- and finally that a is Happy
Happy b := -- then b must also be happy
Eq.subst heq ha -- by rewriting a to b using a = b
Naturally, Lean provides a tactic to automate the application of the elimination rule for equality to convert proofs of P a to proofs of P b using a proof of a = b. It's called rewrite and is abbreviated rw. In comes in two flavors as the following examples show.
example
(Person : Type) -- Suppose there are people,
(Happy : Person → Prop) -- who can be Happy, and
(a b : Person) -- that a and b are people
(heq : b = a) -- and moreover a = b
(ha : Happy a) : -- and finally that a is Happy
Happy b :=
by
rw [heq]
-- exact ha
assumption -- (looks for a proof in your context)
example
(Person : Type) -- Suppose there are people,
(Happy : Person → Prop) -- who can be Happy, and
(a b : Person) -- that a and b are people
(heq : a = b) -- and moreover a = b
(ha : Happy a) : -- and finally that a is Happy
Happy b :=
by
rw [←heq]
exact ha
example
(Person : Type) -- Suppose there are people,
(Happy : Person → Prop) -- who can be Happy, and
(a b : Person) -- that a and b are people
(heq : a = b) -- and moreover a = b
(ha : Happy a) : -- and finally that a is Happy
Happy b :=
by
rw [heq] at ha
exact ha
Properties of Equality
From just the introduction and elimination rule, we can also prove that the equality relation is reflexive (the introduction rule gives us this), symmetric, and transitive; and having all three properties also makes it into what we call an equivalence relation.
Reflexive
The introduction rule for equality assures that equality is what a reflexive relation: every value of any type is related (equal) to itself under this relation.
theorem eqRefl
{α : Type} -- given any type
(a : α) : -- and any value a
a = a := -- a is related to itself
Eq.refl a -- by the intro rule
In effect, the Eq.refl constructor is a proof of the reflexivity of equality. It stipulates that for any value, a, of any type, α, a = a.
#check @Eq.refl
Eq.refl :
∀ {α : Sort u_1}
(a : α),
a = a
Symmetric
A binary relation on a single type is symmetric if whenever it relates any a to some b it also relates that b to that a. It's easy now to prove as a theorem the claim that equality as defined here is a symmetric relation.
theorem eqSymm {α : Type} (a b : α) : a = b → b = a :=
by
intro heq -- assume a = b
rw [heq] -- rewrites goal b = a to a = a
-- rw automates applying Eq.intro at the end
Lean provides a proof of the symmetry of equality. It's called Eq.symm.
#check @Eq.symm
@Eq.symm :
∀ {α : Sort u_1}
{a b : α},
a = b → b = a
Transitive
A binary relation on a set is said to be transitive if, whenever it relates some a to some b, and some b to some c, it relates a to c. Again using just the introduction and elimination rules it's easy to prove that equality as defined is a transitive relation.
theorem eqTrans {α : Type} (a b c : α) : a = b → b = c → a = c :=
by
intro hab hbc
rw [hab, hbc]
Lean provides a proof of the transitivity of equality. It's called Eq.trans.
#check @Eq.trans
@Eq.trans :
∀ {α : Sort u_1}
{a b c : α},
a = b → b = c → a = c
Equivalence
Finally, if a relation is reflexive, symmetric, and transitive, we call it an equivalence relation. Such a relation has the effect of partitioning its domain into non-overlapping (disjoint) collections of equivalent values called equivalence classes. Equality is clearly an equivalence relation, partitioning terms into disjoint classes of terms where all terms in the same class reduce to the same value (e.g., 2, 1 + 1, 2 + 0, 2 * 1, etc). We will address equivalence relations in more detail in the next chapter.
end DMT1.Lectures.setsRelationsFunctions.equality
import Mathlib.Data.Rel
import Mathlib.Data.Set.Basic
import Mathlib.Logic.Relation
import Mathlib.Data.Real.Basic
namespace DMT1.Lectures.setsRelationsFunctions.propertiesOfRelations
Properties of Binary Relations
- Some General Properties of Binary Relations
- Properties of Functions
- Properties of Endorelations
- Ordering Relations
- Closure Operations on Endorelations
- Proving Properties of Relations
- Exercises
- Homework
There are many important properties of relations. In this section we'll formally define some of the most important.
Some General Properties of Binary Relations
Empty Relation
We start with the simple property of a relation being empty. That is, no value pairs satisfy its membership predicate. We would typically write the definition of such a property of a relation as follows:
def isEmpty'' {α β : Type u} : Rel α β → Prop :=
fun r => ¬∃ (x : α) (y : β), r x y
Here α and β are arbitrary types. We formalize the property as a predicate on binary relations from α to β, having the type, Rel α β → Prop. The definition is thus in the form of a function from a binary relation, r, to a specific proposition about it: that no values, x and y, satisfy its membership predicate.
In this chapter, we will specify many properties of relations in this style. To avoid having to introduce α, β, and r in each case, we can declare them once using Lean's variable construct. Lean will thereafter introduce them automatically into definitions in which these identifiers are used. We will also define e as a binary relation from a single type, α, to itself. A relation of this kind is called homogeneous, or an endorelation. We will use the identifier e for any endorelation.
section properties
variable
{α β : Type u} -- arbitrary types as implicit parameters
(r : Rel α β) -- arbitrary binary relation from α to β
(e : Rel α α) -- arbitrary homogeneous/endo relation on α
With these variable definitions, we can now specific the same property with a lot less syntactic boilerplate. First, we can omit declarations for α, β, and r.
def isEmpty' :=
¬∃ (x : α) (y : β), r x y
Second, Lean can infer the specified value is a proposition, so we can omit the : Prop type declaration as well, leaving us with pretty much what one would say in English: a relation is empty if there isn't any pair, (x, y), in the relation.
def isEmpty := ¬∃ (x : α) (y : β), r x y
We will avail ourselves of the use of this shorthand for the rest of this file. Just remember that the mere appearance of the identifiers, α, β, r, or e in such a definition causes Lean to insert type declarations for them, producing the same desugared term as in the first version of isEmpty above.
Complete Relation
The property of a relation relating every pair of values. We call such a relation complete. NOTE: We've corrected the previous chapter, which used the term total for such a relation. Use complete as we will define total to be a different property.
def isComplete := ∀ x y, r x y
-- Example, the complete relation on natural numbers
def natCompleteRel : Rel Nat Nat := fun _ _ => True
-- A proposition and a proof that it is complete
example : isComplete natCompleteRel :=
By the definition of isComplete we need to prove that every pair is in this relation. In other words, we need to prove, ∀ (a b : Nat), natCompleteRel a b. This is a universally quantified proposition, so we apply the rule for ∀, which is to assume arbitrary values of the arguments, a,, b, and then show natCompleteRel a b.
fun _ _ => True.intro
Total Relation
A relation is said to be total if it relates every value in its domain of definition to some output value. In other words, a relation is total if its domain is its entire domain of definition.
def isTotalRel := ∀ (x : α), ∃ (y : β), r x y
Single-Valued Relation
A binary relation is said to be single-valued if no input is related to more than one output. This is the property that crucially distinguishes binary relations in general from ones that are mathematical functions. Simply put, a function cannot have more than one output for any given input.
The way we express this property formally is slightly indirect. It says that to be a function means that if there areoutputs for a single input then they must be the same output after all.
def isSingleValuedRel := ∀ x y z, r x y → r x z → y = z
Surjective Relation
A relation is called surjective if it relates some input to every output in its codomain. That is, for every output, there is some input that maps to it. We note that this property is often defined as being applicable only to functions. We define isSurjective below accordingly.
def isSurjectiveRel :Prop := ∀ (y : β), ∃ (x : α), r x y
Injective Relation
A relation is said to be injective if there is no more than one input that is related to any given output. Such a relation is also called one-to-one, as distinct from many-to-one, which injectivity prohibits.
def isInjectiveRel :=
∀ x y z, r x z → r y z → x = y
Many-To-One Relation
A many-to-one relation is one that is not injective. In other words, it's not the case that every input maps to at most one output value.
def isManyToOneRel := ¬isInjectiveRel r
One-To-Many Relation
A relation is said to be one to many if it allows one input to map to multiple outputs while still requiring that multiple inputs don't map to the same output.
def isOneToMany :Prop :=
¬isSingleValuedRel r ∧
isInjectiveRel r
Many-To-Many Relation
A relation is said to be many to many if it is neither functional (so some input maps to multiple outputs) not injective (so multiple inputs map to some single output).
def isManyToMany :=
¬isSingleValuedRel r ∧
¬isInjectiveRel r
Properties of Functions
Function
We define an alias, isFunction, for the property of being single-valued.
def isFunction : Rel α β → Prop :=
isSingleValuedRel
Total Function
A total function is a function that is total as a relation, i.e., it maps every input to some output.
def isTotalFun := isFunction r ∧ isTotalRel r
Injective Function
The term, injective, is usually applied only to functions. This and the following few definitions apply only to functions and thus all include as a condition that r be function as well as, in this case, never mapping one input to multiple outputs. In other words, to be an injective function is to be a function and to be injective as a relation.
def isInjectiveFun : Prop :=
isFunction r ∧
isInjectiveRel r
One to one is another widely used name for being injective, in contradistinction to being many-to-one.
def isOneToOneFun : Rel α β → Prop :=
isInjectiveFun
Surjective Function
Tbe be a surjective function is to be a function (single-valued) and to be surjective as a relation.
def isSurjectiveFun :=
isFunction r ∧
isSurjectiveRel r
"Onto" is another name often used to mean surjective. The idea is that the function maps its domain "onto the entire codomain." A relation that maps its domain to only part of the codomain can be said to map "into" the codomain, but this use of the term is not standard.
def isOntoFun : Rel α β → Prop :=
isSurjectiveFun
Bijective Function
A total function is that is injective and surjective is said to be bijective. Being bijective in this sense means that (a) every input relates to exactly one output (total), (b) every input maps to a different output (inj), and (c) every output is mapped to by some input (surj).
From having a bijection between two sets one can conclude that they must be of the same size, as in this case there is a perfect matching of elements in one with elements of the other.
def isBijectiveFun :=
isTotalFun r ∧
isInjectiveFun r ∧
isSurjectiveFun r
When a relation is a function and is both injective and surjective then the relation defines a pairing of the elements of the two sets. Among other things, the existence of a bijective relationship shows that the domain and range sets are the same size.
Properties of Endorelations
A binary relation, e, with the same set/type as both its domain of definition and its co-domain is said to be a homogrneous relation, and also an endorelation. We are using the identifier, e, to refer to an arbitrary endorelation. We now define certain important properties of endorelations, in particular.
Being Reflexive
A relation is reflexive if it relates every value in the domain of definition to itself.
def isReflexiveRel := ∀ (a : α), e a a
Being Symmetric
-- The property, if (a, b) ∈ r then (b, a) ∈ r
def isSymmetricRel := ∀ (a b : α), e a b → e b a
Note that being symmetric do not imply that a relation is total. There needs to be a pair, (b, a) in the relation only if there's a pair (a, b). Question: Which of the following relations is symmetric?
- The empty relation
- { (1, 0), (1, 0), (2, 1) }
- { (1, 2), (1, 0), (1, 0), (2, 1) }
Give informal natural languages proofs in each case.
Being Transitive
def isTransitiveRel := ∀ (a b c : α), e a b → e b c → e a c
Note that transitivity doesn't require totality either. Which of the following relations are transitive?
- The empty relation
- The complete relation
- { (0, 1) }
- { (0, 1), (1, 2) }
- { (0, 1), (1, 2), (0, 2) }
- { (0, 1), (1, 2), (0, 2), (2, 0) }
Equivalence Relation
-- The property of partitioning inputs into equivalence classes
def isEquivalence :=
(isReflexiveRel e) ∧
(isSymmetricRel e) ∧
(isTransitiveRel e)
Being Asymmetric
-- The property, if (a, b) ∈ r then (b, a) ∉ r
def isAsymmetricRel :=
∀ (a b : α), e a b → ¬e b a
What a commonly used arithmetic relation that's asymmetric?
Being Antisymmetric
-- The property, if (a, b) ∈ r and (b, a) ∈ r then a = b
def isAntisymmetricRel :=
∀ (a b : α), e a b → e b a → a = b
What a commonly used arithmetic relation that's antisymmetric?
Being Strongly Connected
A relation in which every pair of values is related in at least one direction is said to be strongly connected.
def isStronglyConnectedRel := ∀ (a b : α), e a b ∨ e b a
Ordering Relations
Orderings are a crucial class of relations.
Partial Order
def isPartialOrder :=
isReflexiveRel e ∧
isAntisymmetricRel e ∧
isTransitiveRel e
Strict Partial Order
Total Order
def isTotalOrder :=
isPartialOrder e ∧
isStronglyConnectedRel e
def isLinearOrder : Rel α α → Prop := isTotalOrder
Strict Total Order
Preorder
A preorder is a relation that is Reflexive and Transitive.
Exercise: Write the formal definition and come up with a nice example of a preorder.
Well Order
Closure Operations on Endorelations
Reflexive Closure
Recall that for an endorelation, e, to be reflexive, e must relate every value in its domain of definition to itself.
The reflexive closure of such a relation e is the smallest relation that contains e (relates all of the pairs related by e) and that also relates every value in the entire domain of definition to itself.
We will give two definitions of this operation. The first is as an inductive definition, and the second as a function that takes any endorelation and returns the relation that is its reflexive closure.
Here's the inductive definition, called ReflClosure. To use it, you apply it to any endorelation, called r here, and you get back a relation with two constructors for proving that any given pair is in this relation. The first constructor assures that every pair in e qualifies as a member. The second one assures that for any a, e a a holds. That is, every pair of a value with itself is in the reflexive closure.
inductive ReflClosure {α : Type u} (r : α → α → Prop) : α → α → Prop
| base {a b : α} : r a b → ReflClosure r a b
| refl (a : α) : ReflClosure r a a
We can also define a reflexive closure function that, when applied to any endorelation returns the relation (represented as a membership predicate) that is its reflexive closure.
def reflexiveClosure' : Rel α α → Rel α α :=
fun (e : Rel α α) (a b : α) => e a b ∨ a = b
def reflexiveClosure := fun (a b : α) => e a b ∨ a = b
Symmetric Closure
Similarly, the symmetric closure of an endorelation, e, is the smallest relation that contains e and has the fewest additional pairs needed to make it symmetric. Note that if e is empty, so is its symmetric closure, whereas the reflexive closure of an empty relation having a domain of definition that is not empty is never empty.
inductive SymmetricClosure {α : Type u} (r : α → α → Prop) : α → α → Prop
| base {a b : α} : r a b → SymmetricClosure r a b
| flip {a b : α} : r b a → SymmetricClosure r a b
def symmetricClosure {α : Type u} (r : α → α → Prop) : α → α → Prop :=
fun a b => r a b ∨ r b a
Transitive Closure
inductive TransitiveClosure {α : Type u} (r : α → α → Prop) : α → α → Prop
| base {a b : α} : r a b → TransitiveClosure r a b
| step {a b c : α} : TransitiveClosure r a b → TransitiveClosure r b c → TransitiveClosure r a c
A functional form of this definition, taking a relation and returning its transitive closure, is more complicated, and not worth the time it'd take to introduce it here.
Reflexive Transitive Closure
It's often useful to deal with the reflexive and transitive closure of an endorelation, e. This requires only the addition of a refl constructor to the definition of transitive closure.
inductive ReflexiveTransitiveClosure {α : Type u} (r : α → α → Prop) : α → α → Prop
| base {a b : α} : r a b → ReflexiveTransitiveClosure r a b
| refl (a : α) : ReflexiveTransitiveClosure r a a
| step {a b c : α} :
ReflexiveTransitiveClosure r a b →
ReflexiveTransitiveClosure r b c →
ReflexiveTransitiveClosure r a c
As an interesting aside, first-order predicate logic is not expressive enough to be able to express the claim that even a specific relation is transitive, not to mention formalizing the property of being transitive generalized over all relations.
Thus ends our section on properties of relations.
end properties
Proving Properties of Relations
To prove that some relation, r, has some property P, assert and and show that there is a proof of (P r).
As an example, let's assert and prove that equality on the natural numbers is total. In other words, we claim that there's a proof of the proposition, isTotal (@Eq Nat). Recall that the @ disables the inference of implement arguments, here allowing the type, Nat, to be given explicitly.
One of the first steps in getting to a proof of such a proposition is to reduce the name of the property, such as isTotalRel, to its definition, and in Lean to its representation, as a logical predicate.
In an English language proof, you might say, "By the definition of isTotalRel, it will suffice for us to to show that ∀ x, ∃ y, r x y." You then go on to prove that more transparent form of the basic proposition at hand.
In Lean it's the same. You can unfold (expand) the definition of a term, such as isTotalRel, in a larger expression using the unfold tactic.
example : isTotalRel (@Eq Nat) :=
by
unfold isTotalRel
sorry -- Exercise!
Exercises
A Reflexive Endorelation is Necessarily Total
example : isReflexiveRel e → isTotalRel e :=
by
intro h
unfold isReflexiveRel at h
unfold isTotalRel
intro a
use a
exact (h a)
Equality is an Equivalence Relation.
To show that that equality on a type, α, (@Eq α), is an equivalence relation, we have to show that it's reflexive, symmetric, and transitive. We'll give the proof in a bottom up style, first proving each of the conjuncts, then composing them into a proof of the overall conjecture.
-- equality is reflective
theorem eqIsRefl {α : Type}: isReflexiveRel (@Eq α) :=
fun _ => rfl
-- equality is symmetric
theorem eqIsSymm : @isSymmetricRel α (@Eq α) :=
-- prove that for any a, b, if a = b ∈ r then b = a
-- use proof of a = b to rewrite a to b in b = a
-- yielding b = b, which Lean then proves using rfl
fun (a b : α) (hab : a = b) =>
by rw [hab]
-- equality is transitive
theorem eqIsTrans : @isTransitiveRel α (@Eq α) :=
-- similar to last proof
fun (a b c : α) (hab : a = b) (hbc : b = c) =>
by rw [hab, hbc]
-- equality is an equivalence relation
theorem eqIsEquiv {α β: Type}: @isEquivalence α (@Eq α) :=
-- just need to prove that Eq is refl,, symm, and trans
⟨ eqIsRefl, ⟨ eqIsSymm, eqIsTrans ⟩ ⟩ -- And.intros
The ⊆ Relation is a Partial Order
def subSetRel {α : Type} : Rel (Set α) (Set α) :=
fun (s t : Set α) => s ⊆ t
#reduce @subSetRel
-- fun {α} s t => ∀ ⦃a : α⦄, a ∈ s → t a
example {α : Type}: (@isPartialOrder (Set α) (@subSetRel α)) :=
And.intro
-- @isReflexive α β r
-- by the definition of isReflexive, show ∀ a, r a a
(fun s => -- for any set
fun a => -- for any a ∈ s
fun ains => ains -- a ∈ s
)
(
And.intro
-- @isAntisymmetric α β r
-- ∀ (a b : α), r a b → r b a → a = b
(
fun (s1 s2 : Set α)
(hab : subSetRel s1 s2)
(hba : subSetRel s2 s1) =>
(
Set.ext -- axiom: reduces s1 = s2 to bi-implication
fun _ =>
Iff.intro
(fun h => hab h)
(fun h => hba h)
)
)
-- @isTransitive α β r
-- ∀ (a b c : α), r a b → r b c → r a c
(
(
fun _ _ _ hst htv =>
(
fun _ => -- for any member of a
fun has =>
let hat := hst has
htv hat
)
)
)
)
The Inverse of an Injective Function is a Function
#check Rel.inv
example : isInjectiveFun r → isFunction (r.inv) :=
fun hinjr => -- assume r is injective
-- show inverse is a function
-- assume r output, r.inv input, elements c b a
fun c b a =>
-- we need to show that r.inv is single valued
-- assume r.inv associatss c with both b and a
fun rinvcb rinvca =>
hinjr.right b a c rinvcb rinvca
Homework
In this part of homework, we state and prove, as a theorem, the proposition that, for any natural number, n, the congruence mod n relation, on natural numbers, a and b, is an equivalence relation. We formalize these ideas by defining congModN (n : Nat) to be a family of relations, one for each value of n, each being a (different) equivalence relation on the natural numbers. That's not Lean, that's just the mathematics. The last detail, for a given n, is to specify the relation membership predicate on pairs, (a, b). Here, it will be that 1%n = b%n.
def congModN (n : Nat) : Rel Nat Nat :=
-- for a given n
fun a b => -- the binary predicate
a % n = b % n -- that defines (congModN n)
HOMEWORK PROBLEM #1: FINISH IT OFF.
To get further warmed up, let's prove a congruence for some particular n. Let's make it 3. So what we expect is that 0, 3, 6, ... will be congruent mod n (mod 3). So will be 1, 4, 7, ... And also 2, 5, 8, .... We don't have to say 3, 6, 9, as they are just elements in the equivalence class for 0. So here we go: congruence mod 3 is an equivalence relation.
Congruence Mod 3 is an Equivalence Relation
example : isEquivalence (congModN 3) :=
by
unfold isEquivalence
By the definition of equivalence, we must show
isReflexiveRel (congModN 3) ∧
isSymmetricRel (congModN 3) ∧
isTransitiveRel (congModN 3)
```lean
constructor -- applies first ∧ constructor
On ∧.left we need a prove that (congModN 3) is reflexive And on the right, that it's symmetrical and transitive
-- reflexive
unfold isReflexiveRel
intro n
unfold congModN
rfl
-- split conjunction
constructor
-- symmetric
unfold isSymmetricRel
intro a b
unfold congModN
intro h
rw [h]
-- transitive
-- EXERCISE: fill this hole.
sorry
Congruence Mod n is an Equivalence Relation
Now we state and prove that for any n, conguence mod n is an equivalence relation.
example : ∀ (n : Nat), isEquivalence (congModN n) :=
fun n =>
And.intro
(
fun a =>
rfl
)
(
And.intro
(
fun a b h =>
by
simp [congModN] at h
simp [congModN]
rw [h]
)
(
fun a b c hab hbc =>
by
simp [congModN] at hab
simp [congModN] at hbc
simp [congModN]
rw [hab]
assumption
)
)
The Subset Relation is a Partial Order
theorem subsetPO (α : Type): isPartialOrder (@subSetRel α) :=
by
unfold isPartialOrder
apply And.intro
(
by
unfold isReflexiveRel
intro s
unfold subSetRel
intro t
intro h
assumption
)
(
-- EXERCISE: Fill these holes.
And.intro
sorry
sorry
)
Well Founded Relations
end DMT1.Lectures.setsRelationsFunctions.propertiesOfRelations
Mathematical Structures
import Mathlib.Algebra.Group.Defs
THIS ENTIRE SECTION 09 STILL NEEDS EDITING. SORRY.
- Groups
- Robot Vacuum CLeaner
- Overloaded Definitions
- Rotations
- Group Structure
- AddMonoid
- Additive Semigroups
- AddSemigroup Rot
- AddZeroClass Rot
- Zero Rot
- Rest of AddZeroClass
- Scalar Multiplication of Rot by Nat
- Voila, Monoid Rot
- Additive Groups
- AddGroup Rot
- Example: A Rotation Group
- Constraints on Type Arguments
Groups
Advanced mathematics is to a considerable extent about generalizing from numbers and operations on them to a diversity of abstract objects and analogous operations on them, obeying laws that make it all valid and useful.
As an example, we could imagine a set of objects, let's call them the rotations of a clock hand. A rotation is an action that moves a clock hand from one position on the dial to another. A clockwise rotation by two hours, for example, would move a hand at 3PM to the 5PM position. A duration of two hours acts on a hand pointing tio 3PM to the 5PM position.
There's a zero rotation, which is no rotation at all. Any two rotations can be added. Addition is associative (just think about it). Every rotation has an inverse, which is rotation by the same amount in the opposite direction. And under these definitions, any rotation added to its inverse puts the hand of the clock where it started, which is just what the zero rotation does; so the sum of any rotation and its inverse rotation is zero: no overall rotation at all.
The pattern here, of a set of objects with an associative binary operator (addition in our domain), a zero element, and (additive) inverses arises in innumerable domains even though they might differ great in what the actual objects are. The generalization arises in a form that is expressly specialized to fit each particular type of object at hand. This generalization is that of a group.
Any set of objects with these properties satisfies the rules required to form what is called a group. A group is any mathematical structure having several elements, connected and governed by certain rules.
- a set of objects, sometimes called the carrier set
- an associative binary operation, such as addition
- an element designated as the zero or identity element
- a total inverse operation on elements of the set
- where the binary operation must be associative
- adding zero on the left or right is an identity opreation
- the sum of any element and its inverse is always the zero
The notion of a group is a general structure that we can impose on many different kinds of objects. The 120 degree rotations of a clock hand are the elements of a group as long as we define zero, addition, and inverse in ways that sastisfy the laws.
The rational numbers forms an additive group. The set of invertible matrices forms a multiplicative group. The general notion of a group gives us a common formal language in which to read, reason, and write about any such structure.
Moreover, in everyday algebra we have not only groups. but a large and amazing zoo of abstract mathematical structures, each having special values (e.g., zeros and ones), operations (e.g., add and vadd), axioms and relations amongst elements, notations, major theorems, and now automations.
As an example of automations, consider Lean's support for proving propositional (proof-requiring) equalities involving rings. A ring is short of a field only by multiplicative inverses. Polynomials have addition and additive inverses, as well as multiplication, but they do not have multiplicative inverses in general.
So now suppose you have as a proof goal to show that two, e.g., polynomial expressions are mathematically equal. A proof could require an involved and tedious sesquence of steps involving such mundane concepts as operator associativity, commutativity, reduction of operation applications, and so forth. By contrast, the ring tactic in Lean will assuredly reduce both sides of the equality proposition to normalized forms that will be equal if and only if the terns are mathematically equal.
TODO: Better example perhaps
The lesson is that these ad hoc generalizations provide huge intellectual and now mechanical reasoning leverage, enabling one to treat a huge range of phenomena through its mapping into such a structure. Immedaitely a wealth of knowledge applies to the case at hand. And, again, there's a massive universe of abstractions: group, ring, torsor, topological space, etc.
Robot Vacuum CLeaner
While abstract algebra sounds, well, abstract, it has immediate practical applications. Imaging we have the job to design a robot vacuum cleaner. One of the things it has to do is rotate in place before heading off in a new direction.
In this chapter we will develop an algebraic structure called a torsor, comprising rotational actions, that you can think of as being like vectors in a vector space, that act on a robot to turn it, like a clock hand, from one orientation to another.
To keep things simple, let's that there are only three orientations, indicated by the vertices of an equilateral triangle: initially pointing "north for noon." Now imagine a unit rotatation, one that rotates the robot just one step (we'll take positive rotations as counter-clockwise).
Just as before, as long as we fic the number of orientations of the clock-hand-like robot (e.g., 12 for hours or 3 for the positions of our robot ), we can now think of rotations as a group. From here out, we'll assume just three hand positions, and a unit rotation by 120 degrees. Our objects are now the possible rotations: by 0, 120, and 240 degrees, with one more taking leaving robot back in initial orientation.
When we have a set of actions operating on a set of points (here orientations). An hour is an action, like a vector: a difference. It acts on a point in time, e.g., 2PM, by turning into the next time on the dial: 0/noon if on a three position clock, or to 3PM on an ordinary 12-hour clock.
When we have a set of points (e.g., in time or geometric space), with differences between points forming actions that transform one to the other, all following certain sensible generalized rules, then one has a torsor.
In this chapter, we will thus introduce the formalization of abstract algebra, and abstract mathematics more broadly, in Lean through this example of a torsor with orientations as points, and actions as rotations by fixed amounts, essentially moving points around in a discretized circle.
What we'll have by the end of this chapter is the algebraic architecture of a (early prototype) robot that can rotate to just three fixed orientations), but not one that can move at all yet in a straight line. We'll need actions that linearly translate points. That will be the topic of the next chapter where we'll introduce a few important design patterns, aimed at easing autmoated formal reasoning about these particular structures. By the end we'll have the robot algebra for both rotation (change of direction) and linear translation.
namespace DMT1.Lecture.classes.groups
#check Zero
#check AddZeroClass
#check AddSemigroup
#check AddMonoid
Overloaded Definitions
There is a common structure to every group, but the details vary wildly from one to another. A group of rotations is not the same as a group of invertible matrices representing, say, translations in 3-D Euclidean space. Addition of rotations will be different that addition of translations.
We thus cannot use parametric polymorphism to factor out the common group structure, because that notion rests on the idea of one definition--of an operation, for example---for arguments of any type, but that clearly won't work here. What we need is a new, but weaker, notion of polymorphism: one that allows us to define the same operation (say, addition, +) for objects with entirely different structures.
The idea is known as overloading, or ad hoc polymorphism. We actually use it all the time in everyday programming. Java, for example, defines + for both int and float types, but there is not a single definition of +, Rather, the compiler looks at an expression, such sa x + y, decides what types x and y have, and then selected a particular definition (implementation) of + depending: using the floating point addition operation if they're floats, and int addition if they are ints.
The key idea with ad hoc polymorphism is that we associate different implementations of the same general structure (here just a + operation) with different types of values to be operated upon. Our plan is to generalize algebraic structures to isolate their common elements, and then overload each of the elements for different types of group elements.
In other words, for any group, we will have to overload (give group-specific implementations of) (1) the type of the group elements (e.g., rotations, tranlations, or rational numbers); (2) the definition of addition for the particular element type; (3) the definition of the zero object of that type; along with proofs that, under these definitions, all of the rules hold. As an example, we'll need proofs that the addition operator, as it is defined, is associative.
That's the big idea. We will formalize algebraic concepts as overloadable, or ad hoc polymorphic, structure types in Lean. You will now progress from foundations in basic logic, then set theory, to abstract algebra in Lean, resting on this new idea, of overloadable structure types. In Lean, as in Haskell where they were first introduced, they are called typeclasses. You must now read standard material on typeclasses in Lean. See assignment.
The types of objects for which we will overload definitions in this chapter are rotations and orientations. We will define a group structure on rotations, and what is called a torsor structure on orientations, where groups elements (rotations) act on orientation points by rotating them around a circle.
Rotations
We start by defining a rotation type and ordinary operations on this type, not yet having anything to do with a generic group structure. We start with three rotations: by 0, 120, and 240 degrees, respectively.
inductive Rot : Type where | r0 | r120 | r240
open Rot
We define an addition operation on rotations, as an ordinary function in Lean, so they add up in a way that makes physical sense. The Lean function definition just formalizes the contents of an addition table.
def addRot : Rot → Rot → Rot
| r0, r => r
| r, r0 => r
| r120, r120 => r240
| r240, r120 => r0
| r120, r240 => r0
| r240, r240 => r120
As of Right now we have no definition of a + notation for objects of this type. If you uncomment the next line of code, you will see that Lean indicates that there is no such operator.
--#eval r0 + r120
What we'd like to do now is to overload the + operator so that it will work with our Rot objects. To do this we will create an instance a typeclass in Lean. A typeclass is just an ordinary data (structure) type with one or more fields to be overloaded.
The particular typeclass we need to instantiate is Add α in Lean, where α is the type of objects for which + is to be overloaded. It has just a single field, add to which one assigns whatever function will perform addition for objects of our Rot type: and that would be our addRot function.
Here's the definition of the Add typeclass in Lean, followed by our definition of an instance of this class for our Rot type.
class Add (α : Type u) where
add : α → α → α
A class is just like a structure type in Lean, but with added typeclass machinery what we can now explain. For that, here's our instance definition.
instance : Add Rot := { add := addRot }
Our instance definition defines an instance of the Add class viewed as an ordinary structure type. But because it's a class, not just a structure, Lean registers this definition in its internal registry of typeclass instances. When a definition of addition for Rot objects is needed, Lean searches its database for an instance for the Rot type (or for instances from which such an instance can be synthesized). If no instance with a suitable definition of + can be found, Lean issues an error; otherwise it will use the definition of + stored as the value of the add field in that instance.
An important rule of thumb when using Lean is that one should ever have only one instance of a particular typeclass. Suppose you had two instances of Add Rot. You would then have two different definitions of add for Rot. Even if they are assigned the same value, e.g., addRot, Lean will not know they are the same without a proof of that. When you are using typeclasses and proofs are failing mysteriously, look to the possibility that you've got multiple instances.
Now, the Add typeclass provides an overloaded definition of add for Rot objects. Under the hood, Lean actually instantiates a second typeclass, HAdd, for heterogeneous addition (addition of possibly different types of objects). It's this class that actually provides the definition of the + notation* for the Rot type.
With all of this, we can not only add rotations using the orginal function, addRot r0 r120, but using the add field of the instance of the Add Rot typeclass that Lean fetches when we write Add.add r0 r120. Moreover, we can now also write r0 + r120. Lean will determine that the + is HAdd.hadd, defined in turn to be just Add.add, defined finally to be addRot.
#eval addRot r0 r120 -- no typeclass
#eval Add.add r0 r120 -- Finds Add instance for Rot uses Add.add
#eval r0 + r120 -- Same thing w/ notation from HAdd class
Group Structure
To provide Rot with the structure of a group under an addition operator, we overload the AddGroup typeclass. Whereas Add had just one field, the AddGroup class has several. When we look to its definition, we run into a second Lean concept that's new to us: that one structure (including typeclasses) can extend another.
class AddGroup (A : Type u) extends SubNegMonoid A where
protected neg_add_cancel : ∀ a : A, -a + a = 0
This definition explains that an AddGroup instance will contain all of the fields of the SubNegMonoid structure, plus one new field, called neg_add_cancel, holding a proof that, however all the operations are defined, it will be the case that if you add a group element and its (additive) inverse, the result will always be the zero element of the group. To instantiate an AddGroup class we have to provide values for all of these fields.
One way to see what fields need to be provided is to start an instance definition and then use Lean's error reporting to see what's missing. We want to define our rotations as a group, so let's see what we need.
Uncomment the following line then hover over the red underlined curly brace.
-- instance : AddGroup Rot :=
-- {
-- }
Lean will tell you that you'll need to provide values for all of the following fields.
- 'add_assoc'
- 'zero'
- 'zero_add'
- 'add_zero'
- 'nsmul'
- 'neg',
- 'zsmul',
- 'neg_add_cancel'
But what about the addition operator? Shouldn't the group typeclass have a field where you define the addition operator? Yes, a group class does need to, and does, have a field called add. So why isn't Lean asking it, for add? That's because you have already registered an instance of Add.add for Rot, and typeclass inference found it and used the definition it provides to populate the add field. If you had defined several other typeclasses, all the fields but that last one would already be defined.
There are two development paths you can take here. One is to follow the `extends hierarchy and define and register instances, bottom-up. By the time you get back here to AddGroup, instances will be found that provide definitions for all of the needed fields but for the one new field: neg_add_cancel.
TODO: A diagram of the inheritance structure is meant to go here.
-- ```mermaid -- classDiagram -- class Add { -- add : α → α → α -- } -- class Zero { -- zero : α -- } -- class Neg { -- neg : α → α -- } -- class Sub { -- sub : α → α → α -- }
-- class AddSemigroup { -- add_assoc : ∀ (a b c : G), (a + b) + c = a + (b + c) -- } -- class AddCommMagma { -- add_comm : ∀ (a b : G), a + b = b + a -- } -- class AddCommSemigroup { -- }
-- class AddZeroClass { -- zero_add : ∀ (a : M), 0 + a = a -- add_zero : ∀ (a : M), a + 0 = a -- } -- class AddMonoid { -- nsmul : ℕ → M → M -- nsmul_zero : ∀ (x : M), nsmul 0 x = 0 -- nsmul_succ : ∀ (n : ℕ) (x : M), nsmul (n+1) x = nsmul n x + x -- } -- class AddCommMonoid
-- class SubNegMonoid { -- sub_eq_add_neg : ∀ (a b : G), a - b = a + -b -- zsmul : ℤ → G → G -- zsmul_zero' : ∀ (a : G), zsmul 0 a = 0 -- zsmul_succ' : ∀ (n : ℕ) (a : G), zsmul (n.succ) a = zsmul n a + a -- zsmul_neg' : ∀ (n : ℕ) (a : G), zsmul (-n.succ) a = -(zsmul (n.succ) a) -- } -- class AddGroup { -- neg_add_cancel : ∀ (a : A), -a + a = 0 -- } -- class AddCommGroup
-- Add <|-- AddSemigroup -- Add <|-- AddCommMagma -- AddSemigroup <|-- AddCommSemigroup -- AddCommMagma <|-- AddCommSemigroup
-- Zero <|-- AddZeroClass -- Add <|-- AddZeroClass
-- AddSemigroup <|-- AddMonoid -- AddZeroClass <|-- AddMonoid
-- AddMonoid <|-- AddCommMonoid -- AddCommSemigroup <|-- AddCommMonoid
-- AddMonoid <|-- SubNegMonoid -- Neg <|-- SubNegMonoid -- Sub <|-- SubNegMonoid
-- SubNegMonoid <|-- AddGroup
-- AddGroup <|-- AddCommGroup -- AddCommMonoid <|-- AddCommGroup -- AddCommMonoid <|-- AddCommGroup -- ```
With that defined we have a group structure on Rot, and we can use all of the concepts, intuitions, notations, and results common to all groups, to work with objects of particular interest to us: here just our rotations. There's a zero rotation, associative rotation addition (+, from the integers), additive inverse (negation), and consequently, via induction, both natural number and integer scalar multiplication.
Alternatively, you can just give values to all of the remaining fields right here without defining or instantiating any of the dependencies. The downside is that Lean will not infer definitions of those classes a definition or instance of, here, the AddGroup class. If you should ever need such an instance elsewhere in your system, it might be be a good idea to define it, and its dependencies, explicitly and register instances for all of them.
Definition and instantiation of the full tree of typeclasses maximally enables the typeclass inference system to synthesize a broad variety instances as might be needed downstream. In this section we will thus take that route. We'll start by diagramming the tree and annotating the nodes with the definitions provided at each one.
AddMonoid
class AddMonoid (M : Type u) extends AddSemigroup M, AddZeroClass M where
protected nsmul : ℕ → M → M
protected nsmul_zero : ∀ x, nsmul 0 x = 0 := by intros; rfl
protected nsmul_succ : ∀ (n : ℕ) (x), nsmul (n + 1) x = nsmul n x + x := by intros; rfl
#check AddGroup
#check AddSemigroup
#check SubNegMonoid
Additive Semigroups
class AddSemigroup (G : Type u) extends Add G where
protected add_assoc : ∀ a b c : G, a + b + c = a + (b + c)
Rotation Addition is Associative
We will prove that rotation addition is associative as a separate theorem now, and then will simply plug that in to our new typeclass instance as the proof.
theorem rotAddAssoc : ∀ (a b c : Rot), a + b + c = a + (b + c) :=
by
intro a b c
cases a
-- a = ro
{
cases b
-- b = r0
{
sorry
}
-- b = r120
{
sorry
}
-- b = 240
{
sorry
}
}
-- a = r120
{
sorry
}
-- a = r240
{
sorry
}
AddSemigroup Rot
Now we can augment the Rot type with the structure of a mathematical semigroup. All that means, again, is that (1) there is an addition operation, and (2) it's associative.
instance : AddSemigroup Rot :=
{
add_assoc := rotAddAssoc
}
Next, on our path to augmenting the Rot type with the structure of an additive monoid, we also need to have AddZeroClass for Rot.
This class will add the structure that overloads a zero value and requires it to behave as both a left and right identity (zero) for +.
#check AddZeroClass
AddZeroClass Rot
Here's the AddZeroClass typeclass. It in turn requires Zero and Add for Rot.
class AddZeroClass (M : Type u) extends Zero M, Add M where
protected zero_add : ∀ a : M, 0 + a = a
protected add_zero : ∀ a : M, a + 0 = a
It inherits from (extends) Zero and Add. Here's the Zero class. It just defines a value to be known as zero, and denoted as 0.
Zero Rot
class Zero (α : Type u) where
zero : α
#check Zero
instance : Zero Rot := { zero := r0 }
#reduce (0 : Rot) -- 0 now notation for r0
Rest of AddZeroClass
instance : AddZeroClass Rot :=
{
zero_add :=
by
intro a
cases a
repeat rfl
add_zero :=
by
intro a
cases a
repeat rfl
}
Scalar Multiplication of Rot by Nat
We're almost prepared to add the structure of a monoid on Rot. For that, we'll need to implement a natural number scalar multiplication operator for Rot.
def nsmulRot : Nat → Rot → Rot
| 0, _ => 0 -- Note use of 0 as notation for r0
| (n' + 1), r => nsmulRot n' r + r
Voila, Monoid Rot
And voila, we add the structure of an additive monoid to objects of type Rot: an AddMonoid α. Fortunately, Lean provides default implementation that we \use, implemeting natural number scalar multiplication as n-iterated addition.
instance : AddMonoid Rot :=
{
-- Mathlib: Multiplication by a natural number.
-- Set this to nsmulRec unless Module diamonds
-- are possible.
nsmul := nsmulRot
}
Note that the nsmul_zero and nsmul_succ fields have default values, proving (if possible) that nsmul behaves properly. Namely, scalar multiplication by the natural number, 0, returns the 0 rotation, and that scalar multiplication is just iterated addition.
With this complete definition AddMonoid for Rot, we have gained a scalar multiplication by natural number operation, with concrete notation, • (enter as \smul).
The result is an algebraic structure with Rot as the carrier group, and with addition (+), zero (0), and scalar multiplication (•) operations.
#eval (0 : Rot) -- zero
#eval r120 + 0 -- addition
#eval! 2 • r240 -- scalar mult by ℕ
Additive Groups
#check AddGroup
AddGroup Rot
class AddGroup (A : Type u) extends SubNegMonoid A where
protected neg_add_cancel : ∀ a : A, -a + a = 0
To impose a group structure on Rot, we will need first to impose the structure of a SubNegMonoid and then add a proof that -a + a is 0 for any a. Instantiating SubNegMonoid in turn will require AddMonoid (which we already have), Neg, and Sub instances to be defined, as seen next.
#check SubNegMonoid
Note that most fields of this structure have default values.
class SubNegMonoid (G : Type u) extends AddMonoid G, Neg G, Sub G where
protected sub := SubNegMonoid.sub'
protected sub_eq_add_neg : ∀ a b : G, a - b = a + -b := by intros; rfl
/-- Multiplication by an integer.
Set this to `zsmulRec` unless `Module` diamonds are possible. -/
protected zsmul : ℤ → G → G
protected zsmul_zero' : ∀ a : G, zsmul 0 a = 0 := by intros; rfl
protected zsmul_succ' (n : ℕ) (a : G) :
zsmul n.succ a = zsmul n a + a := by
intros; rfl
protected zsmul_neg' (n : ℕ) (a : G) : zsmul (Int.negSucc n) a = -zsmul n.succ a := by
intros; rfl
-- EXERCISE: Instantiate SubNegMonoid, thus also Neg and Sub
class Neg (α : Type u) where
neg : α → α
def negRot : Rot → Rot
| 0 => 0
| r120 => r240
| r240 => r120
instance : Neg Rot :=
{
neg := negRot
}
class Sub (α : Type u) where
sub : α → α → α
instance : Sub Rot :=
{
sub := λ a b => a + -b
}
instance : SubNegMonoid Rot :=
{
zsmul := zsmulRec
}
-- EXERCISE: Instantiate AddGroup
theorem rotNegAddCancel : ∀ r : Rot, -r + r = 0 :=
by
intro r
cases r
rfl
rfl
rfl
instance : AddGroup Rot :=
{
neg_add_cancel := rotNegAddCancel
}
Example: A Rotation Group
We have succeeded in establishing that the rotational symmetries of an equilateral triangle for an additive group. Th
def aRot := r120 -- rotations
def zeroRot := r0 -- zero element
def aRotInv := -r120 -- inverse
def aRotPlus := r120 + r240 -- addition
def aRotMinus := r120 - r240
def aRotInvTimesNat := 2 • r120 -- scalar mul by ℕ
def aRotInvTimeInt := -2 • r120 -- scalar mul by ℤ
Question: Can we define scalar multiplication by reals or rationals?
Constraints on Type Arguments
-- uncomment to see error
-- def myAdd {α : Type u} : α → α → α
-- | a1, a2 => a1 + a2
def myAdd {α : Type u} [Add α] : α → α → α
| a, b => a + b
By requiring that there be a typeclass instance for α in myAdd we've constrained the function to take and accept only those type for which this is the case. We could read this definition as saying, "Let myAdd by a function polymorphic in any type for which + is defined, such that myAdd a b = a + b."
What we have thus defined is a polymorphic function, but one that applies only to values of type for which some additional information is defined, here, how to add elements of a given type.
#eval myAdd 1 2 -- Nat
#eval myAdd r120 r240 -- Rot
We cannot use myAdd at the moment to add strings, because there's no definition of the Add typeclass for the String type.
-- uncomment to see error
-- #eval myAdd "Hello, " "Lean" -- String (nope)
That problem can be fixed by creating an instance of Add for the String type, where we use String.append to implement add (+). With that we can now apply myAdd to String arguments as well. We have to define a special case Add typeclass instance for each type we want to be addable using Add.add, namely +.
instance : Add String :=
{
add := String.append
}
#eval myAdd "Hello, " "Lean"
end DMT1.Lecture.classes.groups
import Mathlib.Algebra.Group.Defs
import Mathlib.Algebra.Group.Action.Defs
import DMT1.Lectures.L09_algebra.C01_groups
namespace DMT1.Lecture.classes.groupActions
open DMT1.Lecture.classes.groups
Group Actions
Mathematicians often think of the elements of a group as constituting actions, that act/operate on objects, or points, to transform them into other objects/points. For example, we can now view rotations as actions on points corresponding to the three possible robot orientations.
Suppose, for example, that we have a robot vacuum cleaner in the shape of an equilateral triangle, that is capable of rotating but only in multiples of 120 degrees. It can take only three possible orientations, in each of which the shadow underneath it the is exactly the same as in any of the other orientations. Mathematically, we're talking about the rotational symmetries of an equilateral triangle. The natural numbers mod three is another good model for the set of rotations.
We will call this kind of robot a tribot. The state of a tribot is its orientation: o0 for the orientation 0 degrees from its start state; o120 for the orientation 120 degrees rotated from that state (counterclockwise); and o240 is the orientation point 240 degrees from the initial point.
Orientations
inductive Tri where
| o0
| o120
| o240
open Tri
Just as we can have additive and multiplicative groups (depending on whether the operator acts like + or like *), we can have additive and multiplicative group actions. We will treat rotations as additive actions. Actions add up to new actions. Moreover, actions operate additively on orientations in our system. In effect we can add a rotation to a robot in one orientation to make it turn to a new orientation, different from the original orientation by exactly that action. If r is a rotation and t is a robot, we will now write r +ᵥ t to express the concept of the additive action of r on t, adding to its orientation by exactly the amount of that action.
Rotations
def vaddRotTri : Rot → Tri → Tri
| 0, t => t
| Rot.r120, o0 => o120
| Rot.r120, o120 => o240
| Rot.r120, o240 => o0
| Rot.r240, o0 => o240
| Rot.r240, o120 => o0
| Rot.r240, o240 => o120
theorem vaddZero: ∀ p : Tri, vaddRotTri (0 : Rot) p = p :=
by
intro t
cases t
repeat rfl
theorem vAddSum: ∀ (g₁ g₂ : Rot) (p : Tri), vaddRotTri (g₁ + g₂) p = vaddRotTri g₁ (vaddRotTri g₂ p) :=
by
intro g₁ g₂ p
cases g₁
cases g₂
cases p
rfl
repeat sorry
#check VAdd
/-
class VAdd (G : Type u) (P : Type v) where
/-- `a +ᵥ b` computes the sum of `a` and `b`. The meaning of this notation is type-dependent,
but it is intended to be used for left actions. -/
vadd : G → P → P
-/
instance : VAdd Rot Tri :=
{
vadd := vaddRotTri
}
class AddAction (G : Type*) (P : Type*) [AddMonoid G] extends VAdd G P where
/-- Zero is a neutral element for `+ᵥ` -/
protected zero_vadd : ∀ p : P, (0 : G) +ᵥ p = p
/-- Associativity of `+` and `+ᵥ` -/
add_vadd : ∀ (g₁ g₂ : G) (p : P), (g₁ + g₂) +ᵥ p = g₁ +ᵥ g₂ +ᵥ p
instance : AddAction Rot Tri :=
{
zero_vadd := vaddZero
add_vadd := vAddSum
}
#eval Rot.r120 +ᵥ Tri.o0 -- o120
#eval Rot.r120 +ᵥ (Rot.r120 +ᵥ Tri.o0) -- o240
#eval (Rot.r120 + Rot.r120) +ᵥ Tri.o0 -- 0240
Group actions must have this property that you can add them up in the group (+) then apply them once (+ᵥ) rather than applying each one in turn using +ᵥ. That's a great way to optimize batter power usage in a floor-vacuuming robot.
end DMT1.Lecture.classes.groupActions
import DMT1.Lectures.L09_algebra.C02_groupActions
import Mathlib.Algebra.AddTorsor.Defs
Torsors Over Groups
NB: This chapter is currently formatted as a homework assignment, with many blanks left to fill in.
namespace DMT1.Lecture.classes.torsors
open DMT1.Lecture.classes.groupActions
open DMT1.Lecture.classes.groups
We've seen how group elements can act on objects. Let's now consider a special case, where vectors in a vector space are actions, and where they act on points in a linear space by displacing (rather than, say, rotating) them.
#check AddTorsor
Paraphrasing the documentation for AddTorsor in
Lean's mathlib we find that an AddTorsor G P
gives a structure to a nonempty type P
, acted on
by an AddGroup G
with a transitive and free action
given by the +ᵥ
operation and ... where subtraction
of points (torsor elements), yielding group actions,
is given by the -ᵥ
operation. In the case where G is
a vector space, a torsor becomes an affine space.
class AddTorsor (G : outParam Type*) (P : Type*) [AddGroup G] extends AddAction G P,
VSub G P where
[nonempty : Nonempty P]
/-- Torsor subtraction and addition with the same element cancels out. -/
vsub_vadd' : ∀ p₁ p₂ : P, (p₁ -ᵥ p₂ : G) +ᵥ p₂ = p₁
/-- Torsor addition and subtraction with the same element cancels out. -/
vadd_vsub' : ∀ (g : G) (p : P), (g +ᵥ p) -ᵥ p = g
Let's take that apart and see what we're missing. We have G being our rotation group. We have P being our tribot and its possible orientations. We already have defined an AddGroup structure on G, and we've already defined AddAction, (+ᵥ) as the action of a rotation on an orientation. What we're missing is an operation for subtracting one point from another yielding the group action that gets you from the first to the second, and we are missing a proof that our Tri type is non-empty. So what we must instantiate are NonEmpty P as well as VSub G P typeclass. Then we'll have the pieces we need to instantiate Torsor Rot Tri.
To complete the typeclass instance, we'll need proofs that the torsor laws are followed. There are two. The first says that if you subtract two points, the action that results, when applied to the first point takes you to the second point. The first says that the action, (p₁ -ᵥ p₂), when applied to p₂ takes you to p_₁.
To visualize p₁ - p₂ select two points in a plane. Now put an arrowhead pointing at p₁ then draw the line ending at that arrowhead to p₂. So p₂ will be the starting point. The arrow indicates the action that then translates the point p₂ to p₁. Thus adding that arrow (applying that action), to *p₂ will yield p₁.
The second torsor law says that if you have an action g act on a point p yielding a new point (g +ᵥ p), then if from that new point you subtract the original point, p, the result is just exactly the action that got you from p to g +ᵥ p. The algebra makes sense.
-- EXERCISE: Define point-point subtraction for Rot, Tri
open Rot
def rotTriVSub : Tri → Tri → Rot
-- define p1 - p2
| _, _ => sorry
-- EXERCISE: Instantiate the VSub class for Rot and Tri
instance : VSub Rot Tri :=
sorry
-- Exercise: Instantiate NonEmpty for Tri.
class inductive Nonempty (α : Sort u) : Prop where
| intro (val : α) : Nonempty α
theorem nonemptyTri: Nonempty Tri :=
sorry
-- EXERCISE: Prove the first torsor law.
theorem law1 : ∀ p₁ p₂ : Tri, (p₁ -ᵥ p₂ : Rot) +ᵥ p₂ = p₁ :=
sorry
-- Exercise: State and prove the second law for Rot and Tri.
-- Here
-- EXERCISE: Instantiate AddTorsor for Rot and Tri
-- HERE
-- EXERCISE: Write test cases for all operations
#check AddGroup
#check AddMonoid
end DMT1.Lecture.classes.torsors
import Mathlib.Data.Rat.Defs
import Mathlib.Algebra.Module.Basic
--import Mathlib.LinearAlgebra.AffineSpace.Defs
import Mathlib.Tactic.Ring.Basic
Modules and Vector Spaces
In our work so far, we've been working with expressions involving three types of objects:
- scalars, (ℕ and ℤ so far), which scale actions
- actions, so far rotations, which are vector-like
- points, namely the three values values of type Tri
If s a scalar, a is an action, and p a point, we can write the expression (s • a) +ᵥ p, scaling the action, a, by the amount, s, with the scaled action then applied to rotate p.
We have also seen that our Rot actions form a group under Rot addition. That allows us to add up actions in the group before applying the single result to a point, rather than having to apply actions one at a time. If the point is a robot and each action applications uses energy, this capability can save a lot of battery power (and time).
Our work so far has set up a crucial pattern for the next stage in our development: we have scalar values that act multiplicatively on vector-like objects that then act additively on on point-like objects.
In this and the next chapter, we'll see how to enrich scalars, actions, and points to define affine spaces. An affine space is a set of points (a torsor) acted upon by vectors from not just a group but a module in the general case and from a vector space, which is where our main interests lie for now.
The essential upgrades from our earlier work are the following:
- The scalars change from a monoid (ℕ) or group (ℤ) to a field (ℚ)
- The actions change from a group to a module or a vector space
- The point set becomes a torsor over a vector space
Example
A nice example of an affine space is the 1-D real line, ℝ.
- The points on the line are represented by real numbers
- The vectors (differences) are represented by real numbers
- The scalars are simply the real numbers
In this section, we'll first describe, and then formally define, an affine space space. Here's what we will require:
- The scalars, Sc, form a field
- The vectors, Vc, form a module over the scalar field
- Note that a module over a field constitutes a vector space
- The vectors act additively on points by translating them
To be concrete, suppose we have two points: p1, represented by the real number 2.5, and p2 represented by real 3.0. The points on the real line form a torsor where the expression p2 -ᵥ p1 represents a vector, v, representing the directed difference between the points. We'll define v to be the vector represented by the real number, 3.0 - 2.5 = 0.5. That is, it's represented as the difference between the number representing the two points.
Already we can see fundamental benefits arising from the types we are superimposing on real numbers based on the types of the objects they represent. Notably, while we can subtract one point from another to get a vector, we cannot add two points together. That is simply not an operation supported in an affine space.
To be even more concrete, suppose we're roboticists who have to reason about time. We can do that in a type-safe way with the structure we're setting up. Suppose we represent time as the real number line. Let p1, the point in time (on that line) that we choose to represent 3 o'clock, and and p2 represent 5 o'clock. The difference, p2 -ᵥ p1 represents a duration, namely *v = p2 -ᵥ p1 = 2 hours.
On the other hand, the expression, p2 + p1, which suggests the addition of 5PM and 3PM, makes no physical sense at all. In programming a robot, if points and durations in time are both simply real numbers (not values of distinct types), then the type system will not reject this physically meaningless expression.
Next, let's get scalars into the picture. Suppose s = 2.0 is a real number. Now the expression, s • (p2 -ᵥ p1), should type check as well, and would be understood as two times the interval, p2 -ᵥ p1: 4 hours.
Clearly we are computing the real number representations of different types of objects using ordinary real number arithmetic on the underlying real number representations of these objects.
Now, finally, we can write a well typed expression for the time obtained by starting at some arbitrary point in time, say 7:30 PM, and adding this 4 hour duration. The result will be a new point in time, 11:30 PM. If we let p3 = 7.5 (7:30PM). We can express this whole computation (s • (p2 -ᵥ p1)) +ᵥ p3: two (s) times two hours (p2 -ᵥ p1) added to 7:30 (p3) and thus translating that point in time to (11:30 PM), represented by the real number, 2.0 * (5.0 - 3.0)) + 7.5 = 11.5*. That is just what we wanted.
The rest of this chapter will develop the vector space part of our plan. We'll finish the plan in the next chapter with definitions of points and a torsor of such points over the vector space defined in this chapter. To define our vector space, in turn, requires that we specify both scalar and vector types. We will take the rationals as our scalars, and will define a new type, Vc, of vectors. So here we go.
Scalars
In our work to date, scalars have been values from a monoid (Nat) or a group (Int). These are values that one can use to scale vector-like objects, such as rotations, which in turn form a group. To have an affine space, we'll need scalars to come from a field and the set of vector-like objects to form what mathematicians call a module.
A module is like a vector space but slightly less constrained, in the sense that the scalars that multiple module elements are required to come
As we've seen, we cannot compute with real numbers. Yet we need scalars (and the numbers we use to represent points and vectors) to form a field under usual addition and multiplication. A good compromise is to use the rationals, ℚ, as scalars. Like the reals the rationals form a field, but they are also computable.
However, we can do better than to hardwire the choice of ℚ as our scalar field by specifying the scalar type as a type parameter in our development. We'll call it K, and we'll add the constraint that whatever type K is, it must have the structure of a field.
That K forms a field means, roughly speaking, that it has addition and multiplication operators, it forms a group under addition and if 0 is excluded it also forms a group under multiplication, and that these operations are constrained to follow the usual distributive laws.
In Lean, we express the requirement that the scalars form a field by declaring that there must be an instance of the Field typeclass for whatever type K is defined to be.
abbrev K := ℚ
Note that if you set K to Nat (not a field) you will not get a type error at this point. The variable declaration asserts only the requirement that there be such a typeclass but Lean does not verify that that's the case at this time. Rather, it's when you try to use an operation or value provided by such a typeclass, which is when Lean tries to find an instance, that Lean will complain.
You can see this idea in action by changing K from ℚ to Nat in the preceding definition.
def invK (a : K) := a⁻¹ -- returns the multiplicative inverse of a K
#eval invK 3 -- but fails to synthesize [Field K] if K = Nat
Now remember to change K back to meaning Q and all will be well. Later on you can try changing it to ℝ.
At this point we've done everything we need to establish the rationals as a scalar field. That was easy.
"Vectors"
structure Vc : Type where
(val : K)
-- definition of vector (Vc) addition
def addVc (v1 v2 : Vc) := Vc.mk (v1.val + v2.val)
Now we want to show that the set of Vc objects forms a module. A module is just a bit less than a vector space, in that the scalars need only come from a ring, which is a generalization of (that is, slightly less constrained than) a field, in that a ring need not have multiplicative inverses.
We will show that Vc can be endowed with the added structure of a module by instantiating Lean's Module typeclass for the type, Vc.
Modules
#check Module
Here's the Module typeclass type:
Module.{u, v} (R : Type u) (M : Type v) [Semiring R] [AddCommMonoid M] : Type (max u v)Lean 4
The two steps that are required here are, first, we have to show that our scalar type, K := ℚ (the actual parameter we will use for R in the Module type definition) has the structure of a semiring (whatever that is). We do that by providing a Semiring typeclass instance for K which is to say for ℚ. Similarly we have to provide an AddCommMonoid typeclass instance for Vc (the actual parameter for M).
Lean's libraries already have proven that ℚ is a semiring by providing a typeclass instance for us. We can either write instance : Semiring K := {} or use the variable construct, as we did above, to tell Lean that we require and assume there is such a typeclass instance.
-- variable [Semiring K]
-- TODO: Fix that and above
The other typeclass instance we need is AddCommMonoid Vc. AddCommMonoid, like many typeclasses, extends (essentially inherits from) other finer-grained typeclasses, with the end result being a typeclass with multiple fields, some inherited and some added.
We could define an AddCommMonoid Vc instance by explicitly defining instances of all of the typeclasses AddCommMonoid extends. An easier way is to use Lean's error reporting to see what overall set of elements we need to define. Once you have provided required instances, write an empty AddCommMonoid Vc structure (using curly braces) and check the error message. It will tell you what fields you're missing. Next, add the fields and stub out the values using sorry. Finally provide the actual values needed. In this way you can avoid the tedium of tracking down and instantiating all of the parent typeclasses.
instance : Zero Vc := { zero := Vc.mk 0 }
instance : Add Vc := { add := addVc }
instance : AddCommMonoid Vc :=
{
add := addVc -- we had to define Vc addition
add_assoc := by -- we need a proof it's associative
intro a b c -- assume a, b, c are Vc's
Our goal now is to show a + b + c = a + (b + c). To do this, we need to show that the rational numbers representing the vectors on either side of the equals sign are the same. And to do that, we need to get at the rational numbers that are the underlying representations of these vectors.
For this, we will use the theorem, provided by Lean, called congrArg. It shows that if f is any function and a = b then f a = f b. So if you need to show that f a = f b it will suffice to show that a = b. Given two vectors (Vc), we can thus show that they're equal by showing that their rational number representations are equal. These are the arguments given to Vc.mk when the objects were defined). Finally, if what you need to prove is Vc.mk a = Vc.mk b it will suffice to apply congrArg to Vc.mk (our actual value for f) and then to prove a = b. When you study the effect of this application, you'll see that it in effect rewrites the vectors on the left and the side of the equality as applications of Vc.mk to underlying rational values. The representations of the vectors are thus exposed, and now all that is needed is to show that these rationals are equal.
apply congrArg Vc.mk
Note that the + in the expression, a + b within parentheses in the goal is Vc addition, but the + between the .val expressions is rational addition. Lean already know that that's associative, so all we have to do now is to apply Lean's general theorem (Rat.add_assoc) to this special case to finish off the proof.
apply Rat.add_assoc
zero := Vc.mk 0
zero_add := by
intro a -- assume a is any Vc
apply congrArg Vc.mk -- expose representations
simp -- simp works as it knows about Rat.add
rfl -- same Vc, written differently, on each side
add_zero := by
intro a
apply congrArg Vc.mk
simp
rfl
At this point, you really will have to go back to just before this typeclass instance definition and define typeclass instances for Zero Vc and for Add Vc. When you come back here, the error should be resolved.
nsmul := nsmulRec
add_comm := by
intro a b
apply congrArg Vc.mk
apply Rat.add_comm
}
There! With the constraints on the K and Vc types required by Module now proven, we can now take the second step and instantiate Module K Vc by filling in values for each of its fields. Hover over the error to have Lean tell you what fields are missing. Add them, initially stubbing them out with sorry. Then go back and fill in the right values and proofs. That's it. You can write the required values inline, or write definitions just before this instance declarations and then use the values inline. For example, it'd be a good idea to define smul as a standalone function. This is the operaton of multiplication (scaling) of a vector, v, by a scalar, k, still denoted as k • v.
EXERCISE
def smulKVc (k : K) (v : Vc) : Vc := Vc.mk (k * v.val)
To finish off the instance definition establishing Vc as a module over the field, K, i.e., with its scalars coming from K, we will first need to build a Semiring structure on K. That means that the usual distributive laws and the laws for multiplication by 1 and 0 hold. For example, we will have to show that ∀ (a b c : ℚ), a * (b + c) = a * b + a * c. To do this we assume a, b, c are arbitrary values of type Q, then we show that a * (b + c) = a * b + a * c.
The las part is greatly facilitated by the ring tactics, ring and ring1. Their superpower is to reduce expressions using the ring operators, + and *, to normal (fully reduced) forms. Here they do that to both the left and the right side of the goals in the following proofs, at which point ring1 sees that the two sides are equal and applies rfl.
instance : Semiring K :=
{
left_distrib := by
intro a b c
ring1
right_distrib := by
sorry
zero_mul := by
sorry
mul_zero := by
sorry
}
instance : Module K Vc :=
{
smul := smulKVc
one_smul := by
-- assume arbitrary (v : Vc)
intro v
-- expose the underlying ℚ representations
apply congrArg Vc.mk
-- simplify using rational number arithmetic
simp
mul_smul := by
-- assume x, y are scalars
intro x y
-- assume v is a vector
intro v
-- expose representations
apply congrArg Vc.mk
-- Unfold • to smulVc by giving a definitionally equal goal
-- Use the change tactic the rewrite the goal to an equal one
change x * y * v.val = x * (smulKVc y v).val
-- *Now* we can simplify the goal using the definition of smulKVc
simp [smulKVc]
-- The rest is just (rational) arithmetic for which ring1 works
ring1
-- ∀ (a : K), a • 0 = 0
smul_zero := by
-- assume arbitrary vector, a
intro a
-- rewrite • as prefix smulKVc and (0 : Vc) as Vc.mk 0
change smulKVc a (Vc.mk 0) = Vc.mk 0
-- now Lean can simplify using the definition of smulKVc
simp [smulKVc]
smul_add := by
sorry
add_smul := by
sorry
zero_smul := by
sorry
}
Vector Spaces
Hooray! We've now established that Vc forms a module under scalar multiplication by rationals. And because the rationals form not just a ring (in which case we'd still have a nice *module), but a field, we have a vector space!
def v1 : Vc := ⟨3.5⟩
def v2 : Vc := ⟨5.5⟩
def v3 := v1 + v2 -- expect ⟨9⟩
def v4 := (2.0 : K) • v3 -- expect ⟨18⟩
#eval v3 -- 9
#eval! v4 -- 18 (delete ! when sorrys are gone)
We haven't yet overloaded the inverse or subtraction operators for Vc. So the following expressions will not be accepted. Uncomment them to see the error.
-- def v5 := -v4
-- def v6 := v3 - v2
This problem is easy to fix: overload these operators
instance : Neg Vc := { neg := fun v => (-1 : K) • v}
instance : Sub Vc := { sub := fun v2 v1 => ⟨ v2.val - v1.val ⟩ }
Now it works.
def v5 := -v4
def v6 := v3 - v5
Note also that even though we've concrete represented a vector as a rational number, we can't add rations and vectors, as that's not an operation that makes any sense and we haven't defined such an operation. Uncomment the following line to see the error.
-- #eval (3/2 : ℚ) + v5 -- no heterogeneous rat + vc op
-- #eval v5 + (3/2 : ℚ) -- no heterogeneous vc + rat op
As a final step, to gain the notations provided by the AddGroup typeclass, we'll instantiate it for our Vc type. That will requireOur goal now is show proofs that our definitions satisfy a few more simple axioms. You can finish the proofs.
[EXERCISE] Replace the sorry's with valid proofs.
theorem neg_add_cancelVC : ∀ (a : Vc), -a + a = 0 :=
sorry
theorem sub_eq_add_negVc : ∀ (a b : Vc), a - b = a + -b :=
sorry
instance : AddGroup Vc :=
{
zsmul := zsmulRec
neg_add_cancel := neg_add_cancelVC
sub_eq_add_neg := sub_eq_add_negVc
}
So there we have it. An 1-dimensional rational vector space, for which we've also instantiated AddGroup which we'll need in the next chapter.
import DMT1.Lectures.L09_algebra.C04_vectorSpaces
import Mathlib.LinearAlgebra.AffineSpace.Defs
import Mathlib.LinearAlgebra.Basis.Defs
Torsors over Modules: Affine Spaces
An affine space is a *torsor (a set of points) acted on by elements of a module. A module is a set of vector-like elements (for us, actions) over a scalar ring or field. A ring is a structure with addition, additive inverses, and multiplication, but not necessarily multiplicative inverses. Without a multiplicative inverses, a ring lacks fractions, as a fraction a/b is really just (a * b⁻¹).
The Plan
Here we will define an affine space comprising a torsor over a vector space, which in turn is a module over not just any ring but over a scalar field. Moving from a scalar ring to a scalar field ensures that all fractions of actions are in the vector space.
The rationals form a field. If we take the rationals as the scalars for a module, and if we pick a module element, we can scale it by any rational (fractional) amount. This is not the case for modules having merely scalar rings.
We will now illustrate these ideas by formally specifying a 1-D rational affine space. Think of it as a real number line with points that correspond the rationals. Vectors correspond to directed differences between pairs of rationals, and thus also correspond to the rationals. One can subtract points to get vectors, and one can add vectors to points to transform them by translating them linearly.
abbrev
AffineSpace -- and affine space with
(k : Type*) -- scalars
(V : Type*) -- vectors
(P : Type*) -- points
[Ring k] -- where scalars have + and *,
[AddCommGroup V] -- vector addition commutes, and
[Module k V] := -- vectors form a *module* over k, is a
AddTorsor V P -- torsor with P points and V vectors
In Lean, an AffineSpace is just another name for an additive torsor (AddTorsor) over a module with scalars of some type, K, vector-like actions of some type V, and points of some type, P, where K is at least a ring, V is at least an additive and commutative group (as in our group of rotational actions), with point-point subtraction and vector-point addition operations, satisfies additional torsor axioms.
Formal Specification
Our 1-D affine space will be a torsor of points, conceptually on the rational number line and concretely represented by rational numbers, over a space of 1-D vectors, represented by rationals, and with scalars also being just the rationals. Our design, with each of these kinds of elements having different types, will ensure that algebraically invalid expressions, e.g., addition of points, will never pass the Lean type checker.
To being with, we need to define a point type sufficiently rich to represent all points reachable by the addition of any vector to any point. To this end it will suffice to represent our points as rational numbers, but wrapped in a new point type, here Pt.
structure Pt : Type where (val : K)
With that, we're set to proceed to define our torsor of Pt points over the 1-D rational vector space from the last section. We'll do this by instantiating the AddTorsor typeclass for our Vc and Pt types. That will require us to give definitions of the torsor operations (-ᵥ and +ᵥ), and proofs that everything we have specified satisfies the torsor axioms. Working bottom-up, we now provide these function definitions and proofs, and then the final affine space definition.
The set of points of a torsor cannot be empty.
instance : Nonempty Pt := ⟨Pt.mk 0⟩
We define a vector-point addition operation and use it to instantiate the VAdd typeclass for Vc and Pt, giving us, among other things, the +ᵥ notation.
def vaddVcPt (v : Vc) (p : Pt) := Pt.mk (v.val + p.val)
instance : VAdd Vc Pt := { vadd := vaddVcPt }
We similarly define our point-point subtraction operation and instantiate the VSub typeclass get the -ᵥ notation.
def vsubVcPt (p1 p2 : Pt) : Vc := ⟨ p1.val - p2.val ⟩
instance : VSub Vc Pt := { vsub := vsubVcPt}
Here are two tests to confirm that we now have both of these operations with corresopnding concrete notations.
#check fun (v : Vc) (p : Pt) => v +ᵥ p
#check fun p1 p2 => p1 -ᵥ p2
We need to prove that our definitions satisfy the torsor axiom requiring that adding the zero vector to any point leaves the point unchanged.
theorem zero_vaddVcPt : ∀ (p : Pt), (0 : Vc) +ᵥ p = p :=
by
intro a
apply congrArg Pt.mk
simp [vaddVcPt]
rfl
theorem add_vaddVcPt : ∀ (g₁ g₂ : Vc) (p : Pt), (g₁ + g₂) +ᵥ p = g₁ +ᵥ g₂ +ᵥ p :=
by
intro g1 g2 h
apply congrArg Pt.mk
Here we apply a generalized theorem from Lean's libraries to finish the proof. The theorem is universally quantified and so can be treated as a function, applied to particular, to yield a proof about them. This is just ∀ elimination, also known as universal specialization.
apply Rat.add_assoc
-- [EXERCISE: Finish the following proofs.]
theorem vsub_vadd'VcPt : ∀ (p₁ p₂ : Pt), (p₁ -ᵥ p₂) +ᵥ p₂ = p₁ :=
sorry
theorem vadd_vsub'VcPt : ∀ (g : Vc) (p : Pt), (g +ᵥ p) -ᵥ p = g :=
sorry
open Affine
instance : AffineSpace Vc Pt :=
{
zero_vadd := zero_vaddVcPt
add_vadd := add_vaddVcPt
vsub := vsubVcPt
vsub_vadd' := vsub_vadd'VcPt
vadd_vsub' := vadd_vsub'VcPt
}
Test and Demonstration
And that's it. We now have a 1-D rational affine space. Here are examples of expressions that we can now write, with comments connecting the mathematics to a physics scenario where points represent points in time and vectors represent intervals between times. Points in time can be subtracted to produce intervals; intervals can be scaled by any rationals; and intervals can be added to points in time yielding new points in time.
def p1 : Pt := ⟨ 7/2 ⟩ -- 3:30 PM
def p2 : Pt := ⟨ 11/2 ⟩ -- 5:30 PM
def d1 := p2 -ᵥ p1 -- 2 hours
def d2 := ((2 : ℚ) • d1) -- 4 hours
def p3 := d2 +ᵥ p1 -- 7:30 PM
#eval! p3 -- 15/2 (7:30)
The eval command won't work here until all relevant sorry terms are resolved. The ! tells Lean to go ahead and try anyway, which it does sucessfully. You may, if you wish, remove the ! once everything else is working.
Definitional vs. Propositional Equality
We can see in the preceding example, using eval, that our code is computing the right value for p3, namely ⟨ 15 / 2 ⟩. We thus might expect that the proposition, p3 = ⟨15/2⟩ would easily be proven by rfl. Yet as the following example shows (uncomment the code) rfl doesn't work in this case.
-- rfl doesn't work!
-- example : p3 = ⟨ 15/2 ⟩ := rfl
We thus arrive at a significant complexity in type theory: the distinction between definitional and propositional equality propositions. Definitional equalities are provable by Eq.refl, and any such proof works by expanding definitions and applying functions before deciding whether the terms on both sides of the = are exactly the same term.
As an example, we can prove 2 + 3 = 5 because Nat addition is a recursively defined function that can just be applied to 2 and 3 to reduce the term 2 + 3 to 5. We'd say that 2 + 3 is definitionally equal to 5.
Now consider the proposition, involving rationals: 4/2 + 6/2 = 10/2. Uncommenting the next example will show that it is not provable using rfl.
-- uncomment this example to see the error
-- example: (4/2 : ℚ) + 6/2 = 10/2 := rfl
What the error message is saying is that Lean is not able to reduce the expression on the left to be the same as the one on the right. The problem is that, unlike purely computable natural numbers in Lean, rational numbers include proof terms.
Here are the fields in a structure representing a rational number in Lean. There's a possible negative (integer) numerator and a natural number denominator. So far, so good. But such a structure also includes two proof terms.
num : ℤ
den : ℕ
den_pos : 0 < den
red : Nat.coprime num.natAbs den
The first proof term ensures that a denominator can never be zero. The second ensures that the numerator and denominator are co-prime: represented in lowest (reduced) terms. For example, 3/6 is represented as 1/2 internally with a proof that 2 ≠ 0 (in the rationals) and a proof showing that 1 and 2 have no common factors and so can not be further reduced.
The upshot of all this is that even simple operations on rationals, such as addition, must not one compute new numerator and denominator values, but also involves constructing proofs that the result satisfies the two extra logical requirements. That is not something that can be done automatically in general. There is a lot more to it than desugaring notations, expanding definitions, computing function values, etc,
What you have to do instead is to prove propositional equalities on your own. Here we give a proof that p3 is indeed equal to ⟨ 15/2 ⟩.
This proof has two basic parts. The first part, here using unfold and simp, unfolds definitions and simplifies function application expressions using simp. The crucial objective here is to eliminate notations, typeclass field applications, and other such elements to express the goal as a pure rational number computation. The second part uses the valuable tactic, norm_num, to automate the arithmetic computation, including the construction of the proof terms required to assemble the final rational number term.
example : p3 = ⟨ 15/2 ⟩ := by
-- manually reduce goal to pure rational number arithmetic
unfold p3
unfold d2 p1
unfold d1
unfold p1 p2
simp [VSub.vsub]
simp [vsubVcPt]
simp [HSMul.hSMul]
simp [SMul.smul]
simp [smulKVc]
simp [HVAdd.hVAdd]
simp [VAdd.vadd]
simp [vaddVcPt]
-- use the norm_num tactic to finish off the proof
norm_num
As a final comment, you've now seen the change tactic. It let's you change the current goal in proof construction to a different one but only if the two are definitionally equal. If Lean can confirm that, the change is accepted. If if cannot confirm that, e.g., because doing so would require proof construction, then the change will not be allowed, even if it's a perfectly mathematically valid rewriting of the goal.
--
import Mathlib.Data.Rat.Defs
import Mathlib.Data.Fin.Tuple.Basic
import Mathlib.Data.Fin.Basic
import Mathlib.Data.Finset.Basic
import Mathlib.Algebra.Group.Defs
import Mathlib.Algebra.Module.Basic
import Mathlib.Data.Finset.Card
import Init.Data.Repr
import Mathlib.LinearAlgebra.AffineSpace.Defs
Finitely Multi-Dimensional Affine Spaces
One dimensional affine spaces are nice for modeling physical phenomena that, at least in idealized forms, proceeds linearly. Classical notions of time can be like this.
Sometimes, though, one-dimensionality is limiting. We'd thus consider generalizing from 1-D affine spaces to n-D affine spaces. In parrticular, we might use a 2-D affine space to represent the geometry of the planar floor on which an imaginary robot endeavors tireless to pick up dust and debris. A robot that can only move back and forth on a line isn't so good at finding all the crumbs.
Overview
The main driver of change, starting from what we developed in the 1-D case to our parameterized design, will be the need to change from representating a 1-D point as a single rational number, to an n-D representation. For that, we will choose n-long ordered sequences of rationals.
It's a good idea not to think of these rational values as coordinates. They serve to distinguish one point in an affine space from any different point, and to ensure that there are enough points so that all of the now familiar affine space axioms can satisfied.
Concretely we have to construct instances of the now familiar affine space related typeclasses, but for our new n-D representation instead of for the current 1-D representation.
Finite Index Sets: Fin n
In mathematics, tuple is a common name for an ordered sequence of values. A tuple with n values is said to be an n-tuple. If the values in the tuple are of some type, α, one could say it's an α n-tuple (e.g., a real 3-tuple).
There are several ways to represent tuples in Lean. For example, one could represent the set of, say, natural number 3-tuples, as the type of lists of natural numbers that can be pairs with proofs that their lengths are 3. In Lean, this type is called Vector. The Vector type builder is parameterized by both the elemennt type (a type) and the length of the sequence (a value of type Nat). This type is a standard example of a dependent type.
Another approach, that we will adopt here, is to represent an α n-tuple as a function from the finite set of n natural numbers ranging from 0 to n-1, to α values.
Now the question is how to represent such a finite set of α values so that its values can serve as arguments to that order-imposing indexing function.
Lean provides the Fin n type for this purpose. It's values are all of the natural numbers from 0 to n-1. If you try to assign a larger natural number to an identifier of this type, the value will be reduced mod n to a value in the designated range of index values.
#eval (0 : Fin 3)
#eval (1 : Fin 3)
#eval (2 : Fin 3)
#eval (3 : Fin 3)
#eval (4 : Fin 3)
Tuples: Fin n → α
We can represent an α n-tuple as a function, t, taking an index, i, of type Fin n, and returning t i.
For example, we'd represent the tuple, t = (1/2, 1/4, 1/6) as the following function.
def aFinTuple : Fin 3 → ℚ
| 0 => 1/2
| 1 => 1/4
| 2 => 1/6
One then expresses index lookups in tuples using function applicatio.
#eval aFinTuple 0
#eval aFinTuple 1
#eval aFinTuple 2
A value of type Fin n is actually a structure with two fields: a value, and a proof it satisfies that it is between 0 and n-1 (expressed as val < n). When pattern matching on a value of this type, match on both arguments. One if often interested in just the value. The following example is a function that takes a value of type Fin n and returns just the value part of it.
def getFinVal {n : Nat} : Fin n → Nat
| ⟨ val, _ ⟩ => val
#eval (getFinVal (7 : Fin 5)) -- Expect 7
Overloads
-- For Lean to pretty print tuples, e.g., as #eval outputs
instance [Repr α] : Repr (Fin n → α) where
reprPrec t _ := repr (List.ofFn t)
-- A coercion to extract the (Fin n → α) representation
-- Element-wise tuple addition; depends on coercion
instance [Add α] : Add (Fin n → α) where
add x y := fun i => x i + y i
-- -- Element-wise heterogeneous addition
-- instance [HAdd α α α] : HAdd (Fin n → α) (Fin n → α) (Fin n → α) :=
-- { hAdd x y := fun i => x i + y i }
-- Element-wise multiplication
instance [Mul α] : Mul (Fin n → α) where
mul x y := fun i => x i * y i
-- Element-wise negation
instance [Neg α] : Neg (Fin n → α) where
neg x := fun i => - x i
-- TODO: Overload Subtraction for this type
-- Pointwise scalar multiplication for tuples
instance [SMul R α] : SMul R (Fin n → α) where
smul r x := fun i => r • x i
instance [Zero α]: Zero (Fin n → α) := ⟨ fun _ => 0 ⟩
#check (0 : Fin 3 → Nat)
Now we turn to equality. We'll provide two definitions, the first (Eq) logical, the second (BEq) computational returning Bool. Here's the logical equality predicate.
def eqFinTuple {α : Type u} {n : Nat} (a b : Fin n → α) : Prop :=
∀ (i : Fin n), a i = b i
Here is an algorithm that actually decides equality, returning a Boolean. Note that to decide whether two tuples, represented as Fin n → α, are elementwise equal, requires that we can decide if individual elements are equal. So this function also requires a Boolean equality function on individual α values, provided by a required implicit instance of the BEq α typeclass. Assuming that there is an instance enables us to use == notation at the level tuple of individual elements.
def eqFinTupleBool {α : Type u} {n : Nat} [BEq α] (a b : Fin n → α) : Bool :=
(List.finRange n).all (λ i => a i == b i)
With that algorithm defined, we can now overload the BEq operator for (Fin n → α) objects. Among other things, this will give us the == notation for Boolean equality testing on Fin-based tuples. A precondition for using this operator is that the individual elements can be compared for equality in the same way.
instance {α : Type} {n : Nat} [BEq α] : BEq (Fin n → α) :=
{ beq := eqFinTupleBool }
The DecidableEq typeclass overloads =. This is useful when using if (a = b) then ... else in coding. The (a = b) would ordinarily be a proposition but the result here will be Bool. This typeclass also enables the decide tactic, which you can use to determine the truth of equality propositions.
instance [DecidableEq α] : DecidableEq (Fin n → α) :=
fun t1 t2 =>
if h : ∀ i, t1 i = t2 i then
isTrue (funext h)
else
isFalse (λ H => h (congrFun H))
Examples
Now we can add, negate, subtract, and pointwise multiply tuples, scale them using scalar multiplication, and decide if two of them are equal, using all of the nice notations, and other results, that come with the respective typeclasses.
#eval aFinTuple == aFinTuple -- (from BEq) expect true
#eval aFinTuple = aFinTuple -- (from DecidableEq) expext true
#eval aFinTuple == (2 • aFinTuple) -- expect false
#eval aFinTuple = (2 • aFinTuple) -- expect false
#eval aFinTuple * aFinTuple -- pointwise *
Tuples: Tuple α n
We'll wrap tuples represented by values of (Fin n → α) in a new Tuple type, parametric in α and n. With this type in hand, we will then define a range of operations on tuples, mostly by just lifting operations from the (Fin n → α) type.
Note! Having defined decidable equality and other typeclass instances for Fin n → α, Lean can now automaticallysynthesize the corresponding typeclasses instances for our Tuple type!
Data Type
structure Tuple (α : Type u) (n : ℕ) where
toFun : Fin n → α
deriving Repr, DecidableEq, BEq -- Look here!
Overloads
We define an automatically applied coercion of any Tuple to its underlying (Fin n → α) function value.
instance : CoeFun (Tuple α n) (fun _ => Fin n → α) := ⟨Tuple.toFun⟩
-- -- Element-wise heterogeneous addition
-- instance [HAdd α α α] : HAdd (Tuple α n) (Tuple α n) (Tuple α n) :=
-- { hAdd x y := ⟨ x + y ⟩ } -- the "+" is for *Fin n → α*
-- Element-wise tuple addition; depends on coercion
instance [Add α] : Add (Tuple α n) where
add x y := ⟨ x + y ⟩
-- Element-wise multiplication
instance [Mul α] : Mul (Tuple α n) where
mul x y := ⟨ x * y ⟩
-- Element-wise negation
instance [Neg α] : Neg (Tuple α n) where
neg x := ⟨ -x ⟩
-- TODO (EXERCISE): Overload Subtraction for this type
-- Pointwise scalar multiplication for tuples
instance [SMul R α] : SMul R (Tuple α n) where
smul r x := ⟨ r • x ⟩
instance [Zero α]: Zero (Tuple α n) :=
{ zero := ⟨ 0 ⟩ }
Example
-- Example
def myTuple : Tuple ℚ 3 := ⟨ aFinTuple ⟩
def v1 := myTuple
def v2 := 2 • v1
def v3 := v2 + 2 • v2
#eval v1 == v1
#eval v1 == v2
Vectors: Vc α n
We will now represent n-dimensional α vectors as n-tuples of α values, represented as Tuple values.
Data Type
structure Vc (α : Type u) (n: Nat) where
(tuple: Tuple α n)
deriving Repr, DecidableEq, BEq
Overloads
-- A coercion to extract the (Fin n → α) representation
instance : Coe (Vc α n) (Tuple α n) where
coe := Vc.tuple
-- -- Element-wise heterogeneous addition; note Lean introducing types
-- instance [HAdd α α α] : HAdd (Vc α n) (Vc α n) (Vc α n) :=
-- { hAdd x y := ⟨ x.tuple + y.tuple ⟩ }
-- Element-wise tuple addition; depends on coercion
instance [Add α] : Add (Vc α n) where
add x y := ⟨ x.tuple + y.tuple ⟩
-- Element-wise multiplication
instance [Mul α] : Mul (Vc α n) where
mul x y := ⟨ x.tuple * y.tuple ⟩
-- Element-wise negation
instance [Neg α] : Neg (Vc α n) where
neg x := ⟨-x.tuple⟩
-- Pointwise scalar multiplication for tuples
instance [SMul R α] : SMul R (Vc α n) where
smul r x := ⟨ r • x.tuple ⟩
instance [Zero α]: Zero (Vc α n) :=
{ zero := ⟨ 0 ⟩ }
Example
Here's an example: the 3-D rational vector, (1/2, 1/3, 1/6) represented as an instance of the type, NVc ℚ 3.
def a3ℚVc : (Vc ℚ 3) := ⟨ myTuple ⟩
def b3ℚVc := a3ℚVc + (1/2:ℚ) • a3ℚVc
#eval a3ℚVc == a3ℚVc -- == is from BEq
#eval a3ℚVc == b3ℚVc
#eval a3ℚVc = b3ℚVc -- = is from DecidableEq
#eval a3ℚVc + b3ℚVc + b3ℚVc
Points: Pt α n
We will now represent n-dimensional α points * as n-tuples of α values in the same way.
Data Type
structure Pt (α : Type u) (n: Nat) where
(tuple: Tuple α n)
deriving Repr, DecidableEq, BEq
Overloads
We're not going to lift operation such as addition from Tuple to Pt because such operations won't make sense given the meaning we intend Pt objects to have: that they represent points in a space, which cannot be added together.
-- A coercion to extract the (Fin n → α) representation
instance : Coe (Pt α n) (Tuple α n) where
coe := Pt.tuple
α Affine n-Spaces
TODO (EXERCISE): Build an affine space structure on Vc and Pt.
-- TODO: This is what to implement
-- instance (α : Type u) (n : Nat) : AddTorsor (Vc α n) (Pt α n) := _
Starter Example
To give you a good start on the overall task, here's a completed construction showing that our Vc vectors form an additive monoid. We already have a definition of +. We'll need a proof that + is associative, so let's see that first.
theorem vcAddAssoc {α : Type u} {n : Nat} [Ring α]:
∀ (v1 v2 v3 : Vc α n), v1 + v2 + v3 = v1 + (v2 + v3) := by
-- Assume three vectors
intro v1 v2 v3
-- strip Vc and Tuple abstraction
apply congrArg Vc.mk
apply congrArg Tuple.mk
NB: We now must show equality of underlying Fin n → α functions. For this we're going to need an axiom that is new to us: the axiom of functional extensionality. What it says is if two functions produce the same outputs for all inputs then they are equal (even if expressed in very different ways). Look carefully at the goal before and after running funext.
apply funext
-- Now prove values are equal for arbitrary index values
intro i
-- This step is not necessary but gives better clarity
simp [HAdd.hAdd]
-- Finally appeal to associativity of α addition
apply add_assoc
Go read the add_assoc theorem and puzzle through how its application here finishes the proof.
With that, we're two steps (add and add_assoc) closer to showing that our n-Dimensional vectors form a Monoid (as long as α itself has the necessary properties (e.g., that the α + is associative). We ensure that by adding the precondition that α be a Ring. That will ensure that α has all of the usual arithmetic operations and proofs of properties.
instance (α : Type u) (n : Nat) [Ring α]: AddMonoid (Vc α n) :=
{
-- add is already available from the Add Vc instance.
add_assoc := vcAddAssoc -- The proof we just constructed
zero := 0 -- The Vc zero vector
zero_add := by -- ∀ (a : Vc α n), 0 + a = a
intro a
apply congrArg Vc.mk
apply congrArg Tuple.mk
funext -- The tactic version
simp [Add.add]
rfl
add_zero := by -- ∀ (a : Vc α n), a + 0 = a
intro a
apply congrArg Vc.mk
apply congrArg Tuple.mk
funext
simp [Add.add]
rfl
nsmul := nsmulRec
}
Yay. Vc forms an additive monoid.
Your Job
TODO: Continue with the main task. A precondition for forming an additive torsor is to show that Vc forms an additive group. You might want to start with that!
-- instance {α : Type u} {n : Nat} [Ring α]: AddGroup (Vc α n) :=
-- {
-- }
-- instance {α : Type u} {n : Nat} [Ring α]: AddTorsor (Vc α n) (Pt α n) :=
-- {
-- }
Mathematical Structures
import Mathlib.Data.Rat.Defs
import Mathlib.Algebra.Module.Basic
universe u
variable
{n : Nat}
{α : Type u}
namespace DMT1.Algebra.Scalar
Scalars
SMul α α
For this entire system we'll assume scalar multiplication of a scalar by another scalar is just ordinary multiplication.
#synth (SMul ℚ ℚ)
#synth (SMul ℚ (Fin _ → ℚ))
instance [Mul α] : SMul α α := { smul := Mul.mul }
theorem Vc.smul_α_def [Mul α] (a b : α) :
a • b = a * b := rfl
theorem Vc.smul_α_toRep [Mul α] (a b : α) :
a • b = a * b := rfl
end DMT1.Algebra.Scalar
import Mathlib.Data.Rat.Defs
import DMT1.Lectures.L10_algebra.scalar.scalar
namespace DMT1.Algebra.Tuples
open DMT1.Algebra.Scalar
universe u
variable
{n : Nat}
{α : Type u}
Tuple Representation: Fin n → α
A sequence is given its ordering by a function from ordered indices to the values needing order. We will repersent finite ordered α n-tuples as functions from {0, ..., n-1} index sets to α values.
Pretty printing
instance [Repr α] : Repr (Fin n → α) where
reprPrec t _ := repr (List.ofFn t)
Structures on Fin n → α
Many algebraic structures on Fin n → α are already defined in Lean's libraries. Here are just a few examples. A zero is defined and has notation, 0.
#synth (Zero (Fin _ → ℚ))
#synth (Add (Fin _ → ℚ))
#synth (AddCommGroup (Fin _ → ℚ))
HSMul α (Fin n → α)
One structure on Fin n → α that we'll need is scalar multiplication, with notation, of an α-valued scalar by a Fin n → α tuple. You can see that Lean does not provide an instance by uncommenting #synth.
-- #synth (HSMul ℚ (Fin _ → ℚ))
-- We must instantiate it for this application
instance [SMul α α]: HSMul α (Fin n → α) (Fin n → α) :=
{
hSMul a f := SMul.smul a f
}
theorem hSMul_fin_def [SMul α α] (a : α) (f : Fin n → α) :
a • f = fun i => a • (f i) := rfl
theorem hSMul_fin_toRep [SMul α α] (a : α) (f : Fin n → α) (i : Fin n) :
(a • f) i = a • (f i) := rfl
end DMT1.Algebra.Tuples
import Mathlib.Data.Rat.Defs
import DMT1.Lectures.L10_algebra.tuple.tuple
namespace DMT1.Algebra.Vector
universe u
variable
{n : Nat}
{α : Type u}
----------------------------------------------------
Vectors: Vc α n
TODO: Update this explanation for loss of Tuple type We now define our abstract vector type, Vc α n, in the same way, but now using Tuple α n as a concrete representation. We lift the operations and structures we need from the underlying Tuple type, just as did for Tuple from he underlying scalar K type.
Representation as Fin n → α
@[ext]
structure Vc (α : Type u) (n : Nat) : Type u where
(toRep : Fin n → α)
-- deriving Repr
Special Values: Zero (Vc α n)
instance [Zero α]: Zero (Vc α n) where
zero := ⟨ 0 ⟩
@[simp]
theorem Vc.zero_def [Zero α] :
(0 : Vc α n) = ⟨ ( 0 : Fin n → α) ⟩ := rfl
Operations
Add (Vc α n)
instance [Add α] : Add (Vc α n) where
add t1 t2 := ⟨ t1.1 + t2.1 ⟩
-- SIMP ENABLED HERE
theorem Vc.add_def [Add α] (t1 t2 : Vc α n) :
t1 + t2 = ⟨ t1.1 + t2.1 ⟩ := rfl
theorem Vc.add_toRep [Add α] {n : ℕ} (x y : Vc α n) (i : Fin n) :
(x + y).1 i = x.1 i + y.1 i := rfl
HAdd (Vc α n) (Vc α n) (Vc α n)
-- Support for Vc `+` notation using HAdd
@[simp]
instance [Add α] : HAdd (Vc α n) (Vc α n) (Vc α n) :=
{ hAdd := Add.add }
@[simp]
theorem Vc.hAdd_def [Add α] (v w : Vc α n) :
v + w = ⟨ v.1 + w.1 ⟩ := rfl
theorem Vc.hAdd_toRep [Add α] {n : ℕ} (x y : Vc α n) (i : Fin n) :
(x + y).1 i = x.1 i + y.1 i := rfl
Neg (Vc α n)
No separate notation class.
instance [Neg α] : Neg (Vc α n) where
neg t := ⟨ -t.1 ⟩
-- TODO: Release note
theorem Vc.neg_def [Neg α] (t : Vc α n) :
-t = ⟨ fun i => -(t.1 i) ⟩ := rfl
theorem Vc.neg_toRep [Neg α] {n : ℕ} (x : Vc α n) (i : Fin n) :
-x.1 i = -(x.1 i) := rfl
Sub (Vc α n)
instance [Sub α] : Sub (Vc α n) where
sub t1 t2 := ⟨t1.1 - t2.1⟩
-- @[simp]
theorem Vc.sub_def [Sub α] (t1 t2 : Vc α n) :
t1 - t2 = ⟨t1.1 - t2.1⟩ := rfl
theorem Vc.sub_toRep [Sub α] {n : ℕ} (x y : Vc α n) (i : Fin n) :
(x - y).1 i = x.1 i - y.1 i := rfl
HSub (Vc α n) (Vc α n) (Vc α n)
This is the heterogeneous subtraction (-) otation-defining class
instance [Sub α] : HSub (Vc α n) (Vc α n) (Vc α n) where
hSub := Sub.sub
theorem Vc.hSub_def [Sub α] (v w : Vc α n) :
HSub.hSub v w = ⟨ v.1 - w.1 ⟩ := rfl
@[simp]
theorem Vc.hSub_toRep [Sub α] (v w : Vc α n) (i : Fin n) :
(v - w).1 i = v.1 i - w.1 i := rfl
SMul α (Vc α n)
instance [SMul α α] : SMul α (Vc α n) where
smul a t := ⟨ a • t.1 ⟩
theorem Vc.smul_Vc_def [SMul α α] (a : α) (v : Vc α n) :
a • v = ⟨ a • v.toRep ⟩ := rfl
theorem Vc.smul_Vc_toRep [SMul α α] (a : α) (v : Vc α n) (i : Fin n) :
(a • v).toRep i = a • (v.toRep i) :=
rfl
HSMul α vc vc
instance [SMul α α] : HSMul α (Vc α n) (Vc α n) where
hSMul := SMul.smul
theorem Vc.hSMul_def [SMul α α] (a : α) (v : Vc α n) :
a • v = ⟨ fun i => a • (v.1 i) ⟩ := rfl
theorem Vc.hsmul_toRep [SMul α α] (a : α) (v : Vc α n) (i : Fin n) :
(a • v).toRep i = a • (v.toRep i) := rfl
Structures
AddCommSemigroup (Vc α n)
instance [AddCommSemigroup α]: AddCommSemigroup (Vc α n) :=
{
add_comm := by -- So you can see the steps
intros
ext i
apply add_comm
add_assoc := by intros; ext; apply add_assoc
}
AddSemigroup (Vc α n)
Had a bug here: included [Add α] as well as [Semigroup α] thereby getting two equivalent but different definitions of +. Try adding [Add α] to see how the problem manifests.
instance [AddSemigroup α] : AddSemigroup (Vc α n) :=
{
add := Add.add
add_assoc := by
intros a b c
simp [Vc.add_def]
apply add_assoc
}
AddCommMonoid (Vc α n)
instance [AddCommMonoid α] : AddCommMonoid (Vc α n) :=
{
add := Add.add
zero := Zero.zero
nsmul := nsmulRec
add_assoc := by intros; ext; apply add_assoc
zero_add := by intros; ext; apply zero_add
add_zero := by intros; ext; apply add_zero
add_comm := by intros; ext; apply add_comm
}
Module α (Vc α n)
instance [Semiring α] : Module α (Vc α n) :=
{
smul_add := by intros a x y; ext i; apply mul_add,
add_smul := by intros a b x; ext i; apply add_mul,
mul_smul := by intros a b x; ext i; apply mul_assoc,
one_smul := by intros x; ext i; apply one_mul,
zero_smul := by intros x; ext i; apply zero_mul,
smul_zero := by intros a; ext i; apply mul_zero
}
AddMonoid (Vc α n)
instance [AddMonoid α] : AddMonoid (Vc α n) :=
{
nsmul := nsmulRec
zero_add := by
intro a
ext
apply zero_add
add_zero := by
intro a
ext
apply add_zero
}
SubNegMonoid
instance [SubNegMonoid α] : SubNegMonoid (Vc α n) :=
{
zsmul := zsmulRec
sub_eq_add_neg := by intros a b; ext i; apply sub_eq_add_neg
}
instance [AddGroup α] : AddGroup (Vc α n) :=
{
neg_add_cancel := by
intro a
ext
apply neg_add_cancel
}
-- Yay
-- Now that we can have Vc as the type of p2 -ᵥ p1
-- with p1 p2 : Pt
-- We can have Torsor Vc Pt
-- And that is affine space for any Vc sastisfying and Pt satisfying
end DMT1.Algebra.Vector
import DMT1.Lectures.L10_algebra.vector.vector
namespace DMT1.Algebra.Point
open DMT1.Algebra.Vector
open DMT1.Algebra.Tuples
universe u
variable
{n : Nat}
{α : Type u}
Points: Pt α n
We will now represent n-dimensional α points * as n-tuples of α values in the same way.
Representation
@[ext]
structure Pt (α : Type u) (n: Nat) where
(toRep: Fin n → α)
deriving Repr --, DecidableEq --, BEq
Values: Zero (Vc α n)
There are no distinguished point values. However we do need proof that there's some point. For that we'll require, somewhat arbitrarily, that there be a Zero scalar, and we'll build an arbitrary point with all zero internal parameters.
instance [Zero α] : Nonempty (Pt α n) := ⟨ ⟨ 0 ⟩ ⟩
Operations
VSub (Vc α n) (Pt α n)
This is the -ᵥ notation providing typeclass.
instance [Sub α] : VSub (Vc α n) (Pt α n) :=
{ vsub p1 p2 := ⟨ p1.1 - p2.1 ⟩ }
@[simp]
theorem Pt.vsub_def [Sub α] (p1 p2 : Pt α n) :
p1 -ᵥ p2 = ⟨ p1.1 - p2.1 ⟩ := rfl
theorem Pt.vsub_toRep [Sub α] (p1 p2 : Pt α n) (i : Fin n) :
(p1 -ᵥ p2).toRep i = p1.toRep i - p2.toRep i := rfl
VAdd (Vc α n) (Pt α n)
-- defines +ᵥ
instance [Add α] : VAdd (Vc α n) (Pt α n) where
vadd v p := ⟨ v.1 + p.1 ⟩
-- Insight need notation eliminating rule for VAdd from HVAdd
@[simp]
theorem Pt.hVAdd_def [Add α] (v : Vc α n) (p : Pt α n) :
v +ᵥ p = ⟨ v.1 + p.1 ⟩ := rfl
VSub then VAdd
-- set_option pp.rawOnError true
-- @[simp]
theorem Pt.vsub_vadd_def
[Add α]
[Sub α]
(p1 p2 : Pt α n) :
(p1 -ᵥ p2) +ᵥ p2 = ⟨ (p1 -ᵥ p2).1 + p2.1 ⟩ := rfl
-- ∀ (p₁ p₂ : Pt α n), (p₁ -ᵥ p₂) +ᵥ p₂ = p₁
AddActon (Vc α n) (Pt α n)
/-
/-- An `AddMonoid` is an `AddSemigroup` with an element `0` such that `0 + a = a + 0 = a`. -/
class AddMonoid (M : Type u) extends AddSemigroup M, AddZeroClass M where
/-- Multiplication by a natural number.
Set this to `nsmulRec` unless `Module` diamonds are possible. -/
protected nsmul : ℕ → M → M
/-- Multiplication by `(0 : ℕ)` gives `0`. -/
protected nsmul_zero : ∀ x, nsmul 0 x = 0 := by intros; rfl
/-- Multiplication by `(n + 1 : ℕ)` behaves as expected. -/
protected nsmul_succ : ∀ (n : ℕ) (x), nsmul (n + 1) x = nsmul n x + x := by intros; rfl
-/
instance [AddMonoid α] : AddMonoid (Vc α n) :=
{
nsmul := nsmulRec
}
instance [AddMonoid α]: AddAction (Vc α n) (Pt α n) :=
{
-- (p : Pt α n), 0 +ᵥ p = p
zero_vadd := by
intro
-- to study in part by stepping through
--
simp only [Pt.hVAdd_def]
-- TODO: Release note: a simplification here, losing two lines
--simp [Tuple.add_def]
simp [Vc.zero_def]
-- simp [Tuple.zero_def]
-- ∀ (g₁ g₂ : Vc α n) (p : Pt α n), (g₁ + g₂) +ᵥ p = g₁ +ᵥ g₂ +ᵥ p
-- GOOD EXERCISE
-- TODO: Release note: simplification here, too
add_vadd := by
intros
ext
apply add_assoc
}
Add then VAdd
theorem Pt.add_vadd_def [Add α] (v1 v2 : Vc α n) (p : Pt α n) :
(v1 + v2) +ᵥ p = ⟨ (v1 + v2).1 + p.1 ⟩ := rfl
There now. Behold. Correct is simpler
@[simp]
theorem Pt.vsub_vadd'_def
[Zero α]
[Add α]
[Sub α]
(p1 p2 : Pt α n) :
(p1 -ᵥ p2) +ᵥ p2 = ⟨ p1.1 - p2.1 + p2.1⟩ :=
-- match on left pattern
-- rewrite as this pattern
by -- and this shows it's ok
simp only [Pt.hVAdd_def]
simp only [Pt.vsub_def]
end DMT1.Algebra.Point
import Mathlib.Data.Rat.Defs
import Mathlib.LinearAlgebra.AffineSpace.Defs
import DMT1.Lectures.L10_algebra.point.point
namespace DMT1.Algebra.Torsor
open DMT1.Algebra.Vector
open DMT1.Algebra.Point
open DMT1.Algebra.Tuples
universe u
variable
{n : Nat}
{α : Type u}
instance [AddGroup α] [Nonempty (Pt α n)] : AddTorsor (Vc α n) (Pt α n) :=
{
-- ∀ (p₁ p₂ : Pt α n), (p₁ -ᵥ p₂) +ᵥ p₂ = p₁
vsub_vadd':= by
intros p1 p2
simp [Pt.hVAdd_def]
-- vadd_vsub' : ∀ (g : Vc α n) (p : Pt α n), (g +ᵥ p) -ᵥ p = g
vadd_vsub':= by
intro v p
simp [Pt.vsub_def]
}
end DMT1.Algebra.Torsor
import Mathlib.LinearAlgebra.AffineSpace.Defs
import Mathlib.LinearAlgebra.AffineSpace.AffineEquiv
import DMT1.Lectures.L10_algebra.torsor.torsor
import Mathlib.Data.Real.Basic
import Mathlib.LinearAlgebra.AffineSpace.AffineEquiv
#check AffineEquiv.refl (ℝ × ℝ) (ℝ × ℝ)
open DMT1.Algebra.Torsor
open DMT1.Algebra.Point
open DMT1.Algebra.Vector
universe u
variable
{n : Nat}
{α : Type u}
Affine Space
An affine space over a field K (here ℚ) is a torsor (of points) P under a vector space V ' over (with scalars from) K.
Get AffineSpace (as a notation for AddTorsor) by opening the Affine namespace.
open Affine
#check (@AffineSpace)
instance
[Field α]
[AddCommGroup (Vc α n)]
[Module α (Vc α n)]
[AddTorsor (Vc α n) (Pt α n)] :
AffineSpace (Vc α n) (Pt α n) :=
{
-- ∀ (p₁ p₂ : Pt α n), (p₁ -ᵥ p₂) +ᵥ p₂ = p₁
-- ∀ (g : Vc α n) (p : Pt α n), (g +ᵥ p) -ᵥ p = g
vsub_vadd' := by
intros p1 p2
simp [Pt.hVAdd_def]
vadd_vsub':= by
intro v p
simp [Pt.vsub_def]
}
Relation to Torsor in Lean 4
In Lean, AffineSpace is simply a notation for Torsor. You can access this notation by opening the Affine namespace, as shown here.
#synth (AffineSpace (Vc ℚ 2) (Pt ℚ 2))
New Concepts
Please see the Mathlib Affine Space page.
AffineMap
A map between affine spaces that preserves the affine structure.
AffineEquiv
An equivalence between affine spaces that preserves the affine structure;
AffineSubspace
A subset of an affine space closed w.r.t. affine combinations of points;
AffineCombination
An affine combination of points
AffineIndependent
Affine independent set of points;
AffineBasis.coord
The barycentric coordinate of a point.
Missing from Mathlib
Affine Frame
Affine Frame
Geometrically it's Point + Basis
Basis
Here's what it says in the Mathlib file.
Some key definitions are not yet present.
* Affine frames. An affine frame might perhaps be represented as an `AffineEquiv` to a `Finsupp`
(in the general case) or function type (in the finite-dimensional case) that gives the
coordinates, with appropriate proofs of existence when `k` is a field.
Finsupp is off the table for us. We're about low-dimensional computation. So we'll use AffineEquiv to a coordinate-based tuple, represented as a function: namely, Fin n → α. Notably we are avoiding Finsupp, with a loss of generality from infinite dimensional (but finitely supported) cases but with gains in computability and ease of proof construction.
AffineEquiv
See this file:
In this file we define AffineEquiv k P₁ P₂ (notation: P₁ ≃ᵃ[k] P₂) to be the type
of affine equivalences between P₁ and P₂, i.e., equivalences such that both forward
and inverse maps are affine maps.
```lean
#check (@AffineEquiv)
Type Heterogeneity
Heterogeneous Collections (Lists, Tuples, etc.)
EARLY DRAFT: STILL DEVELOPING.
We've seen that parametric polymorphism enables specialization of definitions for argument values of any type, given as the value of a formal parameter, (α : Type u). Such a specialized definition then pertains and applies to values of any given actual parameter values of that that type, but only of that type. For this construct to work well, the fixed code of such a parameterized definition has to work for objects of any type. The code cannot encode special case logic for different types of objects. In Lean, one cannot match on, which is to say, distinguish, different types at all. That would make the logic unsound. Details on that can be found elsewhere.
Constructs like these are good for representing type-homogenous definitions. For example, (List Nat) is the type of lists all of the elements of which are necessarily, in this case, Nat. The generalization of this special case is (List (α : Type u)), where α is the type of all elements in any list value of type (List α).
Parametric polymorphism provides a lot of leverage, but is has its limits. Sometimes we want to generalize concepts, such as addition, for example, following certain intuitive rules, over objects of ad hoc types. For example, we might want a function that implements add for Nats and, of course, a necessarily different function that implements add for Rats. We've already seen that overloading of proof-carrying structures enables compelling formalizations of at least some useful parts of the universe of abstract mathematics. There's ample evidence from others
With typeclasses defined to capture the abstract concepts and their instances providing implementations of these concepts for different types of underlying objects, and with a notation definition, we can write both (2 + 3) and ("Hello" + "world"), with Lean figuring out which instance implements + (add) for Nat, and which, for String.
These method provide a form of polymorphism narrower in range than the parametric variety, insofar as one needs to implement the full concept for each type of underlying object (e.g., Nat or String). But is eliminates the constraint to uniformity of implementation, and thereby greatly augments one's expressive power.
In the context of our encounter with typeclasses, instances, and their applications in basic abtract algebra, we met another break from type homogeneity, in vivid distinctions between homogenous and homogeneous variants of the "same (and even more abstracted)" concept. Addition of two scalars is type-homogenous, but addition of a vector to a point is heterogeneous. In the formalization we get from Lean, one version of the addition concept (+) is defined by the Add, with Add.add taking and returning values of one type, whereas the other (+ᵥ) is required to be used for addition of an object of one type to an object of another, as in the case of vector-point addition in affine spaces. Heterogeneity of this kind is achieved through typeclass definitions parametrically polymorphic in multiple types.
In this chapter we consider type heterogeneity in finite collections of objects, such as lists or tuples represented as functions from a natural number index set to the values at those indices. What if we want a different type of value at each position in a tuple? It's not a remotely crazy idea: we have databases full of tables with columns having different types of data in them. It would be good to be able to ensure that records (tuples of values) have corresponding types. For example, if the type labels on columns were Nat, String, Bool, we'd want to type-check corresonding value tuples, e.g., accepting (1, "hi", true) but rejecting anything not a 3-tuple of values of the specified types. Another application could be in representing function type signatures and type-checked constructors for actual parameteter value tuples.
AI Disclosure: The author did interact with ChatGPT, with context provided by earlier, unrelated chats, when deciding how to populate and organize parts of this chapter. I did adopt--and in most cases fixed--some elements of the chat output. One cannot know the real provenance of such outputs. I take responsibility for checking of correctness. Should you recognize some clear derivative of your own work in this chapter, please don't hesitate to let me know.
With that long intruction, the rest of this chapter presents six somewhat different attacks on the same basis problem, noting some of the strengths, weaknesses, and use case of each approach (that right there does sound like ChatGPT, doesn't it). cases.
import Mathlib.Data.Fin.Basic
import Mathlib.Data.Fin.Tuple.Basic
import Lean.Data.Json
open Fin Lean Json
Custom Dyn Types
Define an inductive type with cases for each supported data or function type.
Wrapper for type-tagged values of heterogeneous types
inductive DynVal where
| nat : Nat → DynVal
| str : String → DynVal
| bool : Bool → DynVal
| natToBool : (Nat → Bool) → DynVal
| natToNat : (Nat → Nat) → DynVal
| strToBool : (String → Bool) → DynVal
| boolToStr : (Bool → String) → DynVal
Pattern Matching on (Destructuring) Psuedotypes
Having mapped each supported type to a corresponding constructor, we can now effectively match on types by matching on their associated DynVal constructors (we'll call these pseudotypes). Here, given a wrapped value, we match on its pseudotype to extract the wrapped value, from which we, in this case, derive a printable version of that value.
def dynValToString : DynVal → String
|DynVal.nat n => toString n
|DynVal.str s => s
|DynVal.bool b => toString b
|DynVal.natToBool _ => "<Nat → Bool>"
|DynVal.natToNat _ => "<Nat → Nat>"
|DynVal.strToBool _ => "<String → Bool>"
|DynVal.boolToStr _ => "<Bool → String>"
Typeclass Instances for Printing of Wrapped Values
instance : Repr DynVal where reprPrec m _ := dynValToString m
instance : ToString DynVal := { toString := dynValToString }
Dynamic Type Tags
Just as we associate a pseudo-typed value DynValye constructor with each supported type, so we associate a DynType type tag with each such type. We can pass these tags as data, define functions to associated metadata, such as printable type names, etc.
inductive DynType where
| nat
| str
| bool
| fn (dom cod : DynType)
Printable Type Names
def dynTypeToString : DynType → String
| .nat => "Nat"
| .str => "String"
| .bool => "Bool"
| .fn d c => s!"({dynTypeToString d} → {dynTypeToString c})"
Signature: n-tuple of DynTypes
We model a signature as a function from position/index to DynType. These are the type tag,s not wrapped values.
def Sig (n : Nat) := Fin n → DynType
-- Example: (Nat, Bool)
def aSig1 : Sig 2 := fun (i : Fin 2) => match i with
| 0 => .nat
| 1 => .str
def aSig2 (_ : String) : (Sig 3) :=
fun
| 0 => .nat
| 1 => .str
| 2 => .bool
Valuation of a Signature
We define Args to the type of a tuple of actual values matching the types in a given Sig (itself a tuple of type tags). We can call such a value type a valuation of a signature.
def Args {n : ℕ} (s : Sig n) : Type :=
∀ i : Fin n, match s i with
| .nat => Nat
| .str => String
| .bool => Bool
| .fn _ _ => String -- TODO
-- Example
def args1 : Args aSig1 := fun (i : Fin 2) => match i with
| 0 => (3 : Nat)
| 1 => "Hello"
#eval args1 0
#eval args1 1
Example of pseudotype-checked arg tuple
def args2 : Args (aSig2 "String")
| 0 => (42 : Nat)
| 1 => "Lean 4"
| 2 => true
A function taking a valuation of a signature and returning a tuple of heterogeneously typed dynamic values (DynVal).
def wrapArgs {n : ℕ} (s : Sig n) (args : Args s) : Fin n → DynVal :=
fun i =>
match s i, args i with
| .nat, x => .nat x
| .str, x => .str x
| .bool, x => .bool x
| .fn _ _, x => .str x
Apply Dynamic Function to Dynamic Argument
Dynamically checked application of a wrapped function, f, to a dynamically checked, wrapped argument, x. Returns Option result.
def applyIfTyped : DynType → DynVal → DynVal → Option DynVal
| .fn .nat .bool, DynVal.natToBool f, DynVal.nat x => some (DynVal.bool (f x))
| .fn .nat .nat, DynVal.natToNat f, DynVal.nat x => some (DynVal.nat (f x))
| .fn .str .bool, DynVal.strToBool f, DynVal.str x => some (DynVal.bool (f x))
| .fn .bool .str, DynVal.boolToStr f, DynVal.bool x => some (DynVal.str (f x))
| _, _, _ => none
-- Example
#eval
applyIfTyped -- expect *false*
(.fn .nat .bool) -- know the function type
(DynVal.natToBool (fun n => n%2 == 0)) -- wrap one of that type
(DynVal.nat 3) -- and apply to wrapped value
Example: A Signature with Named Fields and a Valuation
In this extended example, we build a structure that comprises a signature, a valuation, field names, all for an existential number, n, of fields. We start by defining what an entry for one value will comprise in such a structure; then we define the structure itself.
Entry
structure ModEntry where
{n : ℕ}
names : Fin n → String
sig : Sig n
args : Args sig
-- A convenience function (TODO: take out args part)
def mkModEntry {n : ℕ} (names : Fin n → String) (sig : Sig n) (args : Args sig) : ModEntry :=
{ names := names, sig := sig, args := args }
-- Printing helper functions
def printModEntry (e : ModEntry) : List (String × String) :=
let dynArgs := wrapArgs e.sig e.args
List.ofFn fun i => (e.names i, dynValToString (dynArgs i))
def printSigEntry (e : ModEntry) : List (String × String) :=
List.ofFn fun i => (e.names i, dynTypeToString (e.sig i))
def getFieldNames (e : ModEntry) : List String :=
List.ofFn fun i => e.names i
-- Return (name, type, value) triples from a ModEntry
def printTypedModEntry (e : ModEntry) : List (String × String × String) :=
let dynArgs := wrapArgs e.sig e.args
List.ofFn fun i => (e.names i, dynTypeToString (e.sig i), dynValToString (dynArgs i))
-- Return (name, type) pairs from a ModEntry
def printFieldSigEntry (e : ModEntry) : List (String × String) :=
List.ofFn fun i => (e.names i, dynTypeToString (e.sig i))
Downcasting: Accessing Wrapped Values
This design method loses type information in the sense that once a value is wrapped, one can no longer determine what type of value is in there, except by pattern matching on pseudotype tags, yielding Option-valued results.
Here's an example where we are given a ModEntry that we expect to contain a function of some kind. We pattern match and fail if that is not the case. Otherwise we wrap the arguments carried by the ModEntry and apply the wrapped function to it. If that succeeds we get a wrapped result, otherwise from applyIfTyped we would also get an option None.
def evalModEntryField (e : ModEntry) (i : Fin e.n) (fnVal : DynVal) : Option DynVal :=
match e.sig i with
| .fn dom cod =>
let argVal := wrapArgs e.sig e.args i
applyIfTyped (.fn dom cod) fnVal argVal
| _ => none
Module (A Tuple of Entries)
Now we define a Module as just an n-tuple of ModEntry's, each comprising an existential n, a tuple of field names, a signature (tuple of field type tages), and a valuation (a tuple of values of the types associated with those type tags),
structure Module (n : ℕ) where
entries : Fin n → ModEntry
-- Printing as string functions
def printModuleEntries {n : ℕ} (m : Module n) : List (List (String × String)) :=
List.ofFn fun i => printModEntry (m.entries i)
def printModuleSigs {n : ℕ} (m : Module n) : List (List (String × String)) :=
List.ofFn fun i => printSigEntry (m.entries i)
def printTypedModule {n : ℕ} (m : Module n) : List (List (String × String × String)) :=
List.ofFn fun i => printTypedModEntry (m.entries i)
def printFieldSigModule {n : ℕ} (m : Module n) : List (List (String × String)) :=
List.ofFn fun i => printFieldSigEntry (m.entries i)
-- Printing as JSON functions
def jsonFromPairs (pairs : List (String × String)) : Json :=
Json.mkObj (pairs.map fun (k, v) => (k, Json.str v))
def modEntryValuesJson (e : ModEntry) : Json :=
jsonFromPairs (printModEntry e)
def modEntryTypesJson (e : ModEntry) : Json :=
jsonFromPairs (printSigEntry e)
def moduleValuesJson {n : ℕ} (m : Module n) : Json :=
Json.arr <| Array.ofFn fun i => modEntryValuesJson (m.entries i)
def moduleTypesJson {n : ℕ} (m : Module n) : Json :=
Json.arr <| Array.ofFn fun i => modEntryTypesJson (m.entries i)
def moduleValuesJsonString {n : ℕ} (m : Module n) : String :=
(moduleValuesJson m).pretty
def moduleTypesJsonString {n : ℕ} (m : Module n) : String :=
(moduleTypesJson m).pretty
Example usage
def names2 : Fin 3 → String
| 0 => "id"
| 1 => "desc"
| 2 => "flag"
A module entry (like a simplistic method specification in a class)
def entry1 : ModEntry := mkModEntry names2 (aSig2 "String") args2
Example signature, sig 2, with one field of pseudo-type Nat
def sig3 : Sig 1
| 0 => .nat
-- A corresponding argument 1-tuple, (41)
def args3 : Args sig3
| 0 => (41 : Nat)
-- A cooresponding 1-tuple of field names
def names3 : Fin 1 → String
| 0 => "count"
-- a second module entry
def entry2 : ModEntry := mkModEntry names3 sig3 args3
-- Module with 2 entries
def mod1 : Module 2 where
entries
| 0 => entry1
| 1 => entry2
An example with a function-valued entry
def sig4 : Sig 1
| 0 => .fn .str .bool
def args4 : Args sig4
| 0 => "nonempty?"
def names4 : Fin 1 → String
| 0 => "check"
def entry4 : ModEntry := mkModEntry names4 sig4 args4
def fnEntry4 : DynVal := DynVal.strToBool (fun (s : String) => s ≠ "")
instance : Repr (Option DynVal) where
reprPrec o _ :=
match o with
| Option.none => "None"
| Option.some m => s!"Some {m})"
#eval applyIfTyped (.fn .str .bool) fnEntry4 (DynVal.str "hello") -- Expected: some (dynVal.bool true)
#eval applyIfTyped (.fn .str .bool) fnEntry4 (DynVal.str "") -- Expected: some (dynVal.bool false)
Ok, now let's test typed application of pseudotyped partial functions to pseudotyped arguments, possibly returning Option.none.
def fnEntry3 : DynVal :=DynVal.natToBool (fun (n : Nat) => n % 2 == 0)
#eval evalModEntryField entry2 (0 : Fin 1) fnEntry3 -- Expected: some (dynVal.bool false)
#eval evalModEntryField entry4 (0 : Fin 1) fnEntry4 -- Expected: some (dynVal.bool true)
-- JSON printing of module entries
#eval moduleTypesJsonString mod1
#eval moduleValuesJsonString mod1
#eval modEntryTypesJson entry1
#eval modEntryValuesJson entry1
#eval printModuleEntries mod1
namespace DMT1.Lecture.hetero.hetero
Discussion
- Store heterogeneous values in (List MyDyn).
- Loses static type information
- Must either downcast to use or package instances with values
- Useful with JSON-style serialization
- Dynamic modules or configurations
- Interfacing with external systems
end DMT1.Lecture.hetero.hetero
import Mathlib.Data.Fin.VecNotation
Existential Wrappers
In this approach we wrap a value of any type α, α itself, and a typeclass instance or other metadata specialized for that particular type, α. Because one cannot destructure a type in Lean, one can't tell what type it is. The type is known to exist but it's effectively hidden (even if one can access the α field).
class Typename (α : Type u) where
(toString : String)
instance : Typename Nat := ⟨ "Nat" ⟩
instance : Typename Bool := ⟨ "Bool" ⟩
instance : Typename String := ⟨ "String" ⟩
structure Showable where
α : Type
val : α
valToString : ToString α
typeToString : Typename α
Existential Hiding
From a client's perspective, one knows that the value of the α is some type, and that the value of the val field is of that type, but the actual type is no longer recoverable. There is no question that it exists, but as one cannot pattern match on types there is no way to learn what type it is. Information about the The the value of α and thus the type of val is lost.
Here's a demonstration.
-- Here's a showable instance
def aShowable : Showable := ⟨ Nat, 0, inferInstance, inferInstance ⟩
-- We can access its fields, including the *val* fields
#eval aShowable.val -- no problem, a simple field access
-- But the value of α and the type of val are unrecoverable
#check aShowable.val -- all we know is that the type is α
--#eval aShowable.α -- uncomment to see the error here
Typesafe Operations on Existentially Wrapped Values
The power of existential wrapping comes from the inclusion of type-specific class instances with wrapped types and their values. The aShowable object, for example, carries typeclass instances as field values in turn carry type-specific metadata for α which can include operations. Here they are used to obtain type-specific string values for both α and val.
-- Showables with three distinct underlying types
def natShowable : Showable := ⟨ Nat, 3, inferInstance, inferInstance ⟩
def boolShowable : Showable := ⟨ Bool, true, inferInstance, inferInstance ⟩
def stringShowable : Showable := ⟨ String, "I love maths", inferInstance, inferInstance ⟩
-- We can obtain String names for the underlying types
#eval natShowable.typeToString.toString
#eval boolShowable.typeToString.toString
#eval stringShowable.typeToString.toString
-- We can obtain String renditions of the underlying values
#eval natShowable.valToString.toString natShowable.val
#eval boolShowable.valToString.toString boolShowable.val
#eval stringShowable.valToString.toString stringShowable.val
-- A function returning "(val : type)" for a showable
def toTypedValString (s : Showable) :=
let valName := s.valToString.toString s.val
let typeName := s.typeToString.toString
"(" ++ valName ++ " : " ++ typeName ++ ")"
-- A list of Showables with heterogeneous underlying types
def showables := [ natShowable, boolShowable, stringShowable ]
-- And the punch line: we can "show" an output for each of them
#eval List.map toTypedValString showables
Signature: N-tuple of Showables
We model a signature as a function from position/index to Showable.
def Sig (n : Nat) := Fin n → Showable
-- Convert a signature to a string
def showSig {n : Nat} (sig : Sig n) : String :=
"[" ++ String.intercalate ", " (List.ofFn (fun i => toTypedValString (sig i))) ++ "]"
-- Example Sig
def aSig : Sig 3 := ![natShowable, boolShowable, stringShowable]
-- Convert to String
#eval showSig aSig
-- Output: "[Nat : 42, Bool : true, String : hello]"
Modeling Modules
From here, we represent a module as a tuple of signatures.
TODO.
Discussion
This approach to heterogeneity is extensible. To add support for any type one need only have the required typeclass instances. The approach is also type-safe. There is an added runtime cost insofar as instances are passed and used at runtime, preventing inlining and incurring the costs of making indirect function calls.
Finite-Index Signature Tuples (Fin n → σ i)
dependently-typed, index-based tuple representation, good for repreesenting fixed-arity, heterogeneously-typed structures.
namespace DMT1.Lecture.hetero.hetero
open Std
-- A heterogeneous n-tuple type: σ is a signature Fin n → Type
def Sig (n : Nat) := Fin n → Type
def Val (σ : Sig n) := ∀ i : Fin n, σ i
-- Collects Repr instances for a given signature
class BuildReprs {n : Nat} (σ : Sig n) where
reprs : ∀ i : Fin n, Repr (σ i)
-- Pretty print each entry using its Repr instance
def toReprList {n : Nat} {σ : Sig n} [BuildReprs σ] (v : Val σ) : List String :=
List.ofFn (fun i => Format.pretty ((BuildReprs.reprs i).reprPrec (v i) 0) 80)
def toPrettyString {n : Nat} {σ : Sig n} [BuildReprs σ] (v : Val σ) : String :=
s!"[{String.intercalate ", " (toReprList v)}]"
-- Example signature and value
def sig3 : Sig 3
| 0 => Nat
| 1 => Bool
| 2 => String
def val3 : Val sig3
| 0 => (42 : Nat)
| 1 => true
| 2 => "hello"
instance : BuildReprs sig3 where
reprs
| 0 => inferInstanceAs (Repr Nat)
| 1 => inferInstanceAs (Repr Bool)
| 2 => inferInstanceAs (Repr String)
#eval toPrettyString val3 -- "[42, true, hello]"
-
Fixed-length, statically typed heterogeneity
-
statically typed, fixed arity tuples with per-index types
-
Fast index-based access (like arrays)
-
Dependent functions on each component
-
Suitable for modeling e.g. function signatures and argument packs, struct layouts, schemas
Compared to HList, this gives you:
- Random access via Fin n rather than recursive pattern matching
- Better performance and inference in many cases?
- More natural fit for modeling arities of known size?
end DMT1.Lecture.hetero.hetero
namespace DMT1.Lecture.hetero.hetero
Heterogeneous Lists (HList)
A heterogeneous list is a list built from nil and cons constructors wbut where each value in the list is of a type specified an an element in a corresponding list of types.
inductive HList : List (Type u) → Type (u+1) where
| nil : HList []
| cons {a : Type u} {as : List (Type u)} : a → HList as → HList (a :: as)
namespace HList
Head and Tail
/-- Head of an HList -/
def head {α : Type u} {as : List (Type u)} : HList (α :: as) → α
| cons x _ => x
/-- Tail of an HList -/
def tail {α : Type u} {as : List (Type u)} : HList (α :: as) → HList as
| cons _ xs => xs
/--
## Utility: Build HLists of Function Types
Give two lists of types, return the list of function types
derived by pairing corresponding entries.
-/
def ZipWithFun : List (Type u) → List (Type u) → List (Type u)
| [], [] => []
| a :: as, b :: bs => (a → b) :: ZipWithFun as bs
| _, _ => []
Map Hlist of Functions over HList of Arguments
Map an HList of functions over an HList of values
- HList.map b.fs b.xs maps each function in b.fs over the corresponding value in b.xs
- b.fs : HList (ZipWithFun as bs) is a heterogeneous list of functions, each fᵢ of type aᵢ → bᵢ
- b.xs : HList as is a heterogeneous list of values, where each value xᵢ has type aᵢ -/ def map : ∀ {as bs : List (Type u)}, HList (ZipWithFun as bs) → HList as → Option (HList bs) | [], [], nil, nil => some nil | a :: as, b :: bs, cons f fs, cons x xs => match map fs xs with | some tail => some (cons (f x) tail) | none => none | _, _, _, _ => none
/-!
Pretty-Printing
-/
inductive MapRepr : List (Type u) → Type (u+1) | nil : MapRepr [] | cons {a : Type u} {as : List (Type u)} (head : Repr a) (tail : MapRepr as) : MapRepr (a :: as)
def toReprList : ∀ {ts : List (Type u)}, HList ts → MapRepr ts → List String | [], HList.nil, MapRepr.nil => [] | a :: as, HList.cons x xs, MapRepr.cons head insts => let str := Std.Format.pretty (head.reprPrec x 0) 80 str :: toReprList xs insts
def toPrettyString {ts : List (Type u)} (xs : HList ts) (insts : MapRepr ts) : String := s!"[{String.intercalate ", " (toReprList xs insts)}]"
class BuildMapRepr (ts : List (Type u)) where build : MapRepr ts
instance : BuildMapRepr [] where build := MapRepr.nil
instance [Repr a] [BuildMapRepr as] : BuildMapRepr (a :: as) where build := MapRepr.cons (inferInstance : Repr a) (BuildMapRepr.build)
## MapShow: Printing Heterogeneous Tuples
A typesafe MapShowBundle type encodes
- input types as (e.g., [Nat, Bool, String])
- output types bs (e.g., [String, String, String])
- fs : HList (ZipWithFun as bs) — per-element function types
- xs : HList as — the input values
- bsInst : BuildMapRepr bs — so each type in bs has Repr instance for printing
structure MapShowBundle (as bs : List (Type u)) where fs : HList (HList.ZipWithFun as bs) xs : HList as bsInst : BuildMapRepr bs
Takes a fully-typed MapShowBundle record and returns a String.
Returns the pretty-printed result of mapping the functions over the values.
def mapShowBundle {as bs : List (Type u)} (b : MapShowBundle as bs) : String := match HList.map b.fs b.xs with | some ys => toPrettyString ys b.bsInst.build | none => "mapping failed"
Pretty-print result of function map over an HList using inferred Repr
def mapShow {as bs : List (Type u)} [BuildMapRepr bs] (fs : HList (HList.ZipWithFun as bs)) (xs : HList as) : String := match HList.map fs xs with | some ys => toPrettyString ys (BuildMapRepr.build) | none => "mapping failed"
## Example: Forming and Printing an HList of Values
-- Functions of three different types to String def f₁ : Nat → String := toString def f₂ : Bool → String := fun b => if b then "yes" else "no" def f₃ : String → String := String.toUpper
-- a type-heterogenous list of those three functgions def fs : HList [Nat → String, Bool → String, String → String] := HList.cons f₁ (HList.cons f₂ (HList.cons f₃ HList.nil))
-- a type-matched and -checked list of values (7, false, "hello") def xs : HList [Nat, Bool, String] := HList.cons 7 (HList.cons false (HList.cons "hello" HList.nil))
def myBundle : MapShowBundle [Nat, Bool, String] [String, String, String] where fs := fs xs := xs bsInst := inferInstance
def testMapped : String := mapShowBundle myBundle
#eval testMapped -- "[7, no, HELLO]"
### Example: Represent "configuration spaces"
abbrev ConfigSchema := [Nat, Bool, String]
-- Sample config: max retries = 3, verbose = false, log file = "/tmp/log.txt" def myConfig : HList ConfigSchema := HList.cons 3 (HList.cons false (HList.cons "/tmp/log.txt" HList.nil ) )
-- Extract components def maxRetries := HList.head myConfig def verbose := HList.head (HList.tail myConfig) def logFile := HList.head (HList.tail (HList.tail myConfig))
#eval logFile -- "/tmp/log.txt" #eval verbose #eval maxRetries
## Qualities and Tradeoffs
- typesafe
- dynamic length
- pattern matching
- but linear access time
- but uses Option (HList bs), avoiding totality checking on HList bs directly
It always typechecks because:
- It uses Option (HList bs), avoiding totality checking on HList bs directly.
- The last catch-all handles any mismatch safely.
- No unreachable! or panic! needed.
- The return type is always inhabited (Option is always inhabited).
#eval testMapped
end DMT1.Lecture.hetero.hetero.HList
Dependently Typed (Heterogeneous) Vectors
namespace DMT1.Lecture.hetero.hetero
inductive DVec : (n : Nat) → (σ : Fin n → Type u) → Type (u + 1)
| nil {σ : Fin 0 → Type u} : DVec 0 σ
| cons {n : Nat} {σ : Fin (n + 1) → Type u} :
σ 0 →
DVec n (fun i => σ (Fin.succ i)) →
DVec (n + 1) σ
def DVec.get {n : Nat} {σ : Fin n → Type u} (xs : DVec n σ) (i : Fin n) : σ i :=
match xs, i with
| DVec.cons x _, ⟨0, _⟩ => x
| DVec.cons _ xs', ⟨i'+1, h⟩ =>
xs'.get ⟨i', Nat.lt_of_succ_lt_succ h⟩
def mySig : Fin 3 → Type
| ⟨0, _⟩ => Nat
| ⟨1, _⟩ => Bool
| ⟨2, _⟩ => String
def myDVec : DVec 3 mySig :=
DVec.cons (42 : Nat) (DVec.cons true (DVec.cons "hello" DVec.nil))
#eval myDVec.get ⟨0, by decide⟩ -- 42
#eval myDVec.get ⟨1, by decide⟩ -- true
#eval myDVec.get ⟨2, by decide⟩ -- "hello"
open DVec
def DVec.reprDVec {n : Nat} {σ : Fin n → Type u} (inst : ∀ i, Repr (σ i)) :
DVec n σ → String
| DVec.nil => "[]"
| @DVec.cons n' σ' x xs =>
let head := Std.Format.pretty (repr x)
let tail := DVec.reprDVec (fun i => inst (Fin.succ i)) xs
"[" ++ head ++ "," ++ tail.dropWhile (· == '[') -- TODO: extra , after last elt
instance {n : Nat} {σ : Fin n → Type u} [∀ i, Repr (σ i)] : Repr (DVec n σ) where
reprPrec xs _ := Std.Format.text (DVec.reprDVec (inst := inferInstance) xs)
instance {n : Nat} {σ : Fin n → Type u} [∀ i, Repr (σ i)] : ToString (DVec n σ) where
toString xs := DVec.reprDVec (inst := inferInstance) xs
Lean does not synthesize ∀ i, Repr (σ i) even if it knows each Repr (σ i) separately. We help it by hand-writing the dependent function instance.
instance : ∀ i : Fin 3, Repr (mySig i)
| ⟨0, _⟩ => inferInstanceAs (Repr Nat)
| ⟨1, _⟩ => inferInstanceAs (Repr Bool)
| ⟨2, _⟩ => inferInstanceAs (Repr String)
#eval myDVec
-Precise and compositional
- Access by Fin i
- Allows index-aligned computations
- Boilerplate-heavy
- Requires dependent recursion for construction and access
- Good for index-typed languages, embedded DSLs, typed syntax trees
end DMT1.Lecture.hetero.hetero
Dependent Pair (Sigma) Chains
Idea: Use Σ i, σ i recursively for list-like collections. Here we convert an n-Sig to such a Type by recursion on n.
namespace DMT1.Lecture.hetero.hetero
def Sig (n : Nat) := Fin n → Type
-- A heterogeneous n-tuple based on a type signature σ : Fin n → Type
def HChain : (n : Nat) → (σ : Sig n) → Type
| 0, _ => Unit
| n + 1, σ => Σ x : σ ⟨0, Nat.zero_lt_succ n⟩,
HChain n (fun i => σ ⟨i.val + 1, Nat.add_lt_add_right i.isLt 1⟩)
-- TODO: Examples
Type-safe with different types per position Good for modeling recursive structures or sequences Hard to work with. No constant-time access Not supportiveo of size-polymorphism Usea in specific induction-based constructions
end DMT1.Lecture.hetero.hetero
Getting Started
The DMTL4 book is largely generated from Lean 4 source. You can experiment with
all of the examples in the VS Code IDE. First, follow the Lean 4 quickstart to
install VS Code, Lean, and the Lean 4 extension. Second, install git
and
clone your fork of the repository. Next, open the repository in VS code.
Finally, open the notes you wish to run. The book's Lean code can be found in the
code/DMT1/Lectures/
directory.
Building DMTL4
If you wish to generate the book yourself there are additional steps and dependencies you will need.
First, install mdbook
.
Additionally, install mdbook-toc
, mdbook-mermaid
and
mdbook-image-size
. To do so, download the appropriate tarball for you
system architecture and unpack it into a directory on your path.
Then, install the Haskell installer, ghcup
.
Add ghcup
's bin directory ($HOME/.ghcup/bin
) to your PATH
.
Next, launch the TUI and install ghcup
's recommend Haskell version.
Lastly, if not already installed, install make
on your platform.
At this point you should be able to build the book. From the top-level
directory run make all
. This will transform the code (in code/DMT
) into
markdown (in src/DMT1
) for the book.
Finally, you can serve up the book for browsing with mdbook serve --open &
.
Conveniently, any changes to the src
directory will cause the book to be
rebuilt automatically.
Install Scripts
Manual installation instructions will be replaced with automated install scripts over time. We welcome any and all contributions to this effort.
Windows
Not available at this time.
macOS
The dependencies described above can be installed on macOS via homebrew with the following commands.
brew install mdbook ghcup
# Assuming x86_64. Tweak for apple silicon.
curl -L https://github.com/badboy/mdbook-mermaid/releases/download/v0.14.1/mdbook
-mermaid-v0.14.1-x86_64-apple-darwin.tar.gz | tar -xz -C $HOME/.local/bin
curl -L https://github.com/badboy/mdbook-toc/releases/download/0.14.2/mdbook-toc-0.14.2-x86_64-apple-darwin.tar.gz | tar -xz -C $HOME/.local/bin
# Assuming bash. Tweak if using a different shell.
echo 'PATH=$PATH:$HOME/.ghcup/bin' >> "$HOME/.bashrc"
ghcup install ghc recommended
ghcup set ghc recommended
Other
Note, Windows users using git via git bash and the command line should take care to select the appropriate shell in the VS Code terminal. It will do to set git bash as the terminal by changing Settings: Terminal > Integrated > Defafult Profile: Windows and selecting it there. Thereafter, launching a terminal in VS Code will launch one with a git bash shell. You can see what's possible there in the configuration section and eventually configure your own setup for terminal in VS Code.
Note, rust users can install the mdbook extensions with cargo.
Learning Resources
There are pretty good learning resources for Lean 4. Here we connect you to both written documentation for Lean 4 and to two online communities where you can follow the leaders in the field and ask questions, often getting answers very quickly.
Help Searching Mathlib for Relevant Definitions
- Mathlib Documentation
- Loogle online. Also see the Loogle VSCode extension.
Written Documentation on Lean 4 and Related Tools and Ideas
- Lean Language Site
- Lean Documentation Overview
- Functional Programming in Lean.
- Theorem Proving in Lean 4
- Meta-Programming in Lean 4
- Mathematics in Lean
- A Glimpse of Lean
- Type Checking in Lean 4
- Some Examples in Lean4
- Kevin Buzzard's Intro
- Lean for the Curious Mathematician (2023 edition)
- Learning Resources
- The Type Theory of Lean
- Type Checking in Lean
- Hitchhikers Guide to Logical Verification
- Useful Tactics for Beginners
Books on Closely Related Concepts and Tools (mainly Coq)
- Software Foundations
- The Mechanics of Proof
- Homotopy Type Theory, Chapter 1
- Survey of Logic Symbols
- Certified Programming with Dependent Types
- Software Foundations