Normal Forms: CNF and DNF
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
normal-forms.ts
import type { Tracer } from "../trace";import { clausesOf, dedupe, isTautologous, normalFormText, type Clause, type NormalForm, type NormalFormKind } from "./clauses";import { FormulaPrinter, assignments, evaluate, variables, type Formula } from "./formula";export type RewriteRule = "eliminate-implication" | "push-negation" | "distribute";export interface RewriteStep { rule: RewriteRule; /** The subformula rewritten and what it became. */ before: string; after: string; /** The whole formula after the step. */ formula: string;}export interface NormalFormReport { form: NormalForm; text: string; /** The rewrite route's steps; empty on the table route. */ steps: RewriteStep[];}/** The most rewrite steps a formula may take before the converter gives up: distribution can blow up. */export const MAX_REWRITES = 64;/** * Two ways to the normal forms. The table route reads the form off the * truth table: a disjunctive normal form is one term per true row, * the literals that pick that row out; a conjunctive normal form is * one clause per false row, the literals that rule that row out. The * rewrite route transforms the formula itself by equivalences, one * node at a time: arrows become ors, negations are pushed down to the * variables by De Morgan's laws, and then or is distributed over and * (for CNF) or and over or (for DNF) until the shape is right. Both * routes give an equivalent formula; the table route's can be larger, * the rewrite route's can blow up exponentially on the distribution. * * Time: O(2^n) for the table route; up to exponential for the rewrite route. Space: the same. */export class NormalFormConverter { constructor(private readonly tracer?: Tracer) {} /** The form read off the truth table, row by row. */ fromTable(formula: Formula, kind: NormalFormKind): NormalFormReport { const names = variables(formula); const clauses: Clause[] = []; assignments(names).forEach((values, row) => { const value = evaluate(formula, values); if (kind === "dnf" && value) { // The term true on exactly this row: each variable as it stands here. const term: Clause = names.map((name) => ({ name, negated: !values.get(name) })); clauses.push(term); // ▸ a true row becomes a term: every variable as it stands on that row this.tracer?.emit("read-true-row", { row, values: Object.fromEntries(values), clause: term.map((l) => ({ ...l })), count: clauses.length }); } if (kind === "cnf" && !value) { // The clause false on exactly this row: each variable negated as it stands here. const clause: Clause = names.map((name) => ({ name, negated: values.get(name) ?? false })); clauses.push(clause); // ▸ a false row becomes a clause: every variable negated as it stands on that row this.tracer?.emit("read-false-row", { row, values: Object.fromEntries(values), clause: clause.map((l) => ({ ...l })), count: clauses.length }); } }); return { ...this.assemble(kind, clauses, "table"), steps: [] }; } /** The form reached by rewriting the formula with equivalences, one node at a time. */ rewrite(formula: Formula, kind: NormalFormKind): NormalFormReport { let current = formula; let formulaSoFar = formula; const steps: RewriteStep[] = []; const rules: RewriteRule[] = ["eliminate-implication", "push-negation", "distribute"]; for (const rule of rules) { for (;;) { if (steps.length >= MAX_REWRITES) throw new RangeError(`more than ${MAX_REWRITES} rewrites: the form blows up`); const hit = NormalFormConverter.apply(current, rule, kind); const matchedNode = NormalFormConverter.apply(formulaSoFar, rule, kind); if (!hit) break; if (!matchedNode) break; current = hit.formula; formulaSoFar = matchedNode.formula; const step: RewriteStep = { rule, before: FormulaPrinter.print(hit.before), after: FormulaPrinter.print(hit.after), formula: FormulaPrinter.print(current) }; const step: RewriteStep = { rule, before: FormulaPrinter.print(matchedNode.before), after: FormulaPrinter.print(matchedNode.after), formula: FormulaPrinter.print(formulaSoFar) }; steps.push(step); const payload = { before: step.before, after: step.after, formula: step.formula, step: steps.length }; // ▸ an arrow is a not and an or; an iff is two arrows if (rule === "eliminate-implication") this.tracer?.emit("eliminate-implication", payload); // ▸ a not over a not vanishes; over an and or an or it flips the connective and sinks else if (rule === "push-negation") this.tracer?.emit("push-negation", payload); // ▸ or over and gives two ors (for CNF); and over or gives two ands (for DNF) else this.tracer?.emit("distribute", payload); } } const clauses = clausesOf(current, kind).map(dedupe).filter((clause) => !isTautologous(clause)); const clauses = clausesOf(formulaSoFar, kind).map(dedupe).filter((clause) => !isTautologous(clause)); return { ...this.assemble(kind, clauses, "rewrite"), steps }; } /** The clauses joined into the form, printed once for both routes. */ private assemble(kind: NormalFormKind, clauses: Clause[], route: "table" | "rewrite"): { form: NormalForm; text: string } { const form: NormalForm = { kind, clauses }; const text = normalFormText(form); // ▸ the clauses joined: ands of ors for CNF, ors of ands for DNF this.tracer?.emit("assemble", { kind, clauses: clauses.length, text, route }); return { form, text }; } /** The first node (outermost, left first) the rule applies to, rewritten; null when the rule has nothing left to do. */ private static apply(formula: Formula, rule: RewriteRule, kind: NormalFormKind): { formula: Formula; before: Formula; after: Formula } | null { const after = NormalFormConverter.rewriteNode(formula, rule, kind); if (after) return { formula: after, before: formula, after }; if (formula.kind === "var") return null; if (formula.kind === "not") { const inner = NormalFormConverter.apply(formula.operand, rule, kind); return inner ? { formula: { kind: "not", operand: inner.formula }, before: inner.before, after: inner.after } : null; } const left = NormalFormConverter.apply(formula.left, rule, kind); if (left) return { formula: { kind: formula.kind, left: left.formula, right: formula.right }, before: left.before, after: left.after }; const right = NormalFormConverter.apply(formula.right, rule, kind); if (right) return { formula: { kind: formula.kind, left: formula.left, right: right.formula }, before: right.before, after: right.after }; return null; } /** The rule applied at this node alone, or null when it does not match here. */ private static rewriteNode(f: Formula, rule: RewriteRule, kind: NormalFormKind): Formula | null { if (rule === "eliminate-implication") { if (f.kind === "implies") return { kind: "or", left: { kind: "not", operand: f.left }, right: f.right }; if (f.kind === "iff") return { kind: "and", left: { kind: "or", left: { kind: "not", operand: f.left }, right: f.right }, right: { kind: "or", left: { kind: "not", operand: f.right }, right: f.left } }; return null; } if (rule === "push-negation") { if (f.kind !== "not") return null; const inner = f.operand; if (inner.kind === "not") return inner.operand; if (inner.kind === "and") return { kind: "or", left: { kind: "not", operand: inner.left }, right: { kind: "not", operand: inner.right } }; if (inner.kind === "or") return { kind: "and", left: { kind: "not", operand: inner.left }, right: { kind: "not", operand: inner.right } }; return null; } // Distribute: for CNF, or over and; for DNF, and over or. const outer = kind === "cnf" ? "or" : "and"; const inner = kind === "cnf" ? "and" : "or"; if (f.kind !== outer) return null; if (f.right.kind === inner) return { kind: inner, left: { kind: outer, left: f.left, right: f.right.left }, right: { kind: outer, left: f.left, right: f.right.right } }; if (f.left.kind === inner) return { kind: inner, left: { kind: outer, left: f.left.left, right: f.right }, right: { kind: outer, left: f.left.right, right: f.right } }; return null; }}(not p or not q) and (p or q): 2 clauses, 4 literals; equivalent to the original on every row.