Monocurl Essays
Series Download

Truth tables: and, or, not as calculation

The word "or" has two meanings in English. Truth tables fix one, and turn questions about what sentences mean into finite calculations you can check row by row.

23 min·High school and early college·

Two circuits, each with a battery, a lamp and two switches $P$ and $Q$. With the switches in series (left) the lamp lights only when both are closed. With the switches in parallel (right) it lights when at least one is closed.
Monocurl source
let Series = |cx, p, q| {
    let lit = (p > 0.99) and (q > 0.99)
    return [
        Lamp([cx + 1.45, -0.05, 0], lit),
        Switch([cx - 0.95, 0.65, 0], 0.65, p),
        Switch([cx + 0.3, 0.65, 0], 0.65, q),
        TV([cx - 1.05, -1.55, 0], p > 0.5, AZURE, 0.52),
        TV([cx - 0.05, -1.55, 0], q > 0.5, AZURE, 0.52),
        TV([cx + 1.45, -1.55, 0], lit, EMBER, 0.52)
    ]
}
let Parallel = |cx, p, q| {
    let lit = (p > 0.99) or (q > 0.99)
    return [
        Lamp([cx + 1.45, -0.05, 0], lit),
        Switch([cx - 0.33, 1.15, 0], 0.65, p),
        Switch([cx - 0.33, 0.15, 0], 0.65, q),
        TV([cx - 1.05, -1.55, 0], p > 0.5, AZURE, 0.52),
        TV([cx - 0.05, -1.55, 0], q > 0.5, AZURE, 0.52),
        TV([cx + 1.45, -1.55, 0], lit, EMBER, 0.52)
    ]
}
let Legend = |cx, tex| [
    center{[cx - 1.5, -1.55, 0]} Math("P", 0.8, AZURE),
    center{[cx - 0.5, -1.55, 0]} Math("Q", 0.8, AZURE),
    center{[cx + 0.72, -1.55, 0]} Math(tex, 0.8, EMBER)
]
let CL = -2.05
let CR = 1.85
mesh wires = [
    Path([[CL - 0.95, 0.65, 0], [CL - 1.45, 0.65, 0], [CL - 1.45, -0.75, 0], [CL + 1.45, -0.75, 0], [CL + 1.45, 0.65, 0], [CL + 0.95, 0.65, 0]], SLATE, 3),
    Seg([CL - 0.3, 0.65, 0], [CL + 0.3, 0.65, 0], SLATE, 3),
    Battery([CL - 1.45, -0.05, 0]),
    Path([[CR - 1.0, 0.65, 0], [CR - 1.45, 0.65, 0], [CR - 1.45, -0.75, 0], [CR + 1.45, -0.75, 0], [CR + 1.45, 0.65, 0], [CR + 1.0, 0.65, 0]], SLATE, 3),
    Path([[CR - 0.33, 1.15, 0], [CR - 1.0, 1.15, 0], [CR - 1.0, 0.15, 0], [CR - 0.33, 0.15, 0]], SLATE, 3),
    Path([[CR + 0.32, 1.15, 0], [CR + 1.0, 1.15, 0], [CR + 1.0, 0.15, 0], [CR + 0.32, 0.15, 0]], SLATE, 3),
    Battery([CR - 1.45, -0.05, 0])
]
mesh labels = [
    center{[CL, 1.8, 0]} Cap("in series", 0.85, INK),
    center{[CR, 1.8, 0]} Cap("in parallel", 0.85, INK),
    center{[CL - 0.62, 0.3, 0]} Math("P", 0.85, AZURE),
    center{[CL + 0.62, 0.3, 0]} Math("Q", 0.85, AZURE),
    center{[CR, 0.82, 0]} Math("P", 0.85, AZURE),
    center{[CR, -0.2, 0]} Math("Q", 0.85, AZURE),
    Legend(CL, "P \land Q"),
    Legend(CR, "P \lor Q")
]
mesh left = Series(CL, p: 0, q: 0)
mesh right = Parallel(CR, p: 0, q: 0)

slide "run"
    play Wait(1.1)
    left.p = 1
    right.p = 1
    play Lerp(0.6)
    play Wait(1.1)
    left.p = 0
    right.p = 0
    left.q = 1
    right.q = 1
    play Lerp(0.6)
    play Wait(1.1)
    left.p = 1
    right.p = 1
    play Lerp(0.6)
    play Wait(2)

Uses the shared definitions for this lesson's figures.

A restaurant menu says that the set lunch comes with soup or salad. The manual of a fire alarm says that it sounds if it detects smoke or heat. Now let both things happen at once. Ask the waiter for soup and salad, and you will be told to choose one. Fill the kitchen with smoke and heat together, and nobody expects the alarm to stay quiet on the grounds that it was only promised one of them.

The same two-letter word did opposite jobs. On the menu, “or” ruled out having both, and in the manual it allowed both. English leaves the choice to context, and most of the time context is enough. It stops being enough when the sentence gets long (“the valve opens if the pressure is high and the pump is off or the override is set”), or when the sentence is a step in a proof and the wrong reading makes the proof false.

Logic repairs this in the bluntest way available. It lists every situation that could arise and records, for each one, whether the compound sentence counts as true. The list is called a truth table. Once sentences have tables, a question such as “do these two sentences say the same thing?” is settled by a finite calculation, and the ear is no longer consulted.

Statements, and the two meanings of “or”

First we have to say which sentences are allowed in.

“7 is a prime number” is a statement, with truth value T, and “London is in France” is one with truth value F. “Every even number greater than 2 is a sum of two primes” is a statement even though nobody knows its truth value, because it has one. “Close the door” and “Is it raining?” are not statements. Neither is “$x > 3$”, since whether it is true depends on an $x$ we have not been given; sentences with a free variable are handled later in this series, with quantifiers.

Now take the alarm. Write $S$ for “there is smoke” and $H$ for “there is heat”. At a given moment each is true or false, so there are four possible situations: both, smoke only, heat only, neither. What the alarm does is a decision in each of the four. Do the same for the menu, with “you take soup” and “you take salad”, and put the two side by side.

Two truth tables filled in row by row. On the left, the alarm sounds when there is smoke or heat. On the right, the lunch order is allowed when it has soup or salad. The two columns of results differ in exactly one row, the one where both parts are true.
Monocurl source
let XL = [-3.05, -2.15, -0.85]
let XR = [0.85, 1.75, 3.05]
let Y0 = 0.62
let DY = 0.64
let alarm = map(R2, |r| [r[0], r[1], OR(r[0], r[1])])
let menu = map(R2, |r| [r[0], r[1], AND(OR(r[0], r[1]), NOT(AND(r[0], r[1])))])
let COLS = [AZURE, AZURE, EMBER]
mesh heads = [
    center{[-1.95, 1.85, 0]} Cap("the alarm's %"or%"", 0.85, INK),
    center{[1.95, 1.85, 0]} Cap("the menu's %"or%"", 0.85, INK),
    map(range(0, 3), |c| center{[XL[c], 1.27, 0]} Cap(["smoke", "heat", "alarm"][c], 0.74, [AZURE, AZURE, EMBER][c])),
    map(range(0, 3), |c| center{[XR[c], 1.27 - 0.035 * (c == 0), 0]} Cap(["soup", "salad", "allowed"][c], 0.74, [AZURE, AZURE, EMBER][c]))
]
mesh rows_l = []
mesh rows_r = []
mesh verdict = []

slide "fill"
    play Wait(0.5)
    for (k in range(1, 5)) {
        rows_l = Rows(XL, Y0, DY, alarm, COLS, k)
        rows_r = Rows(XR, Y0, DY, menu, COLS, k)
        play Fade(0.45, [&rows_l, &rows_r])
        play Wait(0.6)
    }
    verdict = [
        FrameBox(XL[2] - 0.5, XL[2] + 0.5, Y0 - 0.36, Y0 + 0.36, ROSE),
        FrameBox(XR[2] - 0.5, XR[2] + 0.5, Y0 - 0.36, Y0 + 0.36, ROSE),
        center{[0, -1.9, 0]} Cap("they disagree only when both are true", 0.8, ROSE)
    ]
    play Fade(0.6, [&verdict])
    play Wait(2.2)

Uses the shared definitions for this lesson's figures.

The alarm’s column is true in three rows and the menu’s in two. They differ only in the first row, where both parts are true. Mathematicians had to pick one meaning for the bare word, and they picked the alarm’s: in mathematics “or” always means “one or the other or both”. This is the inclusive or. The menu’s meaning is the exclusive or, and we will build it out of other pieces in a moment.

Two more small words get the same treatment. “There is smoke and there is heat” is true in the first row only. “There is no smoke” is true exactly when $S$ is false.

The table is the definition. It makes no appeal to what “and” means in conversation, and that has a cost you should see clearly. A connective defined by a table can see only the truth values of its parts. “It is smoky but not hot” becomes $S \land \lnot H$: the note of surprise in “but” is dropped, because it has no effect on whether the sentence is true. And “because” cannot be given a table at all: knowing that $S$ and $H$ are both true does not tell you whether one caused the other.

Pause and try

Using only $\land$, $\lor$ and $\lnot$, write a formula in $P$ and $Q$ that has the menu’s column: true when exactly one of $P$, $Q$ is true. Check it in all four rows.

Show answerHide answer

One answer is $(P \lor Q) \land \lnot(P \land Q)$, “at least one, and not both”. In the row T, T it is $\text{T} \land \lnot \text{T}$, which is F. In the rows T, F and F, T it is $\text{T} \land \lnot \text{F}$, which is T. In the row F, F the first part is already F. Another answer is $(P \land \lnot Q) \lor (\lnot P \land Q)$, “the first without the second, or the second without the first”. Both are right, and we will soon say what that means.

Switches: a machine that computes the tables

The animation at the top of the page is these definitions built out of wire. A switch is closed or open, and the lamp is lit exactly when there is an unbroken path from the battery through the lamp and back. Let $P$ be the statement “switch $P$ is closed”, and likewise $Q$.

When the switches are in series, the current must pass through both, so the lamp is lit exactly when $P \land Q$ is true. When they are in parallel, either branch will carry the current, so the lamp is lit exactly when $P \lor Q$ is true. Compare the tiles under each circuit with the columns of the definition. For $\lnot P$, use a switch that is sprung the other way round, so that it is closed when its button is not pressed.

The circuit is a model of the table: a system with one part for each statement, two states for each part, and a behavior that agrees with the table in every row. Nothing about electricity enters the logic. What the model gives us is a second way to compute. In 1937 Claude Shannon observed that relay circuits obey exactly these tables, and digital circuits have been designed by this algebra ever since.

The table of a compound statement

Connectives can be nested, with parentheses to show the order, as in $\lnot(P \land Q)$. By convention $\lnot$ applies to the shortest thing after it, so $\lnot P \lor Q$ means $(\lnot P) \lor Q$. Between $\land$ and $\lor$ I will always write the parentheses.

A formula with $n$ different letters has a table with $2^n$ rows, because each letter is independently T or F: 4 rows for two letters, 8 for three. To fill in the table, work from the inside out, one column at a time, each new column computed from columns already there.

Here is a case that matters. An inspector reports of the kitchen: “it is not the case that there is both smoke and heat”. That is $\lnot(S \land H)$. What does it tell us about smoke and heat separately?

Pause and try

Which of these is the same as $\lnot(P \land Q)$: (a) $\lnot P \land \lnot Q$, or (b) $\lnot P \lor \lnot Q$? Before computing whole tables, test both in the single row where $P$ is true and $Q$ is false.

Show answerHide answer

In that row $P \land Q$ is F, so $\lnot(P \land Q)$ is T. Formula (a) gives $\text{F} \land \text{T}$, which is F, so (a) is not the same. Formula (b) gives $\text{F} \lor \text{T}$, which is T. One row can rule a candidate out but cannot rule one in; the figure checks (b) in the other three rows.

  1. Start with the four rows and the innermost part, P and Q. It is true in the first row only.
  2. Negate that column, flipping each entry. This is the column of not (P and Q).
  3. The tempting guess, (not P) and (not Q), is true only in the last row. It disagrees in rows two and three.
  4. The formula (not P) or (not Q) matches in all four rows.
The truth table of $\lnot(P \land Q)$ built one column at a time, then compared with two candidates. The guess $\lnot P \land \lnot Q$ is wrong in the two middle rows. The formula $\lnot P \lor \lnot Q$ has the same column in every row.
Monocurl source
let XS = [-3.2, -2.4, -1.2, 0.15, 1.6, 3.0]
let Y0 = 0.75
let DY = 0.72
let pv = map(R2, |r| r[0])
let qv = map(R2, |r| r[1])
let conj = map(R2, |r| AND(r[0], r[1]))
let target = map(R2, |r| NOT(AND(r[0], r[1])))
let guess = map(R2, |r| AND(NOT(r[0]), NOT(r[1])))
let right = map(R2, |r| OR(NOT(r[0]), NOT(r[1])))
let Head = |c, tex, col| center{[XS[c], 1.6, 0]} Math(tex, 0.78, col)
mesh frames = []
mesh base = [Head(0, "P", AZURE), Head(1, "Q", AZURE), Col(XS[0], Y0, DY, pv, AZURE), Col(XS[1], Y0, DY, qv, AZURE)]
mesh c_conj = []
mesh c_target = []
mesh c_guess = []
mesh c_right = []

slide "Start with the four rows and the innermost part, P and Q. It is true in the first row only."
    c_conj = [Head(2, "P \land Q", SLATE), Col(XS[2], Y0, DY, conj, SLATE)]
    play Fade(0.6)

slide "Negate that column, flipping each entry. This is the column of not (P and Q)."
    c_target = [Head(3, "\lnot(P \land Q)", EMBER), Col(XS[3], Y0, DY, target, EMBER)]
    play Fade(0.6)

slide "The tempting guess, (not P) and (not Q), is true only in the last row. It disagrees in rows two and three."
    c_guess = [Head(4, "\lnot P \land \lnot Q", ROSE), Col(XS[4], Y0, DY, guess, SLATE)]
    frames = map([1, 2], |r| FrameBox(XS[4] - 0.47, XS[4] + 0.47, Y0 - r * DY - 0.34, Y0 - r * DY + 0.34, ROSE))
    play Fade(0.6)

slide "The formula (not P) or (not Q) matches in all four rows."
    c_right = [Head(5, "\lnot P \lor \lnot Q", LEAF), Col(XS[5], Y0, DY, right, EMBER)]
    frames = [
        map([1, 2], |r| FrameBox(XS[4] - 0.47, XS[4] + 0.47, Y0 - r * DY - 0.34, Y0 - r * DY + 0.34, ROSE)),
        FrameBox(XS[3] - 0.5, XS[3] + 0.5, Y0 - 3 * DY - 0.38, Y0 + 0.38, LEAF),
        FrameBox(XS[5] - 0.5, XS[5] + 0.5, Y0 - 3 * DY - 0.38, Y0 + 0.38, LEAF)
    ]
    play Fade(0.6)

Uses the shared definitions for this lesson's figures.

The natural guess is to push the “not” inside the way a minus sign goes through a sum, and it fails. In words: “not both smoky and hot” does not say that the kitchen is neither. It says that at least one of the two is missing, and when a $\lnot$ moves inside the parentheses the $\land$ has to turn into $\lor$.

Logical equivalence

We have now twice met two formulas with the same column: the two formulas for the menu’s “or”, and the pair in the figure. That relationship deserves a name.

For example $P \lor \lnot P$ is a tautology and $P \land \lnot P$ is a contradiction; each has only two rows to check. Note that $\equiv$ is not a fourth connective. $A \equiv B$ is a claim about two formulas, and it is either established by comparing tables or refuted by a single row. Because a connective sees only truth values, a part of a formula may always be replaced by an equivalent part without changing the table of the whole. That is what makes the following laws usable as rules of algebra.

The eight-row truth table that proves the first distributive law, filled in row by row. The column for $P \land (Q \lor R)$ and the column for $(P \land Q) \lor (P \land R)$ agree in all eight rows.
Monocurl source
let XS = [-3.45, -2.9, -2.35, -1.5, -0.3, 0.9, 1.8, 3.0]
let Y0 = 1.2
let DY = 0.42
let vals = map(R3, |r| [
    r[0], r[1], r[2],
    OR(r[1], r[2]),
    AND(r[0], OR(r[1], r[2])),
    AND(r[0], r[1]),
    AND(r[0], r[2]),
    OR(AND(r[0], r[1]), AND(r[0], r[2]))
])
let COLS = [AZURE, AZURE, AZURE, SLATE, EMBER, SLATE, SLATE, EMBER]
mesh heads = map(range(0, 8), |c| center{[XS[c], 1.78, 0]} Math(["P", "Q", "R", "Q \lor R", "P \land (Q \lor R)", "P \land Q", "P \land R", "(P \land Q) \lor (P \land R)"][c], 0.7, COLS[c]))
mesh rows = []
mesh frames = []

slide "fill"
    play Wait(0.4)
    for (k in range(1, 9)) {
        rows = Rows(XS, Y0, DY, vals, COLS, k, 0.48, 0.34, 0.7)
        play Fade(0.35, [&rows])
        play Wait(0.3)
    }
    frames = [
        FrameBox(XS[4] - 0.36, XS[4] + 0.36, Y0 - 7 * DY - 0.25, Y0 + 0.25, LEAF),
        FrameBox(XS[7] - 0.36, XS[7] + 0.36, Y0 - 7 * DY - 0.25, Y0 + 0.25, LEAF)
    ]
    play Fade(0.6, [&frames])
    play Wait(2.2)

Uses the shared definitions for this lesson's figures.

A table is a proof because it is exhaustive: the rows cover every case there is, and each entry follows from the definitions.

The first distributive law has the shape of $a(b + c) = ab + ac$, with $\land$ as multiplication and $\lor$ as addition. The second has no counterpart in arithmetic, where $a + bc$ is not $(a + b)(a + c)$.

The switch model shows the first law as a fact about wiring. Build one circuit for each side and try all eight switch positions.

Two circuits for the two sides of the distributive law, run through all eight positions of the switches $P$, $Q$, $R$. The two switches marked $P$ move together. The lamps are lit in exactly the same positions.
Monocurl source
let CL = -2.1
let CR = 1.8
let Sides = |p, q, r| {
    let lit_l = (p > 0.99) and ((q > 0.99) or (r > 0.99))
    let lit_r = ((p > 0.99) and (q > 0.99)) or ((p > 0.99) and (r > 0.99))
    return [
        Lamp([CL + 1.5, -0.25, 0], lit_l),
        Switch([CL - 1.25, 0.55, 0], 0.65, p),
        Switch([CL + 0.2, 1.05, 0], 0.65, q),
        Switch([CL + 0.2, 0.05, 0], 0.65, r),
        Lamp([CR + 1.5, -0.25, 0], lit_r),
        Switch([CR - 0.95, 1.05, 0], 0.65, p),
        Switch([CR + 0.3, 1.05, 0], 0.65, q),
        Switch([CR - 0.95, 0.05, 0], 0.65, p),
        Switch([CR + 0.3, 0.05, 0], 0.65, r),
        TV([-1.0, -1.62, 0], p > 0.5, AZURE, 0.52),
        TV([0.25, -1.62, 0], q > 0.5, AZURE, 0.52),
        TV([1.5, -1.62, 0], r > 0.5, AZURE, 0.52)
    ]
}
mesh wires = [
    Path([[CL - 1.25, 0.55, 0], [CL - 1.4, 0.55, 0], [CL - 1.4, -0.95, 0], [CL + 1.5, -0.95, 0], [CL + 1.5, 0.55, 0], [CL + 1.25, 0.55, 0]], SLATE, 3),
    Seg([CL - 0.6, 0.55, 0], [CL - 0.2, 0.55, 0], SLATE, 3),
    Path([[CL + 0.2, 1.05, 0], [CL - 0.2, 1.05, 0], [CL - 0.2, 0.05, 0], [CL + 0.2, 0.05, 0]], SLATE, 3),
    Path([[CL + 0.85, 1.05, 0], [CL + 1.25, 1.05, 0], [CL + 1.25, 0.05, 0], [CL + 0.85, 0.05, 0]], SLATE, 3),
    Battery([CL - 1.4, -0.25, 0]),
    Path([[CR - 1.25, 0.55, 0], [CR - 1.4, 0.55, 0], [CR - 1.4, -0.95, 0], [CR + 1.5, -0.95, 0], [CR + 1.5, 0.55, 0], [CR + 1.25, 0.55, 0]], SLATE, 3),
    Path([[CR - 0.95, 1.05, 0], [CR - 1.25, 1.05, 0], [CR - 1.25, 0.05, 0], [CR - 0.95, 0.05, 0]], SLATE, 3),
    Path([[CR + 0.95, 1.05, 0], [CR + 1.25, 1.05, 0], [CR + 1.25, 0.05, 0], [CR + 0.95, 0.05, 0]], SLATE, 3),
    Seg([CR - 0.3, 1.05, 0], [CR + 0.3, 1.05, 0], SLATE, 3),
    Seg([CR - 0.3, 0.05, 0], [CR + 0.3, 0.05, 0], SLATE, 3),
    Battery([CR - 1.4, -0.25, 0])
]
mesh labels = [
    center{[CL, 1.82, 0]} Math("P \land (Q \lor R)", 0.85, INK),
    center{[CR, 1.82, 0]} Math("(P \land Q) \lor (P \land R)", 0.85, INK),
    center{[CL - 0.92, 0.22, 0]} Math("P", 0.8, AZURE),
    center{[CL + 0.52, 0.72, 0]} Math("Q", 0.8, AZURE),
    center{[CL + 0.52, -0.28, 0]} Math("R", 0.8, AZURE),
    center{[CR - 0.62, 0.72, 0]} Math("P", 0.8, AZURE),
    center{[CR + 0.62, 0.72, 0]} Math("Q", 0.8, AZURE),
    center{[CR - 0.62, -0.28, 0]} Math("P", 0.8, AZURE),
    center{[CR + 0.62, -0.28, 0]} Math("R", 0.8, AZURE),
    center{[-1.5, -1.62, 0]} Math("P", 0.85, AZURE),
    center{[-0.25, -1.62, 0]} Math("Q", 0.85, AZURE),
    center{[1.0, -1.62, 0]} Math("R", 0.85, AZURE)
]
mesh sides = Sides(p: 0, q: 0, r: 0)

slide "run"
    play Wait(0.7)
    for (k in range(1, 8)) {
        sides.p = R3[7 - k][0]
        sides.q = R3[7 - k][1]
        sides.r = R3[7 - k][2]
        play Lerp(0.45, [&sides])
        play Wait(0.75)
    }
    play Wait(1.5)

Uses the shared definitions for this lesson's figures.

To an engineer the equivalence says the two circuits are interchangeable, and the left one is cheaper, with three switches where the right one needs four.

Venn diagrams are truth tables too

There is a third way to see these laws, and it is the one most people meet first. Draw a box for all the situations under discussion, a circle enclosing those in which $P$ is true, and a second circle enclosing those in which $Q$ is true. Then $P \land Q$ is the overlap of the circles, $P \lor Q$ is everything inside at least one of them, and $\lnot P$ is everything outside the circle for $P$.

  1. The circles cut the box into four regions, labeled with the truth values of P and Q.
  2. P and Q is true in the overlap, the region T T.
  3. Its negation is true everywhere else: the other three regions.
  4. Start again. Not P is the two regions outside the circle for P.
  5. Add the regions outside the circle for Q. Together they cover everything except the overlap.
De Morgan’s law on a Venn diagram. The two circles cut the box into four regions, one for each row of the truth table. The region for $\lnot(P \land Q)$ is everything except the overlap, and so is “outside $P$” together with “outside $Q$”.
Monocurl source
let C1 = [-1.75, -0.2, 0]
let C2 = [-0.35, -0.2, 0]
let RAD = 1.2
let A = 0.948
let lens = flatten([
    map(sample(-A, A, 24), |t| [C1[0] + RAD * cos(t), C1[1] + RAD * sin(t), 0]),
    map(sample(PI - A + 0.03, PI + A - 0.03, 24), |t| [C2[0] + RAD * cos(t), C2[1] + RAD * sin(t), 0])
])
let Venn = |ff, po, qo, both| [
    center{[-1.05, -0.2, 0]} fill{ff} stroke{SLATE, 3} Rect([4.9, 3.4]),
    center{C1} fill{po} stroke{po, 1} Circle(RAD, 96),
    center{C2} fill{qo} stroke{qo, 1} Circle(RAD, 96),
    Region(lens, both),
    center{C1} fill{alpha{0} PAPER} stroke{AZURE, 4} Circle(RAD, 96),
    center{C2} fill{alpha{0} PAPER} stroke{EMBER, 4} Circle(RAD, 96)
]
let ON = tint(PLUM, 0.32)
let Words = |tex, note| [
    center{[2.6, 0.55, 0]} Math(tex, 0.95, PLUM),
    center{[2.6, -0.25, 0]} Note(note, 0.8)
]
mesh venn = Venn(ff: PAPER, po: PAPER, qo: PAPER, both: PAPER)
mesh names = [
    center{[-2.85, 1.05, 0]} Math("P", 0.95, AZURE),
    center{[0.75, 1.05, 0]} Math("Q", 0.95, EMBER)
]
mesh regions = []
mesh words = []

slide "The circles cut the box into four regions, labeled with the truth values of P and Q."
    regions = [
        center{[-2.3, -0.2, 0]} Cap("T F", 0.78, INK),
        center{[-1.05, -0.2, 0]} Cap("T T", 0.78, INK),
        center{[0.2, -0.2, 0]} Cap("F T", 0.78, INK),
        center{[-1.05, -1.62, 0]} Cap("F F", 0.78, INK)
    ]
    words = [center{[2.6, 0.55, 0]} Cap("4 regions", 0.82, INK), center{[2.6, 0.05, 0]} Cap("4 rows", 0.82, INK)]
    play Fade(0.6)

slide "P and Q is true in the overlap, the region T T."
    words = []
    play Fade(0.3, [&words])
    venn.both = ON
    play Lerp(0.7, [&venn])
    words = Words("P \land Q", "shaded: true")
    play Fade(0.4, [&words])

slide "Its negation is true everywhere else: the other three regions."
    words = []
    play Fade(0.3, [&words])
    venn.both = PAPER
    venn.ff = ON
    venn.po = ON
    venn.qo = ON
    play Lerp(0.7, [&venn])
    words = Words("\lnot(P \land Q)", "shaded: true")
    play Fade(0.4, [&words])

slide "Start again. Not P is the two regions outside the circle for P."
    words = []
    play Fade(0.3, [&words])
    venn.po = PAPER
    play Lerp(0.7, [&venn])
    words = Words("\lnot P", "outside P")
    play Fade(0.4, [&words])

slide "Add the regions outside the circle for Q. Together they cover everything except the overlap."
    words = []
    play Fade(0.3, [&words])
    venn.po = ON
    play Lerp(0.7, [&venn])
    words = Words("\lnot P \lor \lnot Q", "same three regions")
    play Fade(0.4, [&words])

Uses the shared definitions for this lesson's figures.

The picture is a proof, provided you see why. The four regions are the four rows, and shading a region is writing T in that row. A Venn argument is a truth table drawn in the plane, so it can be trusted exactly when the drawing contains a region for every combination of truth values. Two overlapping circles give all 4 combinations, and three in the usual arrangement give all 8. Four circles cannot give all 16, because four circles divide the plane into at most 14 regions, so for four letters the circle picture breaks down while the table carries on.

Worked examples

Every truth table has a formula

We have three connectives. The number of possible tables is much larger: a table on two letters has 4 rows, each of which may be T or F, so there are $2^4 = 16$ different columns, and on three letters there are $2^8 = 256$. Are some of them out of reach of $\land$, $\lor$ and $\lnot$?

The exclusive or shows what to do. Its column is true in the rows (T, F) and (F, T). The formula $P \land \lnot Q$ is true in the row (T, F) and in no other row. The formula $\lnot P \land Q$ is true in the row (F, T) and in no other. Join them with “or” and the result is true in exactly those two rows. The same recipe works on any column whatever. Here it is on a column with three letters: a vote among three sensors, true when at least two of them fire.

  1. A table that was not produced by a formula: the wanted column is true when at least two of the three letters are true.
  2. Pick out the rows where the wanted value is T. There are four.
  3. For each such row write an AND of three pieces: the letter where the row has T, its negation where it has F.
  4. Join the four ANDs with OR. The result is true in exactly the four chosen rows, so it has the wanted column.
Building a formula for a given truth table: true when at least two of $P$, $Q$, $R$ are true. Each true row gets a conjunction that is true in that row only, and their disjunction reproduces the column.
Monocurl source
let XS = [-3.4, -2.8, -2.2, -1.25]
let Y0 = 1.2
let DY = 0.43
let want = map(R3, |r| to_int(r[0] + r[1] + r[2] >= 2))
let lit = |r, k| [str_join(["\lnot ", ["P", "Q", "R"][k]]), ["P", "Q", "R"][k]][R3[r][k]]
let term = |r| str_join([lit(r, 0), lit(r, 1), lit(r, 2)], " \land ")
let same = |r, s| (R3[r][0] == R3[s][0]) and (R3[r][1] == R3[s][1]) and (R3[r][2] == R3[s][2])
let trues = filter(range(0, 8), |r| want[r] == 1)
let built = map(range(0, 8), |s| to_int(len(filter(trues, |r| same(r, s))) > 0))
let rowy = |r| Y0 - r * DY
mesh bands = []
mesh table = [
    map(range(0, 4), |c| center{[XS[c], 1.78, 0]} Math(["P", "Q", "R", "\text{wanted}"][c], 0.74, [AZURE, AZURE, AZURE, EMBER][c])),
    Rows(XS, Y0, DY, map(range(0, 8), |r| [R3[r][0], R3[r][1], R3[r][2], want[r]]), [AZURE, AZURE, AZURE, EMBER], 8, 0.5, 0.34, 0.7)
]
mesh terms = []
mesh result = []

slide "A table that was not produced by a formula: the wanted column is true when at least two of the three letters are true."
    play Fade(0.6)

slide "Pick out the rows where the wanted value is T. There are four."
    bands = map(trues, |r| center{[-0.05, rowy(r), 0]} fill{tint(LEAF, 0.16)} stroke{tint(LEAF, 0.16), 1} Rect([7.3, 0.4]))
    play Fade(0.6)

slide "For each such row write an AND of three pieces: the letter where the row has T, its negation where it has F."
    terms = map(trues, |r| center{[0.45, rowy(r), 0]} Math(term(r), 0.74, INK))
    play Fade(0.6)

slide "Join the four ANDs with OR. The result is true in exactly the four chosen rows, so it has the wanted column."
    result = [
        center{[2.75, 1.78, 0]} Math("\text{OR of the four}", 0.74, LEAF),
        Col(2.75, Y0, DY, built, LEAF, 0.5, 0.34, 0.7)
    ]
    play Fade(0.6)

Uses the shared definitions for this lesson's figures.

The formula produced by this proof is called the disjunctive normal form of the table. For the vote it is

$$ (P \land Q \land R) \lor (P \land Q \land \lnot R) \lor (P \land \lnot Q \land R) \lor (\lnot P \land Q \land R). $$

The theorem promises a formula and makes no promise of a short one. The vote is also given by $(P \land Q) \lor (P \land R) \lor (Q \land R)$, which is less than half as long. A set of connectives that can express every truth table is called functionally complete, and the theorem says that $\{\land, \lor, \lnot\}$ is such a set.

Pause and try

Write the disjunctive normal form for the table on $P$, $Q$ whose column, in the usual order of rows, is T, F, T, T. Then find a shorter formula with the same column.

Show answerHide answer

The true rows are (T, T), (F, T) and (F, F), so the normal form is $(P \land Q) \lor (\lnot P \land Q) \lor (\lnot P \land \lnot Q)$. For something shorter, look at the single false row instead: the column is false exactly when $P$ is true and $Q$ is false, so it is $\lnot(P \land \lnot Q)$, which by De Morgan and double negation is $\lnot P \lor Q$. This column belongs to “if $P$ then $Q$”, the subject of the next lesson.

One connective is enough

Three connectives suffice, and fewer will do. De Morgan’s second law, negated on both sides, gives $P \lor Q \equiv \lnot(\lnot P \land \lnot Q)$, so every $\lor$ can be traded for $\land$ and $\lnot$, and the pair $\{\land, \lnot\}$ is already complete. We cannot drop $\lnot$ and keep going (a problem below asks you to prove it), but we can fuse the two survivors into one. Define a new connective, written $P \uparrow Q$ and called NAND for “not and”, by

$$ P \uparrow Q \;=\; \lnot(P \land Q). $$

Its column is F, T, T, T: false only when both parts are true.

  1. A NAND gate: its output is F only when both inputs are T.
  2. Connect one input to both pins. The output is the negation of the input.
  3. Follow a NAND gate with a gate wired as NOT. The negations cancel, leaving P and Q.
  4. Negate each input with its own gate, then NAND the results. By De Morgan the output is P or Q.
The NAND gate and the three constructions from the proof. One gate with its pins joined gives NOT, two gates give AND, three give OR. Each table is computed from the wiring shown.
Monocurl source
let NAND = |p, q| 1 - p * q
# a gate whose flat back is centred at pos; pins at pos + [0, +-0.2 s], output at pos + [1.0 s, 0]
let Gate = |pos, s = 1| {
    var pts = [[pos[0], pos[1] - 0.4 * s, 0], [pos[0], pos[1] + 0.4 * s, 0], [pos[0] + 0.42 * s, pos[1] + 0.4 * s, 0]]
    for (t in sample(PI / 2 - 0.1, -PI / 2 + 0.1, 20)) {
        pts .= [pos[0] + 0.42 * s + 0.4 * s * cos(t), pos[1] + 0.4 * s * sin(t), 0]
    }
    pts .= [pos[0] + 0.42 * s, pos[1] - 0.4 * s, 0]
    return [
        Shape(pts, SLATE, 0.14, 3.5),
        Ring([pos[0] + 0.91 * s, pos[1], 0], SLATE, 0.09 * s, 3.5)
    ]
}
let TX = [1.35, 2.05, 2.75, 3.45]
let TY = 0.55
let TD = 0.62
let Tab = |texs, cols, vals, x0| [
    map(range(0, len(texs)), |c| center{[x0 + c * 0.72, 1.3, 0]} Math(texs[c], 0.72, cols[c])),
    map(range(0, len(texs)), |c| Col(x0 + c * 0.72, TY, TD, map(vals, |row| row[c]), cols[c], 0.56, 0.44, 0.74))
]
let S1 = [
    Seg([-3.3, 0.3, 0], [-2.0, 0.3, 0], AZURE, 3.5),
    Seg([-3.3, -0.3, 0], [-2.0, -0.3, 0], AZURE, 3.5),
    Seg([-0.5, 0, 0], [0.4, 0, 0], EMBER, 3.5),
    Gate([-2.0, 0, 0], 1.5),
    center{[-3.55, 0.3, 0]} Math("P", 0.9, AZURE),
    center{[-3.55, -0.3, 0]} Math("Q", 0.9, AZURE),
    center{[-0.2, 0.42, 0]} Math("P \uparrow Q", 0.9, EMBER),
    center{[-1.6, -1.35, 0]} Cap("a NAND gate", 0.8, INK),
    Tab(["P", "Q", "P \uparrow Q"], [AZURE, AZURE, EMBER], map(R2, |r| [r[0], r[1], NAND(r[0], r[1])]), 1.7)
]
let S2 = [
    Path([[-3.3, 0, 0], [-2.6, 0, 0], [-2.6, 0.3, 0], [-2.0, 0.3, 0]], AZURE, 3.5),
    Path([[-2.6, 0, 0], [-2.6, -0.3, 0], [-2.0, -0.3, 0]], AZURE, 3.5),
    Pt([-2.6, 0, 0], AZURE, 0.08),
    Seg([-0.5, 0, 0], [0.4, 0, 0], EMBER, 3.5),
    Gate([-2.0, 0, 0], 1.5),
    center{[-3.55, 0, 0]} Math("P", 0.9, AZURE),
    center{[-0.2, 0.42, 0]} Math("\lnot P", 0.9, EMBER),
    center{[-1.6, -1.35, 0]} Cap("NOT from one gate", 0.8, INK),
    Tab(["P", "P \uparrow P"], [AZURE, EMBER], map([[1], [0]], |r| [r[0], NAND(r[0], r[0])]), 2.05)
]
let S3 = [
    Seg([-3.3, 0.24, 0], [-2.9, 0.24, 0], AZURE, 3.5),
    Seg([-3.3, -0.24, 0], [-2.9, -0.24, 0], AZURE, 3.5),
    Path([[-1.7, 0, 0], [-1.35, 0, 0], [-1.35, 0.24, 0], [-0.95, 0.24, 0]], SLATE, 3.5),
    Path([[-1.35, 0, 0], [-1.35, -0.24, 0], [-0.95, -0.24, 0]], SLATE, 3.5),
    Pt([-1.35, 0, 0], SLATE, 0.08),
    Seg([0.25, 0, 0], [0.75, 0, 0], EMBER, 3.5),
    Gate([-2.9, 0, 0], 1.2),
    Gate([-0.95, 0, 0], 1.2),
    center{[-3.55, 0.24, 0]} Math("P", 0.9, AZURE),
    center{[-3.55, -0.24, 0]} Math("Q", 0.9, AZURE),
    center{[-1.5, 0.75, 0]} Math("P \uparrow Q", 0.76, SLATE),
    center{[0.3, 0.48, 0]} Math("P \land Q", 0.85, EMBER),
    center{[-1.4, -1.35, 0]} Cap("AND from two gates", 0.8, INK),
    Tab(["P", "Q", "P \uparrow Q", "\text{out}"], [AZURE, AZURE, SLATE, EMBER], map(R2, |r| [r[0], r[1], NAND(r[0], r[1]), NAND(NAND(r[0], r[1]), NAND(r[0], r[1]))]), 1.45)
]
let S4 = [
    Path([[-3.35, 0.95, 0], [-3.1, 0.95, 0], [-3.1, 1.13, 0], [-2.8, 1.13, 0]], AZURE, 3.5),
    Path([[-3.1, 0.95, 0], [-3.1, 0.77, 0], [-2.8, 0.77, 0]], AZURE, 3.5),
    Pt([-3.1, 0.95, 0], AZURE, 0.08),
    Path([[-3.35, -0.95, 0], [-3.1, -0.95, 0], [-3.1, -0.77, 0], [-2.8, -0.77, 0]], AZURE, 3.5),
    Path([[-3.1, -0.95, 0], [-3.1, -1.13, 0], [-2.8, -1.13, 0]], AZURE, 3.5),
    Pt([-3.1, -0.95, 0], AZURE, 0.08),
    Path([[-1.9, 0.95, 0], [-1.75, 0.95, 0], [-1.75, 0.22, 0], [-1.45, 0.22, 0]], SLATE, 3.5),
    Path([[-1.9, -0.95, 0], [-1.75, -0.95, 0], [-1.75, -0.22, 0], [-1.45, -0.22, 0]], SLATE, 3.5),
    Seg([-0.35, 0, 0], [0.15, 0, 0], EMBER, 3.5),
    Gate([-2.8, 0.95, 0], 0.9),
    Gate([-2.8, -0.95, 0], 0.9),
    Gate([-1.45, 0, 0], 1.1),
    center{[-3.6, 0.95, 0]} Math("P", 0.9, AZURE),
    center{[-3.6, -0.95, 0]} Math("Q", 0.9, AZURE),
    center{[-1.4, 1.2, 0]} Math("\lnot P", 0.76, SLATE),
    center{[-1.4, -1.2, 0]} Math("\lnot Q", 0.76, SLATE),
    center{[-0.2, 0.5, 0]} Math("P \lor Q", 0.85, EMBER),
    center{[-1.6, -1.85, 0]} Cap("OR from three gates", 0.8, INK),
    Tab(["P", "Q", "\lnot P", "\lnot Q", "\text{out}"], [AZURE, AZURE, SLATE, SLATE, EMBER], map(R2, |r| [r[0], r[1], NAND(r[0], r[0]), NAND(r[1], r[1]), NAND(NAND(r[0], r[0]), NAND(r[1], r[1]))]), 0.75)
]
mesh stage = []

slide "A NAND gate: its output is F only when both inputs are T."
    stage = S1
    play Fade(0.6)

slide "Connect one input to both pins. The output is the negation of the input."
    stage = []
    play Fade(0.3)
    stage = S2
    play Fade(0.6)

slide "Follow a NAND gate with a gate wired as NOT. The negations cancel, leaving P and Q."
    stage = []
    play Fade(0.3)
    stage = S3
    play Fade(0.6)

slide "Negate each input with its own gate, then NAND the results. By De Morgan the output is P or Q."
    stage = []
    play Fade(0.3)
    stage = S4
    play Fade(0.6)

Uses the shared definitions for this lesson's figures.

Chip designers care, because one kind of gate is the easiest thing to manufacture in bulk. The Apollo guidance computer was built entirely from a three-input relative of NAND called NOR, which a problem below investigates.

Where truth tables stop

A sentence must have a truth value before it can enter a table. “This sentence is false” has none that is consistent, since if it is true then it is false, and if it is false then it is true. And words such as “because” or “probably” are not connectives of this kind at all.

Tables grow fast. Twenty letters need $2^{20} = 1{,}048{,}576$ rows, and 300 letters need more rows than there are atoms in the observable universe. For formulas of that size, equivalences are established by algebra with the laws above.

Stepping back

The hard part of this subject is at the very start: agreeing to let a table, and nothing else, say what a connective means. After that everything was computation. Equivalence became “same column”, proof became “check every row”, and De Morgan’s laws became facts about small tables that can be used as algebra on large formulas. The reusable idea is that a claim about finitely many cases can be settled by listing the cases, and that a circuit or a Venn diagram proves something exactly when it is such a list in disguise. The next lesson applies the same method to the connective that people most often get wrong: “if ... then”.

Check yourself

Which formula is logically equivalent to $\lnot(P \lor Q)$?

Problems

Problems 1 and 2 repeat the moves of the lesson. Problems 3 and 4 ask you to combine them. Problems 5 and 6 go further.

Problem 1

Compute the truth table of $P \land (\lnot P \lor Q)$ and find a shorter equivalent formula. Then get the same answer with the distributive law.

Show solutionHide solution

In the rows (T, T), (T, F), (F, T), (F, F) the column of $\lnot P \lor Q$ is T, F, T, T. Taking “and” with the column of $P$, which is T, T, F, F, gives T, F, F, F. That is the column of $P \land Q$. By the distributive law, $P \land (\lnot P \lor Q) \equiv (P \land \lnot P) \lor (P \land Q)$; the first bracket is a contradiction, and “F or $X$” has the value of $X$, leaving $P \land Q$.

Problem 2

Use De Morgan’s laws to write the negation of each without a “not” in front of the whole thing. (a) “$n$ is even and $n$ is greater than 10.” (b) “I will take the bus or I will walk.” (c) $\lnot P \lor Q$.

Show solutionHide solution

(a) $\lnot(E \land G) \equiv \lnot E \lor \lnot G$: “$n$ is odd or $n$ is at most 10”. (b) $\lnot(B \lor W) \equiv \lnot B \land \lnot W$: “I will not take the bus and I will not walk”. (c) $\lnot(\lnot P \lor Q) \equiv \lnot\lnot P \land \lnot Q \equiv P \land \lnot Q$, using De Morgan and then double negation. Check (c) in the row $P$ true, $Q$ false: $\lnot P \lor Q$ is F, so its negation is T, and $P \land \lnot Q$ is $\text{T} \land \text{T}$, which is T.

Problem 3

A column on $P$, $Q$, $R$ is true exactly when an odd number of the three letters are true. Write its disjunctive normal form, and verify it in the rows (T, F, F) and (T, T, F).

Show solutionHide solution

The rows with an odd number of T’s are (T, T, T), (T, F, F), (F, T, F) and (F, F, T). So the normal form is

$$ (P \land Q \land R) \lor (P \land \lnot Q \land \lnot R) \lor (\lnot P \land Q \land \lnot R) \lor (\lnot P \land \lnot Q \land R). $$

In the row (T, F, F) the second conjunction is $\text{T} \land \text{T} \land \text{T}$, so the formula is T, as wanted. In the row (T, T, F) the first conjunction fails at $R$, the second at $\lnot Q$, and the third and fourth at $\lnot P$, so the formula is F, as wanted.

Problem 4

The connective NOR is defined by $P \downarrow Q = \lnot(P \lor Q)$. Express $\lnot P$, $P \lor Q$ and $P \land Q$ using $\downarrow$ alone, and conclude that NOR by itself is functionally complete.

Show solutionHide solution

Not: $P \downarrow P = \lnot(P \lor P) \equiv \lnot P$, because $P \lor P$ has the value of $P$. Or: $(P \downarrow Q) \downarrow (P \downarrow Q) \equiv \lnot(P \downarrow Q) = \lnot\lnot(P \lor Q) \equiv P \lor Q$. And: $(P \downarrow P) \downarrow (Q \downarrow Q) \equiv \lnot P \downarrow \lnot Q = \lnot(\lnot P \lor \lnot Q) \equiv \lnot\lnot P \land \lnot\lnot Q \equiv P \land Q$, by De Morgan and double negation. Since every truth table has a formula in $\land$, $\lor$, $\lnot$, and each of those can be rewritten with $\downarrow$, every truth table has a formula in $\downarrow$ alone.

Problem 5

Find the error. “Claim: $P \lor (Q \land R) \equiv (P \lor Q) \land R$. Proof: when all three letters are true both sides are true, and when all three are false both sides are false. So the two sides agree.” Give a row in which the two sides differ, and state the correct law.

Show solutionHide solution

The proof checks two rows out of eight. Take $P$ true, $Q$ false, $R$ false. The left side is $\text{T} \lor (\text{F} \land \text{F})$, which is T. The right side is $(\text{T} \lor \text{F}) \land \text{F}$, which is F. So the claim is false. The correct law distributes $P$ to both parts: $P \lor (Q \land R) \equiv (P \lor Q) \land (P \lor R)$. In the same row the right side of that is $\text{T} \land \text{T}$, which is T.

Problem 6

Prove that $\{\land, \lor\}$ without $\lnot$ is not functionally complete. (Hint: what is the truth value of a formula built from letters with only $\land$ and $\lor$ in the row where every letter is true?)

Show solutionHide solution

We show that every formula built from letters using only $\land$ and $\lor$ is true in the row where all letters are true. Argue by induction on the number of connectives: prove it for formulas with none, and show that if it holds for all formulas with fewer than $k$ connectives then it holds for those with $k$. A formula with no connectives is a single letter, which is true in that row. A formula with $k \ge 1$ connectives is $A \land B$ or $A \lor B$, where $A$ and $B$ have fewer connectives, so both are true in that row, and then $A \land B$ and $A \lor B$ are true there too. Now the column of $\lnot P$ has F in the row where $P$ is true. No formula in $\land$ and $\lor$ alone has an F in the all-true row, so none has the table of $\lnot P$.

Shared definitions used by this lesson's figures
# house kit: prepended to every essay figure after the std imports

# palette. AZURE and EMBER are the two actors, LEAF marks an answer, ROSE a problem
let PAPER = [1, 1, 1, 1]
let INK = [0.08, 0.09, 0.11, 1]
let SLATE = [0.40, 0.44, 0.50, 1]
let MIST = [0.85, 0.86, 0.88, 1]
let AZURE = [0.13, 0.42, 0.68, 1]
let EMBER = [0.89, 0.42, 0.12, 1]
let LEAF = [0.13, 0.55, 0.36, 1]
let ROSE = [0.78, 0.22, 0.24, 1]
let PLUM = [0.47, 0.30, 0.66, 1]
let GOLD = [0.85, 0.62, 0.08, 1]

background = PAPER

# an opaque pastel of col; use for fills so overlaps do not darken
let tint = |col, amount = 0.18| [
    1 - (1 - col[0]) * amount,
    1 - (1 - col[1]) * amount,
    1 - (1 - col[2]) * amount,
    1
]

# text: Cap for labels, Note for quiet labels, Math for typeset maths
let Cap = |txt, size = 0.75, col = INK| color{col} Text(txt, size, "Charter")
let Note = |txt, size = 0.65| color{SLATE} Text(txt, size, "Charter")
let Math = |tex, size = 0.9, col = INK| color{col} Tex(tex, size)

# a paper rectangle to sit behind text that crosses lines
let Plate = |pos, size| center{pos} fill{PAPER} stroke{alpha{0} PAPER, 0} Rect(size)

# shapes with fill and stroke both set, so no stray black appears
let Pt = |pos, col = INK, r = 0.075| center{pos} fill{col} stroke{col, 1} Circle(r, 48)
let Ring = |pos, col = INK, r = 0.1, w = 3| center{pos} fill{PAPER} stroke{col, w} Circle(r, 48)
let Seg = |a, b, col = INK, w = 3| stroke{col, w} Line(a, b)
let Path = |pts, col = INK, w = 3| stroke{col, w} Polyline(pts)
let Region = |pts, col| fill{col} stroke{alpha{0} col, 0} Polygon(pts)
let Outline = |pts, col = INK, w = 3| fill{alpha{0} PAPER} stroke{col, w} Polygon(pts)
let Shape = |pts, col, amount = 0.18, w = 3| fill{tint(col, amount)} stroke{col, w} Polygon(pts)
let Disc = |pos, r, col, amount = 0.18, w = 3| center{pos} fill{tint(col, amount)} stroke{col, w} Circle(r, 96)
let Box = |pos, size, col, amount = 0.18, w = 3| center{pos} fill{tint(col, amount)} stroke{col, w} Rect(size)
# a line from a to b ending in a small solid arrowhead
let Tip = |a, b, col = INK, w = 3, head = 0.17| {
    let d = normalize([b[0] - a[0], b[1] - a[1], 0])
    let n = [-d[1], d[0], 0]
    let base = [b[0] - d[0] * head, b[1] - d[1] * head, 0]
    return [
        stroke{col, w} Line(a, base),
        fill{col} stroke{col, 1} Polygon([
            b,
            [base[0] + n[0] * head * 0.42, base[1] + n[1] * head * 0.42, 0],
            [base[0] - n[0] * head * 0.42, base[1] - n[1] * head * 0.42, 0]
        ])
    ]
}

# plots. a frame is [x0, x1, y0, y1, centre, width, height]: a data window and the scene box it fills
let Frame = |x0, x1, y0, y1, c = [0, 0, 0], w = 6.4, h = 3.6| [x0, x1, y0, y1, c, w, h]
let fx = |fr, x| fr[4][0] + (x - (fr[0] + fr[1]) / 2) * fr[5] / (fr[1] - fr[0])
let fy = |fr, y| fr[4][1] + (y - (fr[2] + fr[3]) / 2) * fr[6] / (fr[3] - fr[2])
let at = |fr, x, y| [fx(fr, x), fy(fr, y), 0]

# axes through the data origin (or the nearest edge of the window), with optional names
let Axes = |fr, x_name = "", y_name = "", col = SLATE| {
    let ox = clamp(fr[0], 0, fr[1])
    let oy = clamp(fr[2], 0, fr[3])
    var parts = [
        Tip(at(fr, fr[0], oy), [fx(fr, fr[1]) + 0.25, fy(fr, oy), 0], col, 2.5, 0.15),
        Tip(at(fr, ox, fr[2]), [fx(fr, ox), fy(fr, fr[3]) + 0.25, 0], col, 2.5, 0.15)
    ]
    if (x_name != "") {
        parts .= center{[fx(fr, fr[1]) + 0.5, fy(fr, oy), 0]} Math(x_name, 0.8, col)
    }
    if (y_name != "") {
        parts .= center{[fx(fr, ox), fy(fr, fr[3]) + 0.5, 0]} Math(y_name, 0.8, col)
    }
    return parts
}

# tick marks with numbers at the listed data values
let XTicks = |fr, xs, col = SLATE, places = 0| block {
    let oy = fy(fr, clamp(fr[2], 0, fr[3]))
    for (x in xs) {
        . Seg([fx(fr, x), oy - 0.07, 0], [fx(fr, x), oy + 0.07, 0], col, 2)
        . center{[fx(fr, x), oy - 0.33, 0]} Math(to_string(x, places), 0.72, col)
    }
}
let YTicks = |fr, ys, col = SLATE, places = 0| block {
    let ox = fx(fr, clamp(fr[0], 0, fr[1]))
    for (y in ys) {
        . Seg([ox - 0.07, fy(fr, y), 0], [ox + 0.07, fy(fr, y), 0], col, 2)
        . center{[ox - 0.36, fy(fr, y), 0]} Math(to_string(y, places), 0.72, col)
    }
}

# faint grid lines at the listed data values
let GridLines = |fr, xs, ys, col = MIST| block {
    for (x in xs) {
        . Seg(at(fr, x, fr[2]), at(fr, x, fr[3]), col, 1.5)
    }
    for (y in ys) {
        . Seg(at(fr, fr[0], y), at(fr, fr[1], y), col, 1.5)
    }
}

# the graph of f over [a, b], drawn in the frame. values are clamped to the window
let Graph = |fr, f, a, b, col = AZURE, w = 4, n = 160|
    stroke{col, w} Polyline(map(sample(a, b, n), |x| at(fr, x, clamp(fr[2], f(x), fr[3]))))

# the region between the graph of f and the x axis over [a, b]
let AreaUnder = |fr, f, a, b, col = AZURE, amount = 0.2, n = 80| {
    var pts = [at(fr, a, 0)]
    for (x in sample(a, b, n)) {
        pts .= at(fr, x, clamp(fr[2], f(x), fr[3]))
    }
    pts .= at(fr, b, 0)
    return fill{tint(col, amount)} stroke{alpha{0} col, 0} Polygon(pts)
}

# rows of a two-letter and a three-letter truth table, T first
let R2 = [[1, 1], [1, 0], [0, 1], [0, 0]]
let R3 = [[1, 1, 1], [1, 1, 0], [1, 0, 1], [1, 0, 0], [0, 1, 1], [0, 1, 0], [0, 0, 1], [0, 0, 0]]
let NOT = |p| 1 - p
let AND = |p, q| p * q
let OR = |p, q| max(p, q)
# a truth value tile: T is solid, F is hollow
let TV = |pos, v, col = AZURE, w = 0.7, h = 0.48, size = 0.86| [
    center{pos} fill{[PAPER, col][to_int(v)]} stroke{[SLATE, col][to_int(v)], 2.5} Rect([w, h]),
    center{pos} Cap(["F", "T"][to_int(v)], size, [SLATE, PAPER][to_int(v)])
]
let Heads = |xs, y, texs, size = 0.8, col = INK| map(range(0, len(xs)), |c| center{[xs[c], y, 0]} Math(texs[c], size, col))
# one column of tiles going down from y0
let Col = |x, y0, dy, vals, col, w = 0.7, h = 0.48, size = 0.78| map(range(0, len(vals)), |r| TV([x, y0 - r * dy, 0], vals[r], col, w, h, size))
# the first n rows of a table; cols[c] is the tile colour of column c
let Rows = |xs, y0, dy, vals, cols, n, w = 0.7, h = 0.48, size = 0.78| block {
    for (r in range(0, n)) {
        for (c in range(0, len(xs))) {
            . TV([xs[c], y0 - r * dy, 0], vals[r][c], cols[c], w, h, size)
        }
    }
}
let FrameBox = |x0, x1, y0, y1, col, w = 4.5| Outline([[x0, y0, 0], [x1, y0, 0], [x1, y1, 0], [x0, y1, 0]], col, w)
# a switch whose fixed contact is at a; closed runs from 0 (open) to 1 (closed)
let Switch = |a, length, closed, col = AZURE| {
    let ang = (1 - closed) * 0.5
    return [
        Seg(a, [a[0] + length * cos(ang), a[1] + length * sin(ang), 0], col, 5.5),
        Pt(a, col, 0.075),
        Pt([a[0] + length, a[1], 0], col, 0.075)
    ]
}
let Lamp = |pos, lit| block {
    if (lit) {
        for (k in range(0, 8)) {
            let d = [cos(TAU * (k + 0.5) / 8), sin(TAU * (k + 0.5) / 8), 0]
            . Seg(pos + d * 0.38, pos + d * 0.56, GOLD, 4)
        }
    }
    . center{pos} fill{[PAPER, [1, 0.84, 0.25, 1]][to_int(lit)]} stroke{[SLATE, GOLD][to_int(lit)], 3.5} Circle(0.28, 48)
}
let Battery = |pos| [
    Plate(pos, [0.56, 0.17]),
    Seg([pos[0] - 0.26, pos[1] + 0.085, 0], [pos[0] + 0.26, pos[1] + 0.085, 0], SLATE, 3),
    Seg([pos[0] - 0.13, pos[1] - 0.085, 0], [pos[0] + 0.13, pos[1] - 0.085, 0], SLATE, 6.5)
]