Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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[];

Expand Down
105 changes: 105 additions & 0 deletions src/features/bloom/bloom.ts
Original file line number Diff line number Diff line change
@@ -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));
123 changes: 123 additions & 0 deletions src/features/editdistance/levenshtein.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading