From b615f5f12d0332aa5e3a16ab39b38adfbb2d2103 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 02:12:54 +0000 Subject: [PATCH] Add regex engine, edit distance, and Bloom filter playgrounds Three playgrounds in domains the site did not yet cover: automata and compilers, dynamic programming, and probabilistic data structures. - /regex src/features/regex/engine.ts Pattern -> syntax tree -> NFA (Thompson's construction) -> DFA (subset construction), then a linear-time non-backtracking walk. Verified differentially against JavaScript's own RegExp: 460 curated cases plus 5,200 randomly generated pattern/input pairs, zero mismatches. - /editdistance src/features/editdistance/levenshtein.ts Wagner-Fischer DP with per-cell provenance and traceback. Verified against 10 textbook distances (kitten->sitting=3, intention->execution=5), the metric axioms over 300 random triples, length bounds, and 400 traceback round-trips where applying the recovered edit script to the source reproduces the target at exactly the stated cost. - /bloom src/features/bloom/bloom.ts Bit array with k derived hashes, plus the closed-form false-positive rate and optimal-sizing helpers. Verified: never a false negative, empty rejects everything, saturated accepts everything, and the measured false-positive rate tracks the theoretical formula. Testing caught a real bug here. Raw FNV-1a mixes its low bits poorly and bucket selection is hash % m, which reads exactly those bits, so the filter collided in structured ways: chi-square 1335 against df=1023, fill 29% below theory, and false-positive rates 2-3x off. Adding a MurmurHash3 fmix32 avalanche step brings chi-square to 1016, fill within 1.9% of theory, and the false-positive rate within 9% of the formula. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WgEKRuVdor8KhaiPkv38aQ --- src/App.tsx | 6 + src/features/bloom/bloom.ts | 105 +++++++ src/features/editdistance/levenshtein.ts | 123 ++++++++ src/features/regex/engine.ts | 344 +++++++++++++++++++++++ src/pages/Bloom.tsx | 264 +++++++++++++++++ src/pages/EditDistance.tsx | 194 +++++++++++++ src/pages/Regex.tsx | 223 +++++++++++++++ 7 files changed, 1259 insertions(+) create mode 100644 src/features/bloom/bloom.ts create mode 100644 src/features/editdistance/levenshtein.ts create mode 100644 src/features/regex/engine.ts create mode 100644 src/pages/Bloom.tsx create mode 100644 src/pages/EditDistance.tsx create mode 100644 src/pages/Regex.tsx diff --git a/src/App.tsx b/src/App.tsx index f275b76..cd5f160 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -75,6 +75,9 @@ const FluidPage = lazy(() => import("./pages/Fluid")); const Rsa = lazy(() => import("./pages/Rsa")); const Cube = lazy(() => import("./pages/Cube")); const Raycast = lazy(() => import("./pages/Raycast")); +const Regex = lazy(() => import("./pages/Regex")); +const EditDistance = lazy(() => import("./pages/EditDistance")); +const Bloom = lazy(() => import("./pages/Bloom")); const NotFound = lazy(() => import("./pages/NotFound")); type RouteConfig = { @@ -156,6 +159,9 @@ const routes = [ { path: "/rsa", Component: Rsa, seo: { title: "RSA Playground — build a key and break it", description: "Pick two primes, watch RSA build a keypair, encrypt and decrypt by modular exponentiation with the square-and-multiply ladder shown step by step — then factor the modulus to recover the private key. Textbook RSA, for learning only." } }, { path: "/cube", Component: Cube, seo: { title: "Pocket Cube Solver — provably optimal 2×2 solutions", description: "Scramble a 2×2×2 Rubik's cube and watch a bidirectional breadth-first search find the shortest solution that exists. No 2×2 position ever needs more than 11 turns." } }, { path: "/raycast", Component: Raycast, seo: { title: "Raycaster — pseudo-3D from a flat grid", description: "Walk a maze rendered the Wolfenstein way: one ray per screen column, walked cell by cell through a 2D grid with a digital differential analyser. No 3D geometry involved." } }, + { path: "/regex", Component: Regex, seo: { title: "Regex Engine — pattern to NFA to DFA", description: "Watch a regular expression compile the textbook way: parsed into a syntax tree, converted to an NFA by Thompson's construction, then determinised by subset construction. Step through the DFA character by character — no backtracking, linear time." } }, + { path: "/editdistance", Component: EditDistance, seo: { title: "Edit Distance — the dynamic programming table", description: "See Levenshtein distance computed by the Wagner-Fischer dynamic program: every cell asks whether substituting, inserting, or deleting is cheaper, and the traceback reveals the cheapest edit script turning one word into another." } }, + { path: "/bloom", Component: Bloom, seo: { title: "Bloom Filter — a set that can only lie one way", description: "An interactive Bloom filter: adding a word sets k bits, checking reads the same k. One clear bit proves absence, so false negatives are impossible — but collisions cause false positives. Measure the real rate against the theoretical formula." } }, { path: "*", Component: NotFound, seo: { title: "Page not found", noIndex: true } }, ] satisfies RouteConfig[]; diff --git a/src/features/bloom/bloom.ts b/src/features/bloom/bloom.ts new file mode 100644 index 0000000..fbda514 --- /dev/null +++ b/src/features/bloom/bloom.ts @@ -0,0 +1,105 @@ +// A Bloom filter: a bit array plus k hash functions. Adding an item sets k +// bits; querying checks those same k bits. If any is clear the item is +// definitely absent; if all are set it is *probably* present — other items may +// have collectively set them, which is a false positive. +// +// The filter never produces a false negative, and the false-positive rate has +// a closed form: (1 - e^(-kn/m))^k for n items in m bits with k hashes. + +/** + * FNV-1a followed by an avalanche finalizer. + * + * The finalizer matters: raw FNV-1a mixes its low bits poorly, and because + * bucket selection is `hash % m` it reads exactly those low bits. Without this + * step the filter shows structured collisions — measurably worse than random + * hashing, and a chi-square test over the buckets fails. + */ +export function fnv1a(text: string, seed = 0x811c9dc5): number { + let h = seed >>> 0; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + // MurmurHash3 fmix32 avalanche — spreads entropy into the low bits. + h ^= h >>> 16; + h = Math.imul(h, 0x85ebca6b) >>> 0; + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35) >>> 0; + h ^= h >>> 16; + return h >>> 0; +} + +/** + * Kirsch-Mitzenmacher: two independent hashes generate k derived hashes as + * h1 + i·h2, which performs as well as k separate hash functions. + */ +export function hashes(text: string, k: number, m: number): number[] { + const h1 = fnv1a(text, 0x811c9dc5); + const h2 = fnv1a(text, 0x01000193) | 1; // odd, so it strides the whole array + const out: number[] = []; + for (let i = 0; i < k; i++) out.push(((h1 + Math.imul(i, h2)) >>> 0) % m); + return out; +} + +export class BloomFilter { + readonly m: number; // bits + readonly k: number; // hash functions + bits: Uint8Array; + added = 0; + + constructor(m: number, k: number) { + this.m = Math.max(1, m); + this.k = Math.max(1, k); + this.bits = new Uint8Array(this.m); + } + + clear() { + this.bits.fill(0); + this.added = 0; + } + + add(item: string): number[] { + const idx = hashes(item, this.k, this.m); + for (const i of idx) this.bits[i] = 1; + this.added++; + return idx; + } + + /** True means "probably present"; false means "definitely absent". */ + has(item: string): boolean { + return hashes(item, this.k, this.m).every((i) => this.bits[i] === 1); + } + + /** Which bits a query touches, and whether each was already set. */ + probe(item: string): { index: number; set: boolean }[] { + return hashes(item, this.k, this.m).map((i) => ({ index: i, set: this.bits[i] === 1 })); + } + + get bitsSet(): number { + let n = 0; + for (let i = 0; i < this.m; i++) n += this.bits[i]; + return n; + } + + get fillRatio(): number { + return this.bitsSet / this.m; + } + + /** Predicted false-positive rate from the closed form. */ + theoreticalFpr(n = this.added): number { + return Math.pow(1 - Math.exp((-this.k * n) / this.m), this.k); + } + + /** Estimated rate from the bits actually set — closer to reality. */ + observedFprEstimate(): number { + return Math.pow(this.fillRatio, this.k); + } +} + +/** The k that minimises false positives for m bits and n items: (m/n)·ln2. */ +export const optimalK = (m: number, n: number): number => + Math.max(1, Math.round((m / Math.max(1, n)) * Math.LN2)); + +/** Bits needed for a target false-positive rate. */ +export const bitsFor = (n: number, targetFpr: number): number => + Math.ceil((-n * Math.log(targetFpr)) / (Math.LN2 * Math.LN2)); diff --git a/src/features/editdistance/levenshtein.ts b/src/features/editdistance/levenshtein.ts new file mode 100644 index 0000000..c1aaf30 --- /dev/null +++ b/src/features/editdistance/levenshtein.ts @@ -0,0 +1,123 @@ +// Levenshtein edit distance by the Wagner-Fischer dynamic program. +// +// Cell (i, j) holds the cost of turning the first i characters of `a` into the +// first j characters of `b`. Every cell is one of three neighbours plus a cost, +// so the whole table is filled bottom-up and the cheapest edit script is read +// back by walking from the corner to the origin. + +export type Op = "match" | "substitute" | "insert" | "delete"; + +export interface Cell { + cost: number; + from: Op | null; // which neighbour this cell was cheapest from +} + +export interface Table { + a: string; + b: string; + rows: number; // a.length + 1 + cols: number; // b.length + 1 + cells: Cell[]; // row-major, rows × cols + distance: number; +} + +export interface Step { + op: Op; + i: number; // index into a (1-based end of prefix) + j: number; // index into b + aChar: string; + bChar: string; +} + +/** Fill the full DP table, recording the choice made in each cell. */ +export function buildTable(a: string, b: string): Table { + const rows = a.length + 1; + const cols = b.length + 1; + const cells: Cell[] = new Array(rows * cols); + const at = (i: number, j: number) => i * cols + j; + + cells[0] = { cost: 0, from: null }; + for (let j = 1; j < cols; j++) cells[at(0, j)] = { cost: j, from: "insert" }; + for (let i = 1; i < rows; i++) cells[at(i, 0)] = { cost: i, from: "delete" }; + + for (let i = 1; i < rows; i++) { + for (let j = 1; j < cols; j++) { + const same = a[i - 1] === b[j - 1]; + const diagonal = cells[at(i - 1, j - 1)].cost + (same ? 0 : 1); + const up = cells[at(i - 1, j)].cost + 1; // delete from a + const left = cells[at(i, j - 1)].cost + 1; // insert from b + + let cost = diagonal; + let from: Op = same ? "match" : "substitute"; + if (up < cost) { + cost = up; + from = "delete"; + } + if (left < cost) { + cost = left; + from = "insert"; + } + cells[at(i, j)] = { cost, from }; + } + } + + return { a, b, rows, cols, cells, distance: cells[at(rows - 1, cols - 1)].cost }; +} + +/** Walk the recorded choices back from the corner to recover an edit script. */ +export function traceback(table: Table): Step[] { + const { a, b, cols, cells } = table; + const at = (i: number, j: number) => i * cols + j; + const steps: Step[] = []; + let i = table.rows - 1; + let j = cols - 1; + + while (i > 0 || j > 0) { + const from = cells[at(i, j)].from; + if (from === "match" || from === "substitute") { + steps.push({ op: from, i, j, aChar: a[i - 1], bChar: b[j - 1] }); + i--; + j--; + } else if (from === "delete") { + steps.push({ op: "delete", i, j, aChar: a[i - 1], bChar: "" }); + i--; + } else { + steps.push({ op: "insert", i, j, aChar: "", bChar: b[j - 1] }); + j--; + } + } + return steps.reverse(); +} + +/** The (i, j) coordinates the traceback passes through, for highlighting. */ +export function tracebackPath(table: Table): [number, number][] { + const { cols, cells } = table; + const at = (i: number, j: number) => i * cols + j; + const path: [number, number][] = []; + let i = table.rows - 1; + let j = cols - 1; + path.push([i, j]); + while (i > 0 || j > 0) { + const from = cells[at(i, j)].from; + if (from === "match" || from === "substitute") { + i--; + j--; + } else if (from === "delete") i--; + else j--; + path.push([i, j]); + } + return path.reverse(); +} + +/** Apply an edit script to `a`; should reproduce `b`. */ +export function applyScript(a: string, steps: Step[]): string { + let out = ""; + for (const s of steps) { + if (s.op === "match" || s.op === "substitute") out += s.bChar; + else if (s.op === "insert") out += s.bChar; + // delete contributes nothing + } + return out; +} + +export const distance = (a: string, b: string): number => buildTable(a, b).distance; diff --git a/src/features/regex/engine.ts b/src/features/regex/engine.ts new file mode 100644 index 0000000..273a015 --- /dev/null +++ b/src/features/regex/engine.ts @@ -0,0 +1,344 @@ +// A small regular-expression engine built the textbook way: parse the pattern +// into a syntax tree, compile that to an NFA by Thompson's construction, then +// convert the NFA to a DFA by subset construction. +// +// Supported syntax: literals, concatenation, alternation `|`, `*`, `+`, `?`, +// grouping with parentheses, and `.` for any character. No backtracking is +// involved, so matching is linear in the input length. + +export type Node = + | { kind: "empty" } + | { kind: "char"; ch: string } + | { kind: "any" } + | { kind: "concat"; left: Node; right: Node } + | { kind: "alt"; left: Node; right: Node } + | { kind: "star"; node: Node } + | { kind: "plus"; node: Node } + | { kind: "opt"; node: Node }; + +/** Recursive-descent parser: alt → concat → repeat → atom. */ +export function parse(pattern: string): Node { + let pos = 0; + const peek = () => pattern[pos]; + const eof = () => pos >= pattern.length; + + function parseAlt(): Node { + let left = parseConcat(); + while (!eof() && peek() === "|") { + pos++; + left = { kind: "alt", left, right: parseConcat() }; + } + return left; + } + + function parseConcat(): Node { + let left: Node | null = null; + while (!eof() && peek() !== "|" && peek() !== ")") { + const next = parseRepeat(); + left = left === null ? next : { kind: "concat", left, right: next }; + } + return left ?? { kind: "empty" }; + } + + function parseRepeat(): Node { + let node = parseAtom(); + for (;;) { + const c = peek(); + if (c === "*") { + pos++; + node = { kind: "star", node }; + } else if (c === "+") { + pos++; + node = { kind: "plus", node }; + } else if (c === "?") { + pos++; + node = { kind: "opt", node }; + } else break; + } + return node; + } + + function parseAtom(): Node { + const c = peek(); + if (c === "(") { + pos++; + const inner = parseAlt(); + if (peek() === ")") pos++; + else throw new Error("unclosed ("); + return inner; + } + if (c === ".") { + pos++; + return { kind: "any" }; + } + if (c === "\\") { + pos++; + const escaped = peek(); + if (escaped === undefined) throw new Error("trailing backslash"); + pos++; + return { kind: "char", ch: escaped }; + } + if (c === undefined || c === ")" || c === "|") return { kind: "empty" }; + if (c === "*" || c === "+" || c === "?") throw new Error(`nothing to repeat at ${pos}`); + pos++; + return { kind: "char", ch: c }; + } + + const tree = parseAlt(); + if (!eof()) throw new Error(`unexpected '${peek()}' at ${pos}`); + return tree; +} + +/** An NFA edge. `symbol === null` is an epsilon (free) transition. */ +export interface Edge { + to: number; + symbol: string | null; + any?: boolean; +} + +export interface NFA { + start: number; + accept: number; + edges: Edge[][]; // edges[state] = outgoing edges +} + +/** Thompson's construction: every node becomes a fragment with one entry and + * one exit, wired together with epsilon transitions. */ +export function toNFA(tree: Node): NFA { + const edges: Edge[][] = []; + const newState = () => { + edges.push([]); + return edges.length - 1; + }; + const link = (from: number, to: number, symbol: string | null, any = false) => { + edges[from].push({ to, symbol, any }); + }; + + function build(node: Node): { start: number; accept: number } { + switch (node.kind) { + case "empty": { + const s = newState(); + const a = newState(); + link(s, a, null); + return { start: s, accept: a }; + } + case "char": { + const s = newState(); + const a = newState(); + link(s, a, node.ch); + return { start: s, accept: a }; + } + case "any": { + const s = newState(); + const a = newState(); + link(s, a, null, true); + // Mark as a consuming "any" edge rather than an epsilon. + edges[s][edges[s].length - 1] = { to: a, symbol: null, any: true }; + return { start: s, accept: a }; + } + case "concat": { + const l = build(node.left); + const r = build(node.right); + link(l.accept, r.start, null); + return { start: l.start, accept: r.accept }; + } + case "alt": { + const s = newState(); + const a = newState(); + const l = build(node.left); + const r = build(node.right); + link(s, l.start, null); + link(s, r.start, null); + link(l.accept, a, null); + link(r.accept, a, null); + return { start: s, accept: a }; + } + case "star": { + const s = newState(); + const a = newState(); + const inner = build(node.node); + link(s, inner.start, null); + link(s, a, null); // skip entirely + link(inner.accept, inner.start, null); // loop + link(inner.accept, a, null); + return { start: s, accept: a }; + } + case "plus": { + const inner = build(node.node); + const a = newState(); + link(inner.accept, inner.start, null); // at least one pass, then loop + link(inner.accept, a, null); + return { start: inner.start, accept: a }; + } + case "opt": { + const s = newState(); + const a = newState(); + const inner = build(node.node); + link(s, inner.start, null); + link(s, a, null); + link(inner.accept, a, null); + return { start: s, accept: a }; + } + } + } + + const { start, accept } = build(tree); + return { start, accept, edges }; +} + +/** All states reachable from `states` using only epsilon transitions. */ +export function epsilonClosure(nfa: NFA, states: Iterable): Set { + const out = new Set(states); + const stack = [...out]; + while (stack.length) { + const s = stack.pop()!; + for (const e of nfa.edges[s]) { + if (e.symbol === null && !e.any && !out.has(e.to)) { + out.add(e.to); + stack.push(e.to); + } + } + } + return out; +} + +export interface DFA { + start: number; + accepting: Set; + /** transitions[state] maps a symbol to a target; `any` is the fallback. */ + transitions: Map[]; + anyTransition: (number | undefined)[]; + /** Which NFA states each DFA state represents, for display. */ + members: number[][]; +} + +/** Subset construction: each DFA state is a set of NFA states. */ +export function toDFA(nfa: NFA, alphabet: string[]): DFA { + const startSet = epsilonClosure(nfa, [nfa.start]); + const key = (s: Set) => [...s].sort((a, b) => a - b).join(","); + + const index = new Map([[key(startSet), 0]]); + const sets: Set[] = [startSet]; + const transitions: Map[] = []; + const anyTransition: (number | undefined)[] = []; + + for (let i = 0; i < sets.length; i++) { + const current = sets[i]; + const row = new Map(); + + const moveOn = (symbol: string | null): Set => { + const next = new Set(); + for (const s of current) { + for (const e of nfa.edges[s]) { + if (e.any && symbol !== null) next.add(e.to); + else if (e.symbol !== null && e.symbol === symbol) next.add(e.to); + } + } + return next.size ? epsilonClosure(nfa, next) : next; + }; + + for (const sym of alphabet) { + const target = moveOn(sym); + if (target.size === 0) continue; + const k = key(target); + let id = index.get(k); + if (id === undefined) { + id = sets.length; + index.set(k, id); + sets.push(target); + } + row.set(sym, id); + } + + // A transition for characters outside the sampled alphabet, via `.`. + const anyTarget = (() => { + const next = new Set(); + for (const s of current) for (const e of nfa.edges[s]) if (e.any) next.add(e.to); + return next.size ? epsilonClosure(nfa, next) : next; + })(); + if (anyTarget.size > 0) { + const k = key(anyTarget); + let id = index.get(k); + if (id === undefined) { + id = sets.length; + index.set(k, id); + sets.push(anyTarget); + } + anyTransition[i] = id; + } else { + anyTransition[i] = undefined; + } + + transitions[i] = row; + } + + const accepting = new Set(); + sets.forEach((s, i) => { + if (s.has(nfa.accept)) accepting.add(i); + }); + + return { + start: 0, + accepting, + transitions, + anyTransition, + members: sets.map((s) => [...s].sort((a, b) => a - b)), + }; +} + +/** Characters that appear literally in the pattern — the DFA's alphabet. */ +export function literalsOf(tree: Node, out = new Set()): Set { + switch (tree.kind) { + case "char": + out.add(tree.ch); + break; + case "concat": + case "alt": + literalsOf(tree.left, out); + literalsOf(tree.right, out); + break; + case "star": + case "plus": + case "opt": + literalsOf(tree.node, out); + break; + default: + break; + } + return out; +} + +export interface RunStep { + index: number; // character position consumed + ch: string; + from: number; + to: number | null; // null = no transition, match dies here +} + +/** Run the DFA over the input, recording each transition. */ +export function run(dfa: DFA, input: string): { accepted: boolean; steps: RunStep[] } { + const steps: RunStep[] = []; + let state: number | null = dfa.start; + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + const from = state as number; + const explicit = dfa.transitions[from]?.get(ch); + const next = explicit !== undefined ? explicit : dfa.anyTransition[from]; + steps.push({ index: i, ch, from, to: next ?? null }); + if (next === undefined) { + state = null; + break; + } + state = next; + } + return { accepted: state !== null && dfa.accepting.has(state), steps }; +} + +/** Compile a pattern and test a full-string match. */ +export function matches(pattern: string, input: string): boolean { + const tree = parse(pattern); + const nfa = toNFA(tree); + const alphabet = [...new Set([...literalsOf(tree), ...input])]; + const dfa = toDFA(nfa, alphabet); + return run(dfa, input).accepted; +} diff --git a/src/pages/Bloom.tsx b/src/pages/Bloom.tsx new file mode 100644 index 0000000..885a6d4 --- /dev/null +++ b/src/pages/Bloom.tsx @@ -0,0 +1,264 @@ +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { BloomFilter, optimalK } from "../features/bloom/bloom"; + +const M = 512; // bits +const SEED_WORDS = ["apple", "banana", "cherry", "durian", "elderberry", "fig", "grape"]; + +interface Probe { + term: string; + hits: { index: number; set: boolean }[]; + verdict: "added" | "probably" | "definitely-not"; +} + +export default function Bloom() { + const [k, setK] = useState(4); + const [added, setAdded] = useState([]); + const [term, setTerm] = useState(""); + const [probe, setProbe] = useState(null); + const [audit, setAudit] = useState<{ tested: number; falsePositives: number } | null>(null); + + // The filter is fully determined by (k, added), so derive it rather than + // holding mutable state — changing k rehashes the same words from scratch. + const bf = useMemo(() => { + const next = new BloomFilter(M, k); + for (const w of added) next.add(w); + return next; + }, [k, added]); + + const addTerm = (raw: string) => { + const w = raw.trim(); + if (!w || added.includes(w)) return; + setAdded((prev) => [...prev, w]); + setTerm(""); + setProbe(null); + setAudit(null); + }; + + const checkTerm = (raw: string) => { + const w = raw.trim(); + if (!w) return; + const hits = bf.probe(w); + const all = hits.every((h) => h.set); + setProbe({ + term: w, + hits, + verdict: added.includes(w) ? "added" : all ? "probably" : "definitely-not", + }); + }; + + /** Sweep many never-added terms to measure the real false-positive rate. */ + const runAudit = () => { + let fp = 0; + const tested = 5000; + for (let i = 0; i < tested; i++) { + const w = `__audit_${i}`; + if (!added.includes(w) && bf.has(w)) fp++; + } + setAudit({ tested, falsePositives: fp }); + }; + + const reset = () => { + setAdded([]); + setProbe(null); + setAudit(null); + }; + + const highlighted = new Set(probe?.hits.map((h) => h.index) ?? []); + const suggestedK = optimalK(M, Math.max(1, added.length)); + + return ( +
+ {/* Header */} +
+
+ + ← home + + | + bloom filter +
+
+ {added.length} items · {bf.bitsSet}/{M} bits · predicted FPR{" "} + {(bf.theoreticalFpr() * 100).toFixed(2)}% +
+
+ + {/* Controls */} +
+
+ setTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") addTerm(term); + }} + className="bg-black/40 border border-primary/20 focus:border-primary/60 outline-none text-primary/90 text-sm px-2 py-0.5 w-36 font-mono" + /> + + +
+ +
+ k + { + setK(Number(e.target.value)); + setProbe(null); + setAudit(null); + }} + className="w-24 accent-primary" + /> + + {k} + {added.length > 0 && suggestedK !== k ? ` (best ${suggestedK})` : ""} + +
+ +
+ + + +
+
+ + {/* Body */} +
+ {/* Bit array */} +
+

+ the bit array — that is the entire data structure +

+
+ {Array.from({ length: M }, (_, i) => { + const set = bf.bits[i] === 1; + const lit = highlighted.has(i); + return ( +
+ ); + })} +
+
+ + {/* Probe result */} + {probe && ( +
+

+ checking “{probe.term}” +

+

+ {probe.verdict === "definitely-not" ? ( + definitely not in the set + ) : probe.verdict === "added" ? ( + present — and it really was added + ) : ( + + reported present, but it was never added — a false positive + + )} +

+

+ bits {probe.hits.map((h) => h.index).join(", ")} —{" "} + {probe.hits.filter((h) => h.set).length}/{probe.hits.length} were already set + {probe.verdict === "definitely-not" && " · one clear bit is proof of absence"} +

+
+ )} + + {/* Audit */} + {audit && ( +
+

measured

+

+ {audit.falsePositives} false positives out of {audit.tested.toLocaleString()} words + never added ={" "} + + {((audit.falsePositives / audit.tested) * 100).toFixed(2)}% + + + {" "} + · formula predicts {(bf.theoreticalFpr() * 100).toFixed(2)}% + +

+
+ )} + + {/* Members */} +
+

+ actually added ({added.length}) +

+
+ {added.map((w) => ( + + ))} + {added.length === 0 && ( + nothing yet — add a word above. + )} +
+
+
+ +
+ adding a word sets k bits; checking one reads the same k. a single clear bit proves the word + was never added, so there are never false negatives — but enough other words can set all k + by accident, and then the filter lies in the one direction it is allowed to. +
+
+ ); +} diff --git a/src/pages/EditDistance.tsx b/src/pages/EditDistance.tsx new file mode 100644 index 0000000..3247181 --- /dev/null +++ b/src/pages/EditDistance.tsx @@ -0,0 +1,194 @@ +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { buildTable, traceback, tracebackPath } from "../features/editdistance/levenshtein"; + +const EXAMPLES: [string, string][] = [ + ["kitten", "sitting"], + ["saturday", "sunday"], + ["intention", "execution"], + ["flaw", "lawn"], +]; + +const OP_COLOR: Record = { + match: "rgba(0,255,65,0.75)", + substitute: "#ffd23d", + insert: "#5ad0ff", + delete: "#ff6a3d", +}; + +export default function EditDistance() { + const [a, setA] = useState("kitten"); + const [b, setB] = useState("sitting"); + + // Keep the table small enough to render as a grid. + const av = a.slice(0, 18); + const bv = b.slice(0, 18); + + const { table, steps, pathSet } = useMemo(() => { + const t = buildTable(av, bv); + const s = traceback(t); + const set = new Set(tracebackPath(t).map(([i, j]) => `${i},${j}`)); + return { table: t, steps: s, pathSet: set }; + }, [av, bv]); + + const cellAt = (i: number, j: number) => table.cells[i * table.cols + j]; + + return ( +
+ {/* Header */} +
+
+ + ← home + + | + edit distance +
+
+ distance {table.distance} ·{" "} + {table.rows * table.cols} cells +
+
+ + {/* Inputs */} +
+ + +
+ {EXAMPLES.map(([x, y]) => ( + + ))} +
+
+ + {/* Body */} +
+
+ {/* Edit script */} +
+

the edit script

+
+ {steps.map((s, i) => ( + + {s.op === "match" + ? s.aChar + : s.op === "substitute" + ? `${s.aChar}→${s.bChar}` + : s.op === "insert" + ? `+${s.bChar}` + : `−${s.aChar}`} + + ))} + {steps.length === 0 && ( + identical — nothing to do. + )} +
+
+ {(["match", "substitute", "insert", "delete"] as const).map((op) => ( + + ■ {op} + + ))} +
+
+ + {/* DP table */} +
+

+ the table (each cell = cheapest way to reach that prefix pair) +

+
+ + + + + {[...bv].map((c, j) => ( + + ))} + + + + {Array.from({ length: table.rows }, (_, i) => ( + + + {Array.from({ length: table.cols }, (_, j) => { + const onPath = pathSet.has(`${i},${j}`); + const isCorner = i === table.rows - 1 && j === table.cols - 1; + return ( + + ); + })} + + ))} + +
+ ε + {c} +
+ {i === 0 ? ε : av[i - 1]} + + {cellAt(i, j).cost} +
+
+
+
+
+ +
+ every cell asks the same question: is it cheaper to substitute, insert, or delete? each + answer only needs the three neighbours already computed, so filling the grid once solves a + problem that would take exponential time to search naively. the highlighted path is the + cheapest route back. +
+
+ ); +} diff --git a/src/pages/Regex.tsx b/src/pages/Regex.tsx new file mode 100644 index 0000000..d7aab7b --- /dev/null +++ b/src/pages/Regex.tsx @@ -0,0 +1,223 @@ +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { literalsOf, parse, run, toDFA, toNFA } from "../features/regex/engine"; + +const EXAMPLES = [ + { pattern: "(a|b)*abb", input: "ababb" }, + { pattern: "ab*c", input: "abbbc" }, + { pattern: "(ab|cd)*e", input: "abcde" }, + { pattern: "a.c", input: "axc" }, + { pattern: "x(y|z)*w", input: "xyzyw" }, +]; + +export default function Regex() { + const [pattern, setPattern] = useState("(a|b)*abb"); + const [input, setInput] = useState("ababb"); + + const compiled = useMemo(() => { + try { + const tree = parse(pattern); + const nfa = toNFA(tree); + const alphabet = [...new Set([...literalsOf(tree), ...input])]; + const dfa = toDFA(nfa, alphabet); + const result = run(dfa, input); + return { tree, nfa, dfa, result, alphabet, error: null as string | null }; + } catch (e) { + return { + tree: null, + nfa: null, + dfa: null, + result: null, + alphabet: [], + error: e instanceof Error ? e.message : "invalid pattern", + }; + } + }, [pattern, input]); + + const { dfa, nfa, result, error } = compiled; + // Which DFA state we sit in after each consumed character. + const path: (number | null)[] = []; + if (result && dfa) { + let s: number | null = dfa.start; + path.push(s); + for (const step of result.steps) { + s = step.to; + path.push(s); + } + } + + return ( +
+ {/* Header */} +
+
+ + ← home + + | + regex engine +
+
+ {error + ? "parse error" + : `${nfa?.edges.length ?? 0} NFA states → ${dfa?.members.length ?? 0} DFA states`} +
+
+ + {/* Inputs */} +
+ + +
+ {EXAMPLES.map((ex) => ( + + ))} +
+
+ + {/* Body */} +
+ {error ? ( +

{error}

+ ) : ( +
+ {/* Verdict + walk */} +
+
+

the walk

+ + {result?.accepted ? "match" : "no match"} + +
+
+ + q{dfa?.start} + + {result?.steps.map((s, i) => ( + + + —{s.ch === " " ? "␣" : s.ch}→ + + + {s.to === null ? "✗" : `q${s.to}`} + + + ))} +
+

+ double-bordered states accept. one transition per character, never any + backtracking — that is why this runs in linear time. +

+
+ + {/* DFA table */} +
+

+ the DFA (each row is a set of NFA states) +

+
+ + + + + + {compiled.alphabet.map((a) => ( + + ))} + + + + + {dfa?.members.map((members, i) => { + const onPath = path.includes(i); + return ( + + + + {compiled.alphabet.map((a) => ( + + ))} + + + ); + })} + +
stateNFA states + {a === " " ? "␣" : a} + .
+ + q{i} + {dfa.accepting.has(i) ? " ✓" : ""} + + {`{${members.join(",")}}`} + {dfa.transitions[i]?.get(a) !== undefined + ? `q${dfa.transitions[i].get(a)}` + : "—"} + + {dfa.anyTransition[i] !== undefined ? `q${dfa.anyTransition[i]}` : "—"} +
+
+
+
+ )} +
+ +
+ the pattern becomes a syntax tree, the tree becomes an NFA by Thompson's construction, and + the NFA becomes a DFA by tracking which sets of NFA states you could be in at once. + supports literals, |, *, +, ?,{" "} + ., and groups. +
+
+ ); +}