<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Rustam Niraula — Writing</title><description>Long-form notes on backend systems, worked out in code: networking, distributed systems, algorithms and observability.</description><link>https://rustamniraula.com.np/</link><language>en</language><item><title>Building a Vertical Search Engine Over 2,196 University Publications</title><link>https://rustamniraula.com.np/blog/pureportal-vertical-search-engine/</link><guid isPermaLink="true">https://rustamniraula.com.np/blog/pureportal-vertical-search-engine/</guid><description>Crawling a Cloudflare-protected PurePortal site into MongoDB, building a per-field TF-IDF index from scratch, and ranking with cosine similarity — plus what the evaluation numbers actually exposed about a bag-of-words engine.</description><pubDate>Mon, 24 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;An Information Retrieval assignment asked for a working search engine over a real, live website — not a toy corpus. I picked Coventry University’s Centre for Healthcare and Community Transformation (CHCT) page on PurePortal, the university’s research database, and built a vertical search engine over its publications: crawler, TF-IDF index, cosine ranking, and a small web UI, no search library involved anywhere.&lt;/p&gt;
&lt;p&gt;The finished crawl holds 2,196 publications from 117 department members. Source: &lt;a href=&quot;https://github.com/rustamniraula90/ir-assignment&quot;&gt;github.com/rustamniraula90/ir-assignment&lt;/a&gt; (&lt;code&gt;search-engine/&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Three things about this turned out to matter more than the TF-IDF math itself: PurePortal sits behind Cloudflare, so getting past that shaped the whole crawler design; a page cap that isn’t documented anywhere silently truncated an early version of the crawl; and building the ground truth for evaluation by hand made the limits of a bag-of-words ranker completely explicit rather than something I’d just have to take on faith.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Architecture: three modules, MongoDB in between&lt;a href=&quot;#architecture-three-modules-mongodb-in-between&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The whole system is a three-stage pipeline with MongoDB as the handoff point between stages, deliberately kept as separate processes rather than one monolith:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;PurePortal (live site) → crawler.py → MongoDB → indexer.py → in-memory TF-IDF index → api.py → browser&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;crawler.py&lt;/code&gt;&lt;/strong&gt; — a long-running, resumable crawler. It never touches a request path; it just keeps MongoDB’s &lt;code&gt;publications&lt;/code&gt; collection current.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;indexer.py&lt;/code&gt;&lt;/strong&gt; — pure indexing and scoring logic. No web framework, no I/O beyond reading MongoDB. Given a query string, it returns ranked results.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;api.py&lt;/code&gt;&lt;/strong&gt; — a FastAPI app that builds the index once at startup, serves a search page and a JSON endpoint, and rebuilds the index from MongoDB every hour in the background.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;MongoDB holds two collections: &lt;code&gt;urls&lt;/code&gt;, which doubles as the crawl frontier and queue, and &lt;code&gt;publications&lt;/code&gt;, the extracted records. The TF-IDF index itself is never persisted — it’s rebuilt in memory from &lt;code&gt;publications&lt;/code&gt; whenever the API starts or its hourly refresh fires. That split matters: the crawler can be killed, restarted, or left running for days without the search API caring, and the API can be redeployed without re-crawling anything.&lt;/p&gt;
&lt;h2&gt;Seeding from the wrong page silently caps a crawl at 75 results&lt;a href=&quot;#seeding-from-the-wrong-page-silently-caps-a-crawl-at-75-results&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The obvious way to crawl a department’s publications is to start from the organisation’s own aggregated publications listing and follow its pagination. That’s what the first version of the crawler did, and it always stopped at exactly 75 results — regardless of how many publications the department’s members actually had between them. That number turned out to be a hard cap on that listing page itself, not a bug in the pagination logic, and it’s not documented anywhere on the site.&lt;/p&gt;
&lt;p&gt;A single prolific staff member can have several hundred publications, so 75 total was obviously wrong. The fix was to change what the crawl seeds from: the organisation’s &lt;strong&gt;persons&lt;/strong&gt; listing instead, then each member’s own paginated &lt;code&gt;/publications/&lt;/code&gt; page individually. That gives three distinct URL types, which the crawl queue prioritizes in this order so the full member list is discovered early:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;PRIORITY = {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;profiles&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;,             &lt;/span&gt;&lt;span&gt;# paginated list of department members&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;profile_publications&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;# one member&apos;s paginated publication list&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;publication&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;,          &lt;/span&gt;&lt;span&gt;# one publication&apos;s detail page&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;extract_content()&lt;/code&gt; dispatches on this type with a &lt;code&gt;match&lt;/code&gt; statement, and each case both parses the current page and enqueues whatever it finds next — member profile pages enqueue that member’s publication listing, publication listings enqueue individual publication pages (plus their own next page), and publication pages just get scraped for title, abstract, keywords, authors and year.&lt;/p&gt;
&lt;h2&gt;The crawl frontier lives in MongoDB, not in memory&lt;a href=&quot;#the-crawl-frontier-lives-in-mongodb-not-in-memory&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;urls&lt;/code&gt; isn’t a log of what’s been crawled — it &lt;em&gt;is&lt;/em&gt; the frontier. Every URL document carries a &lt;code&gt;next_crawl_date&lt;/code&gt;, a &lt;code&gt;priority&lt;/code&gt;, and a &lt;code&gt;retries&lt;/code&gt; counter:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; get_next_job&lt;/span&gt;&lt;span&gt;():&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; db_urls.find_one_and_update(&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        {&lt;/span&gt;&lt;span&gt;&quot;next_crawl_date&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;span&gt;&quot;$lt&quot;&lt;/span&gt;&lt;span&gt;: datetime.now(timezone.utc)}},&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        {&lt;/span&gt;&lt;span&gt;&quot;$set&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;span&gt;&quot;next_crawl_date&quot;&lt;/span&gt;&lt;span&gt;: datetime.now(timezone.utc) + timedelta(&lt;/span&gt;&lt;span&gt;minutes&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt;)}},&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        sort&lt;/span&gt;&lt;span&gt;=[(&lt;/span&gt;&lt;span&gt;&quot;priority&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;), (&lt;/span&gt;&lt;span&gt;&quot;next_crawl_date&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;)],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    )&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;find_one_and_update&lt;/code&gt; claims a job atomically and pushes its &lt;code&gt;next_crawl_date&lt;/code&gt; forward by ten minutes as a claim window, so the crawler can be killed and restarted without two runs ever grabbing the same URL, and without losing the queue’s position. On success, &lt;code&gt;mark_success()&lt;/code&gt; pushes &lt;code&gt;next_crawl_date&lt;/code&gt; out by seven days rather than deleting the record — the same document gets recrawled on a weekly cadence for as long as the crawler keeps running, which reproduces a scheduled recrawl without cron or any external scheduler. On failure, &lt;code&gt;schedule_retry()&lt;/code&gt; backs off by an hour, capped at five retries, before giving up until the normal weekly slot — a transient network blip costs an hour, not a week.&lt;/p&gt;
&lt;h2&gt;Getting past Cloudflare without driving a browser for every page&lt;a href=&quot;#getting-past-cloudflare-without-driving-a-browser-for-every-page&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;PurePortal sits behind Cloudflare’s Turnstile challenge, which a plain &lt;code&gt;requests.Session&lt;/code&gt; cannot pass. Running every single page through a full browser would work but is slow — a Selenium round trip per publication, times 2,196 pages, adds up fast for a resource nobody but a crawl loop is going to see rendered.&lt;/p&gt;
&lt;p&gt;The approach instead: open one persistent, undetected browser session (via &lt;code&gt;seleniumbase&lt;/code&gt;’s &lt;code&gt;uc_open_with_reconnect&lt;/code&gt; + &lt;code&gt;uc_gui_click_captcha&lt;/code&gt;) at the start of a crawl run, solve the challenge once, then copy the resulting &lt;code&gt;cf_clearance&lt;/code&gt; cookie into a plain &lt;code&gt;requests.Session&lt;/code&gt; that handles every page after that:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; fetch_page&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;sb&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;url&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;max_challenge_retries&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    response = session.get(url, &lt;/span&gt;&lt;span&gt;timeout&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;30&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; response.status_code == &lt;/span&gt;&lt;span&gt;200&lt;/span&gt;&lt;span&gt; and&lt;/span&gt;&lt;span&gt; not&lt;/span&gt;&lt;span&gt; is_challenge_page(response.text):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        return&lt;/span&gt;&lt;span&gt; response.text&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    # ...fall back to the browser, re-solve, copy cookies back into `session`&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;is_challenge_page()&lt;/code&gt; checks the response for a &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; of “Just a moment…” or a &lt;code&gt;#challenge-running&lt;/code&gt; element, since a 200 status alone doesn’t mean the response is real content — Cloudflare serves the interstitial with a 200 too. Most pages never touch the browser at all; it only comes back into play if the session cookie has expired or a fresh challenge appears.&lt;/p&gt;
&lt;p&gt;Two failure modes get handled at different levels. An ordinary bad page — a timeout, a malformed response — is caught inside &lt;code&gt;process_job()&lt;/code&gt; and goes through the normal &lt;code&gt;schedule_retry()&lt;/code&gt; backoff. A dead browser session is a different, fatal failure (&lt;code&gt;BrowserDiedError&lt;/code&gt;, raised on any &lt;code&gt;WebDriverException&lt;/code&gt; or connection error while the browser is active), and it’s handled one level up, in &lt;code&gt;crawl()&lt;/code&gt;: the whole browser session is torn down and relaunched from scratch after a ten-second pause, and the crawl loop resumes from whatever MongoDB’s queue currently holds — no in-memory state to lose.&lt;/p&gt;
&lt;h2&gt;robots.txt, and choosing availability over strict compliance&lt;a href=&quot;#robotstxt-and-choosing-availability-over-strict-compliance&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The crawler fetches and parses &lt;code&gt;robots.txt&lt;/code&gt; once per crawl run and checks every candidate URL against it before fetching:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; can_fetch&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;rp&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;url&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; rp &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; None&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        return&lt;/span&gt;&lt;span&gt; True&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; rp.can_fetch(&lt;/span&gt;&lt;span&gt;&quot;*&quot;&lt;/span&gt;&lt;span&gt;, url)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If that initial fetch fails, &lt;code&gt;rp&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt;, and &lt;code&gt;can_fetch()&lt;/code&gt; degrades to allowing everything rather than halting the crawl. &lt;code&gt;get_crawl_delay()&lt;/code&gt; degrades the same way, falling back to a fixed five-second delay if &lt;code&gt;robots.txt&lt;/code&gt; didn’t specify one. That’s a deliberate trade: a single failed &lt;code&gt;robots.txt&lt;/code&gt; fetch shouldn’t take down an otherwise-working crawl, and the crawl rate stays capped by the fallback delay either way, so the politeness contract with the site doesn’t actually go away — it just falls back to a sane default instead of blocking on a request that failed.&lt;/p&gt;
&lt;h2&gt;Per-field TF-IDF, not one vector per document&lt;a href=&quot;#per-field-tf-idf-not-one-vector-per-document&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;indexer.py&lt;/code&gt; builds three completely separate TF-IDF indexes — title, abstract, keywords — each with its own vocabulary and its own IDF table, rather than concatenating all the text into one vector per publication:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;FIELD_WEIGHTS = {&lt;/span&gt;&lt;span&gt;&quot;title&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;0.5&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;keywords&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;0.3&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;abstract&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;0.2&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reason for keeping them separate: a single combined vector scores a query term appearing in the title exactly the same as the same term buried somewhere in a 300-word abstract, which throws away the strongest positional signal a publication record has. Across the full 2,196-publication corpus this produces three vocabularies of very different sizes — 3,284 terms for title, 3,065 for keywords, 10,350 for abstract — which on its own says something about how much more lexical variety a free-text abstract carries versus a controlled, short title.&lt;/p&gt;
&lt;p&gt;Preprocessing is one shared pipeline, applied identically to indexed text and incoming queries — lowercase, split on non-letters, drop English stopwords, stem with Porter:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; clean_and_tokenize&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    text = text.lower()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    words = re.split(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;[^&lt;/span&gt;&lt;span&gt;a-z&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, text)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; [STEMMER.stem(w) &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; w &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; words &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; w &lt;/span&gt;&lt;span&gt;and&lt;/span&gt;&lt;span&gt; w &lt;/span&gt;&lt;span&gt;not&lt;/span&gt;&lt;span&gt; in&lt;/span&gt;&lt;span&gt; STOP_WORDS]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That last point isn’t cosmetic: if query tokens and document tokens went through even slightly different preprocessing, cosine similarity between them wouldn’t be comparing the same vocabulary, and the whole ranking would be scoring noise.&lt;/p&gt;
&lt;p&gt;Building one field’s index is standard vector-space TF-IDF — term frequency times inverse document frequency, an inverted index, and each document’s vector magnitude cached up front so scoring doesn’t recompute it per query:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; build_field_index&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;tokenized&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;n_docs&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    doc_freq = Counter()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; tokens &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; tokenized.values():&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        for&lt;/span&gt;&lt;span&gt; term &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; set&lt;/span&gt;&lt;span&gt;(tokens):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            doc_freq[term] += &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    idf = {term: math.log(n_docs / df) &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; term, df &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; doc_freq.items()}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    inverted_index = defaultdict(&lt;/span&gt;&lt;span&gt;dict&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    magnitude = {}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; doc_id, tokens &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; tokenized.items():&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        tf = term_frequency(tokens)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        vector = {term: weight * idf[term] &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; term, weight &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; tf.items()}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        magnitude[doc_id] = math.sqrt(&lt;/span&gt;&lt;span&gt;sum&lt;/span&gt;&lt;span&gt;(w * w &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; w &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; vector.values()))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        for&lt;/span&gt;&lt;span&gt; term, weight &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; vector.items():&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            inverted_index[term][doc_id] = weight&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;span&gt;&quot;index&quot;&lt;/span&gt;&lt;span&gt;: inverted_index, &lt;/span&gt;&lt;span&gt;&quot;magnitude&quot;&lt;/span&gt;&lt;span&gt;: magnitude, &lt;/span&gt;&lt;span&gt;&quot;idf&quot;&lt;/span&gt;&lt;span&gt;: idf}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The inverted index — term → &lt;code&gt;{doc_id: weight}&lt;/code&gt; — is what keeps a query cheap: &lt;code&gt;field_scores()&lt;/code&gt; only ever walks documents that share at least one term with the query, never the whole collection. Query terms with no entry in a field’s IDF table are dropped outright, rather than treated as zero-weight, since a term absent from the corpus vocabulary tells you nothing about relevance for that field.&lt;/p&gt;
&lt;h2&gt;Combining three cosine scores into one ranking&lt;a href=&quot;#combining-three-cosine-scores-into-one-ranking&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Scoring a query against one field is exactly a textbook vector-space search: turn the query into a TF-IDF vector using that field’s own IDF table, walk the inverted index to accumulate a dot product per candidate document, and divide by the two vector magnitudes to get cosine similarity — bounded to &lt;code&gt;[0, 1]&lt;/code&gt; since none of the weights are negative.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; field_scores&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;field_index&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;tokens&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    qtf = term_frequency(tokens)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    query_vector = {term: weight * field_index[&lt;/span&gt;&lt;span&gt;&quot;idf&quot;&lt;/span&gt;&lt;span&gt;][term]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                    for&lt;/span&gt;&lt;span&gt; term, weight &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; qtf.items() &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; term &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; field_index[&lt;/span&gt;&lt;span&gt;&quot;idf&quot;&lt;/span&gt;&lt;span&gt;]}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    magnitude_query = math.sqrt(&lt;/span&gt;&lt;span&gt;sum&lt;/span&gt;&lt;span&gt;(w * w &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; w &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; query_vector.values()))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; magnitude_query == &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        return&lt;/span&gt;&lt;span&gt; {}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    dot_products = defaultdict(&lt;/span&gt;&lt;span&gt;float&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; term, q_weight &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; query_vector.items():&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        for&lt;/span&gt;&lt;span&gt; doc_id, d_weight &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; field_index[&lt;/span&gt;&lt;span&gt;&quot;index&quot;&lt;/span&gt;&lt;span&gt;].get(term, {}).items():&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            dot_products[doc_id] += q_weight * d_weight&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; {doc_id: dot / (magnitude_query * field_index[&lt;/span&gt;&lt;span&gt;&quot;magnitude&quot;&lt;/span&gt;&lt;span&gt;][doc_id])&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            for&lt;/span&gt;&lt;span&gt; doc_id, dot &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; dot_products.items() &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; field_index[&lt;/span&gt;&lt;span&gt;&quot;magnitude&quot;&lt;/span&gt;&lt;span&gt;][doc_id] &amp;gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The three per-field scores then combine with fixed weights that sum to 1, so the combined score stays on the same 0–1 cosine scale as any individual field:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;combined = defaultdict(&lt;/span&gt;&lt;span&gt;float&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; field, weight &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; FIELD_WEIGHTS.items():&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; doc_id, score &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; per_field[field].items():&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        combined[doc_id] += weight * score&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Title gets 0.5 because it’s short and written to state exactly what the publication is about — a query term landing there is the strongest single signal available. Keywords get 0.3: author-assigned and nearly as specific as title terms, but missing on roughly a third of the collection (63.4% coverage) and prone to generic labels. Abstract gets 0.2, the smallest share, precisely because it’s the longest field — a several-hundred-word abstract will contain almost any query term somewhere by chance, so a match there is the weakest per-term evidence of the three. I checked that ordering by hand against the test queries during development: swapping the title and abstract weights visibly let long abstracts outrank exact title matches.&lt;/p&gt;
&lt;p&gt;The API keeps and returns all four numbers — combined score plus the three field scores — rather than collapsing to just the final rank, and the UI surfaces them per result so it’s visible &lt;em&gt;why&lt;/em&gt; one publication outranked another instead of just trusting a single opaque number.&lt;/p&gt;
&lt;h2&gt;Evaluation: building ground truth that doesn’t cheat&lt;a href=&quot;#evaluation-building-ground-truth-that-doesnt-cheat&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;There’s no official relevance judgment set for this collection, and judging by hand — reading the top 10 results for each query and marking them relevant — has two problems: it’s subjective, and it only ever judges the 10 documents the engine already chose to show, saying nothing about whether it missed something better sitting at position 11 or in the other 2,186.&lt;/p&gt;
&lt;p&gt;Instead, &lt;code&gt;evaluate.py&lt;/code&gt; applies one fixed rule to the &lt;em&gt;entire&lt;/em&gt; collection, independent of the ranker being tested: stem the query’s terms, stem each publication’s title/abstract/keywords, and call a publication relevant once it shares at least 60% of the query’s distinct stemmed terms.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; relevant_urls&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    query_terms = &lt;/span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt;(clean_and_tokenize(query))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    threshold = &lt;/span&gt;&lt;span&gt;max&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, math.ceil(&lt;/span&gt;&lt;span&gt;len&lt;/span&gt;&lt;span&gt;(query_terms) * QUERY_TERM_MATCH_FRACTION))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    relevant = &lt;/span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; pub &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; db_publications.find({}, {&lt;/span&gt;&lt;span&gt;&quot;url&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;title&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;abstract&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;keywords&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;}):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        doc_terms = &lt;/span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt;(clean_and_tokenize(&lt;/span&gt;&lt;span&gt;&quot; &quot;&lt;/span&gt;&lt;span&gt;.join([pub.get(&lt;/span&gt;&lt;span&gt;&quot;title&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&quot;&lt;/span&gt;&lt;span&gt;), pub.get(&lt;/span&gt;&lt;span&gt;&quot;abstract&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&quot;&lt;/span&gt;&lt;span&gt;), &lt;/span&gt;&lt;span&gt;&quot; &quot;&lt;/span&gt;&lt;span&gt;.join(pub.get(&lt;/span&gt;&lt;span&gt;&quot;keywords&quot;&lt;/span&gt;&lt;span&gt;, []))])))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        if&lt;/span&gt;&lt;span&gt; len&lt;/span&gt;&lt;span&gt;(query_terms &amp;amp; doc_terms) &amp;gt;= threshold:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            relevant.add(pub[&lt;/span&gt;&lt;span&gt;&quot;url&quot;&lt;/span&gt;&lt;span&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; relevant&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;60% was chosen by testing both extremes first. Requiring every term (100%) sometimes found zero relevant documents for a genuine four-word query, since real publications rarely repeat every query word verbatim. Requiring just one shared term let something as generic as “health” alone mark a third of the collection relevant to every health-related query, which is clearly too loose to mean anything. 60% sits between those failure modes.&lt;/p&gt;
&lt;p&gt;Five test queries were chosen to span the department’s actual subject areas — nursing, physical activity in older adults, health inequalities, mental health, and one narrow clinical topic (bariatric surgery) — rather than being picked to flatter the ranker.&lt;/p&gt;
&lt;h3&gt;Results&lt;a href=&quot;#results&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;





























































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Query&lt;/th&gt;&lt;th&gt;Precision&lt;/th&gt;&lt;th&gt;Recall&lt;/th&gt;&lt;th&gt;F1&lt;/th&gt;&lt;th&gt;P@5&lt;/th&gt;&lt;th&gt;AP&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;community nursing intervention&lt;/td&gt;&lt;td&gt;0.50&lt;/td&gt;&lt;td&gt;0.037&lt;/td&gt;&lt;td&gt;0.069&lt;/td&gt;&lt;td&gt;0.80&lt;/td&gt;&lt;td&gt;0.925&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;physical activity older adults&lt;/td&gt;&lt;td&gt;1.00&lt;/td&gt;&lt;td&gt;0.110&lt;/td&gt;&lt;td&gt;0.198&lt;/td&gt;&lt;td&gt;1.00&lt;/td&gt;&lt;td&gt;1.000&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;health inequalities&lt;/td&gt;&lt;td&gt;0.70&lt;/td&gt;&lt;td&gt;0.304&lt;/td&gt;&lt;td&gt;0.424&lt;/td&gt;&lt;td&gt;0.80&lt;/td&gt;&lt;td&gt;0.826&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;mental health young people&lt;/td&gt;&lt;td&gt;0.70&lt;/td&gt;&lt;td&gt;0.115&lt;/td&gt;&lt;td&gt;0.197&lt;/td&gt;&lt;td&gt;1.00&lt;/td&gt;&lt;td&gt;0.962&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;bariatric surgery&lt;/td&gt;&lt;td&gt;1.00&lt;/td&gt;&lt;td&gt;0.400&lt;/td&gt;&lt;td&gt;0.571&lt;/td&gt;&lt;td&gt;1.00&lt;/td&gt;&lt;td&gt;1.000&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Mean&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.78&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;0.193&lt;/td&gt;&lt;td&gt;0.292&lt;/td&gt;&lt;td&gt;0.92&lt;/td&gt;&lt;td&gt;&lt;strong&gt;MAP 0.943&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Query response time, measured in-process across 25 calls: 1.51 ms mean, 1.54 ms median, 2.27 ms at the 95th percentile. That stays flat regardless of corpus size because a query only ever touches documents the inverted index lists against its own terms — never all 2,196 publications. The one cost that does scale with corpus size is rebuilding the whole index, about 2.8 seconds, which is exactly why that happens once at process start and then hourly in the background rather than inside a request.&lt;/p&gt;
&lt;p&gt;Accuracy isn’t reported at all: with only a few dozen relevant documents out of 2,196 for any given query, true negatives dominate so completely that returning nothing at all would already score above 93%. It would be a meaningless number dressed up as a good one.&lt;/p&gt;
&lt;h3&gt;What the weak query and the perfect ones actually tell you&lt;a href=&quot;#what-the-weak-query-and-the-perfect-ones-actually-tell-you&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;“Physical activity older adults” and “bariatric surgery” hit precision 1.00 and AP 1.00 because both use specific enough vocabulary that title, abstract and keyword scores all agree on the same handful of publications — there’s no ambiguity in that part of the corpus for those terms to exploit.&lt;/p&gt;
&lt;p&gt;“Community nursing intervention” is the weak one, at precision 0.50. The five results the relevance rule rejects are genuinely about nursing — they just aren’t also about &lt;em&gt;community&lt;/em&gt; care or a specific &lt;em&gt;intervention&lt;/em&gt;. They share only one of the query’s three stemmed terms with a rejected document, but “nurs” alone carries enough TF-IDF weight on its own to still land in the top 10. A model that scored phrases rather than independent terms would very likely rank these lower, since it’s exactly the kind of failure a bag-of-words vector can’t distinguish: matching one strong term isn’t the same as matching the query’s actual meaning.&lt;/p&gt;
&lt;p&gt;Two other misses expose something the ranker and the ground truth rule both share, since both are lexical, bag-of-words methods with no notion of meaning. A sports psychology paper about young footballers’ mental skills gets marked relevant to “mental health young people” purely because “mental,” “young” and “people” each appear somewhere in its text — not because it’s actually about the query’s subject. And for “health inequalities,” a paper on cross-country musculoskeletal disease trends that’s clearly on-topic gets marked &lt;em&gt;irrelevant&lt;/em&gt; by the same rule, because it never happens to use the literal word “health.” That’s a vocabulary mismatch working against the evaluation itself, not just the ranker, and no amount of tuning the field weights fixes it — it needs something that reasons about meaning rather than counting shared strings.&lt;/p&gt;
&lt;p&gt;Mean recall (0.193) looks low in isolation, but that’s a property of the evaluation setup, not the ranking: only the top 10 results per query are ever checked against every relevant document across the full 2,196-publication collection. A query with 134 relevant documents in the corpus (community nursing intervention) can retrieve 10 good results and still show single-digit recall by construction.&lt;/p&gt;
&lt;h2&gt;What I’d change&lt;a href=&quot;#what-id-change&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Field weights are hand-picked and fixed. They were sanity-checked against five queries during development, which is enough to catch an obviously wrong ordering but not enough to claim they’re optimal — a larger, less improvised relevance judgment set would let those weights actually be fit rather than eyeballed.&lt;/p&gt;
&lt;p&gt;The ranker has no notion of phrases or term proximity, which is precisely what the “community nursing intervention” result exposes: matching “nurs” alone is treated identically to matching all three terms together. Bigram or phrase-aware scoring, even something simple like boosting documents where query terms appear adjacent, would likely fix that specific failure without touching the rest of the pipeline.&lt;/p&gt;
&lt;p&gt;And the 60%-term-overlap relevance rule is itself lexical, so it inherits the same vocabulary-mismatch blind spot as the engine it’s grading — the musculoskeletal-disease paper marked irrelevant to “health inequalities” is a ground-truth error, not a ranking error, and no amount of retuning &lt;code&gt;search_publications()&lt;/code&gt; will fix it. Any lexical evaluation has this ceiling built in; getting past it needs relevance judgments that don’t require the ground truth and the system under test to share the same weakness.&lt;/p&gt;</content:encoded><category>Systems &amp; Infrastructure</category><category>Python</category><category>Information Retrieval</category><category>MongoDB</category><category>FastAPI</category></item><item><title>Rebuilding My Portfolio: From a Static Single Page to Astro, Pagefind, and Cloudflare</title><link>https://rustamniraula.com.np/blog/converting-portfolio-to-astro-blog/</link><guid isPermaLink="true">https://rustamniraula.com.np/blog/converting-portfolio-to-astro-blog/</guid><description>How a hand-written Vite single-pager became a content engine: Astro 7 collections, build-time Pagefind search, per-post social cards, and a deploy on Cloudflare that fires on every push.</description><pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;For two years the portfolio was about as simple as a site gets: one &lt;code&gt;index.html&lt;/code&gt;, one 611-line &lt;code&gt;style.css&lt;/code&gt;, one &lt;code&gt;src/main.js&lt;/code&gt;, and Vite to bundle it. That was the entire repository. It was fast, cost nothing to run, and never broke.&lt;/p&gt;
&lt;p&gt;It also couldn’t hold an article. Every position on that page was hand-placed markup, so “add a blog” meant hand-writing another page, and another, forever. Three things I wanted instead: write in Markdown and &lt;code&gt;git push&lt;/code&gt;, get real search without paying for a search API, and deploy without a step in between.&lt;/p&gt;
&lt;p&gt;This is the move, and what got built on top of it afterwards.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The rules first&lt;a href=&quot;#the-rules-first&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I wrote the constraints down before picking anything, because the tools are the easy half.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Static output.&lt;/strong&gt; Pages ship as HTML. JavaScript where something genuinely needs to be interactive.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Markdown in, site out.&lt;/strong&gt; A post is a file. No CMS, no database, no admin panel.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Search with no backend.&lt;/strong&gt; No Algolia, no Elasticsearch, no per-query cost. $0/month has to stay $0/month.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don’t lose what worked.&lt;/strong&gt; The hero canvas, the Open Graph tags, the &lt;code&gt;Person&lt;/code&gt; JSON-LD, the analytics snippet. Those were the parts of the old file with accumulated value in them.&lt;/li&gt;
&lt;/ol&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Concern&lt;/th&gt;&lt;th&gt;Choice&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Build&lt;/td&gt;&lt;td&gt;Astro 7, static output&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Markdown + components&lt;/td&gt;&lt;td&gt;&lt;code&gt;@astrojs/mdx&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Search&lt;/td&gt;&lt;td&gt;Pagefind, post-build&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Social cards&lt;/td&gt;&lt;td&gt;Satori → resvg, at build time&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Hosting&lt;/td&gt;&lt;td&gt;Cloudflare Workers static assets, built on push&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Astro renders components to HTML and only hydrates what you explicitly ask it to, which is constraint 1 for free. Pagefind is the interesting one: it doesn’t index source files or ship a client-side index like Fuse.js. It runs &lt;em&gt;after&lt;/em&gt; the build, reads the HTML Astro just emitted, and writes a chunked index. The browser downloads only the fragments a query touches.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Before and after&lt;a href=&quot;#before-and-after&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;├── index.html&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;├── package.json&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;├── src&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;│   └── main.js&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;├── style.css&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;└── wrangler.jsonc&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;├── astro.config.mjs&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;├── worker/&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;│   └── index.ts                  # /api/contact, and the asset server&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;└── src/&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ├── content.config.ts          # the schema every post is checked against&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ├── content/blog/              # posts, as Markdown&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ├── data/                      # site, categories, contact rules&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ├── lib/                       # post helpers, OG card renderer&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ├── components/&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ├── layouts/BaseLayout.astro   # &amp;lt;head&amp;gt;, SEO, schema&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ├── pages/&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │   ├── index.astro            # home&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │   ├── 404.astro&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │   ├── rss.xml.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │   ├── sitemap.xml.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │   ├── og/[slug].png.ts       # one social card per post&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │   └── blog/&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │       ├── index.astro&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │       ├── [slug].astro&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │       └── category/[category]/index.astro&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    └── styles/global.css          # tokens in, components below&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No top-level &lt;code&gt;style.css&lt;/code&gt; and no &lt;code&gt;main.js&lt;/code&gt;. The stylesheet moved in, and &lt;code&gt;main.js&lt;/code&gt; came apart into whatever component owned each behaviour.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Typed frontmatter&lt;a href=&quot;#typed-frontmatter&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Content collections are what make a folder of Markdown behave like a database.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; blog&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;defineCollection&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  loader:&lt;/span&gt;&lt;span&gt; glob&lt;/span&gt;&lt;span&gt;({ &lt;/span&gt;&lt;span&gt;pattern:&lt;/span&gt;&lt;span&gt; &quot;**/*.{md,mdx}&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;base:&lt;/span&gt;&lt;span&gt; &quot;./src/content/blog&quot;&lt;/span&gt;&lt;span&gt; }),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  schema:&lt;/span&gt;&lt;span&gt; z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    title:&lt;/span&gt;&lt;span&gt; z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;(),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    description:&lt;/span&gt;&lt;span&gt; z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;(),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    pubDate:&lt;/span&gt;&lt;span&gt; z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;date&lt;/span&gt;&lt;span&gt;(),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    category:&lt;/span&gt;&lt;span&gt; z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;enum&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;categorySlugs&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    tags:&lt;/span&gt;&lt;span&gt; z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;array&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;()).&lt;/span&gt;&lt;span&gt;optional&lt;/span&gt;&lt;span&gt;(),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    featured:&lt;/span&gt;&lt;span&gt; z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;boolean&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;optional&lt;/span&gt;&lt;span&gt;(),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    summary:&lt;/span&gt;&lt;span&gt; z&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;optional&lt;/span&gt;&lt;span&gt;(),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;});&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A typo in frontmatter is a build failure, not a blank spot on a deployed page. &lt;code&gt;category&lt;/code&gt; is an enum over a hand-maintained list, so &lt;code&gt;catagory: systems&lt;/code&gt; stops the deploy rather than producing a post filed under nothing.&lt;/p&gt;
&lt;h2&gt;One layout owning the shell&lt;a href=&quot;#one-layout-owning-the-shell&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;BaseLayout.astro&lt;/code&gt; holds what has to be true on every page: &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, header, footer, and the metadata the old &lt;code&gt;index.html&lt;/code&gt; had collected. That last part is the one worth being careful about — Open Graph tags, the &lt;code&gt;Person&lt;/code&gt; JSON-LD, the analytics snippet, all sitting in a file I was about to delete, which is exactly when that sort of thing disappears without anyone noticing.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;const canonical = new URL(Astro.url.pathname, Astro.site ?? Astro.url).href;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Canonical and social URLs are derived rather than pasted. &lt;code&gt;isHome&lt;/code&gt; scopes the &lt;code&gt;Person&lt;/code&gt; schema to one page instead of repeating it everywhere. Posts add &lt;code&gt;BlogPosting&lt;/code&gt; and a &lt;code&gt;BreadcrumbList&lt;/code&gt;; the breadcrumb is passed in as props, so the template doesn’t have to know where in the hierarchy it sits.&lt;/p&gt;
&lt;h2&gt;Fonts without a network&lt;a href=&quot;#fonts-without-a-network&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Three families, all self-hosted, resolved at build time from the &lt;code&gt;@fontsource&lt;/code&gt; packages:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;fonts&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    name:&lt;/span&gt;&lt;span&gt; &quot;Fraunces&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    cssVariable:&lt;/span&gt;&lt;span&gt; &quot;--font-fraunces&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    provider:&lt;/span&gt;&lt;span&gt; fontProviders&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;local&lt;/span&gt;&lt;span&gt;(),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    fallbacks:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;Georgia&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;ui-serif&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;serif&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;fontProviders.local()&lt;/code&gt; means &lt;code&gt;npm run build&lt;/code&gt; never reaches out to Google, and Astro emits a metric-matched fallback face so the first paint uses something the right shape rather than Times. Fraunces carries the optical-size axis, so headings pick up the high-contrast display cut as they grow without a second file.&lt;/p&gt;
&lt;h2&gt;The hero canvas&lt;a href=&quot;#the-hero-canvas&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Worth a section because the first version was quietly broken.&lt;/p&gt;
&lt;p&gt;The original drew a random walk mirrored across four quadrants, accumulating into a symmetric lattice. It ran on &lt;code&gt;setInterval(fn, 10)&lt;/code&gt; — roughly 100fps, forever, redrawing the whole canvas every 10ms whether or not anyone was looking. A &lt;code&gt;mousemove&lt;/code&gt; listener called the resize handler, which reset the walk position, so moving the mouse snapped the pattern. No &lt;code&gt;prefers-reduced-motion&lt;/code&gt;, no devicePixelRatio handling.&lt;/p&gt;
&lt;p&gt;The drawing is now a flow field instead. Around seventy particles drift through a field built from three offset sine waves, and the trail is decayed with a 1.7% white wash per frame rather than &lt;code&gt;clearRect&lt;/code&gt;. That wash is the whole trick — it fades old segments toward the paper while new ones draw on top, which is what makes it read as long exposure instead of scribble.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; angleAt&lt;/span&gt;&lt;span&gt; = (&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sin&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;0.0021&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;0.21&lt;/span&gt;&lt;span&gt;) +&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;cos&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;0.0027&lt;/span&gt;&lt;span&gt; - &lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;0.16&lt;/span&gt;&lt;span&gt;) +&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sin&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;) * &lt;/span&gt;&lt;span&gt;0.0013&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;0.11&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Seventy particles and k=3 nearest neighbours is a few thousand float operations a frame. A library would have cost more in bytes than the graph does.&lt;/p&gt;
&lt;p&gt;The lifecycle is where the previous version was failing: one &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop, paused when the tab is hidden or the hero scrolls out of view, DPR capped at 2, and a single settled frame under &lt;code&gt;prefers-reduced-motion&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Categories instead of tag soup&lt;a href=&quot;#categories-instead-of-tag-soup&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The first blog index derived its sidebar from whatever tags happened to be in use, with counts. Nine posts produced twenty-two categories, most with one item, which is a filter nobody can use.&lt;/p&gt;
&lt;p&gt;It’s a hand-maintained list now, with a rule that a category waits for roughly three posts before it earns a page:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; const&lt;/span&gt;&lt;span&gt; categories&lt;/span&gt;&lt;span&gt; = [&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  { &lt;/span&gt;&lt;span&gt;slug:&lt;/span&gt;&lt;span&gt; &quot;systems&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;name:&lt;/span&gt;&lt;span&gt; &quot;Systems &amp;amp; Infrastructure&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;icon:&lt;/span&gt;&lt;span&gt; &quot;hub&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;description:&lt;/span&gt;&lt;span&gt; &quot;...&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  { &lt;/span&gt;&lt;span&gt;slug:&lt;/span&gt;&lt;span&gt; &quot;algorithms&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;name:&lt;/span&gt;&lt;span&gt; &quot;Algorithms&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;icon:&lt;/span&gt;&lt;span&gt; &quot;account_tree&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;description:&lt;/span&gt;&lt;span&gt; &quot;...&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  { &lt;/span&gt;&lt;span&gt;slug:&lt;/span&gt;&lt;span&gt; &quot;machine-learning&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;name:&lt;/span&gt;&lt;span&gt; &quot;Machine Learning &amp;amp; Statistics&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;icon:&lt;/span&gt;&lt;span&gt; &quot;psychology&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;description:&lt;/span&gt;&lt;span&gt; &quot;...&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;] &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; const&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;as const&lt;/code&gt; plus &lt;code&gt;z.enum&lt;/code&gt; means adding a category is one edit in one file and a typo is a build error. The filter is plain links to &lt;code&gt;/blog/category/&amp;lt;slug&amp;gt;/&lt;/code&gt; — no client-side filtering, so it works with JavaScript off and every filtered view is shareable. I had a “Load More” button too, which went: with nine posts it hides content from crawlers and from Ctrl-F, and client-side pagination is the wrong tool at this size.&lt;/p&gt;
&lt;h2&gt;Code blocks that know what they are&lt;a href=&quot;#code-blocks-that-know-what-they-are&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Nine posts, 102 fenced blocks. Two things I wanted that weren’t there.&lt;/p&gt;
&lt;p&gt;Filenames. A fence can carry a title, and a Shiki transformer lifts it onto the &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt;, so the header bar above a block can name the file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;```go title=&quot;crawler.py&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One trap: the fence has to declare a language. Shiki reads a bare &lt;code&gt;``` title=&quot;x&quot;&lt;/code&gt; as the &lt;em&gt;language&lt;/em&gt; name, swallows the title, and &lt;code&gt;data-language&lt;/code&gt; degrades to &lt;code&gt;plaintext&lt;/code&gt;. Cost me one block and about ten minutes.&lt;/p&gt;
&lt;p&gt;Copy buttons and line highlighting are a small script over the rendered &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; — posts are Markdown, so this enhances the output rather than wrapping it in a component. The overflow fade uses a token rather than a literal, because its position has to track the header height above it and a magic number there silently desyncs the moment the padding changes.&lt;/p&gt;
&lt;h2&gt;Search&lt;a href=&quot;#search&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Two markers do the work:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;article&lt;/span&gt;&lt;span&gt; data-pagefind-body&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &amp;lt;&lt;/span&gt;&lt;span&gt;h1&lt;/span&gt;&lt;span&gt; data-pagefind-meta&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;title&quot;&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;post&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;h1&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;Found a data-pagefind-body element on the site.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;↳ Ignoring pages without this tag.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once any page declares it, Pagefind indexes only pages that have it, and only that element’s subtree. The home page and the feed drop out automatically; searching returns articles rather than fragments of my own navigation. The table of contents carries &lt;code&gt;data-pagefind-ignore&lt;/code&gt; because the heading text is already inside the body and indexing it twice just skews relevance.&lt;/p&gt;
&lt;p&gt;The UI is Pagefind’s Component UI — custom elements, so mounting is markup and a module script:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;pagefind-input&lt;/span&gt;&lt;span&gt; instance&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;blog&quot;&lt;/span&gt;&lt;span&gt; autofocus&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;false&quot;&lt;/span&gt;&lt;span&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;pagefind-input&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;pagefind-summary&lt;/span&gt;&lt;span&gt; instance&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;blog&quot;&lt;/span&gt;&lt;span&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;pagefind-summary&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;pagefind-results&lt;/span&gt;&lt;span&gt; instance&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;blog&quot;&lt;/span&gt;&lt;span&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;pagefind-results&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Elements sharing an &lt;code&gt;instance&lt;/code&gt; attribute find each other, which is what lets the input, the count and the results sit independently in the layout. Two details that cost time: the script needs &lt;code&gt;type=&quot;module&quot;&lt;/code&gt;, and in Astro it also needs &lt;code&gt;is:inline&lt;/code&gt;, because &lt;code&gt;/pagefind/&lt;/code&gt; only exists in build output and the bundler has nothing to resolve at compile time. Custom elements don’t upgrade until their definition loads, so &lt;code&gt;astro dev&lt;/code&gt; renders an inert element rather than an error — a &lt;code&gt;:not(:defined)&lt;/code&gt; rule reserves the input’s height so nothing below it jumps when it paints.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/&lt;/code&gt; and &lt;code&gt;⌘K&lt;/code&gt; focus the field. The deep link is &lt;code&gt;?q=&lt;/code&gt;, which is also the &lt;code&gt;SearchAction&lt;/code&gt; target in the &lt;code&gt;WebSite&lt;/code&gt; schema.&lt;/p&gt;
&lt;h2&gt;Social cards, generated&lt;a href=&quot;#social-cards-generated&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Every post gets a 1200×630 card rendered at build time by Satori and rasterised with resvg, from a handful of frontmatter fields.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; const&lt;/span&gt;&lt;span&gt; GET&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;APIRoute&lt;/span&gt;&lt;span&gt;&amp;lt;{ &lt;/span&gt;&lt;span&gt;post&lt;/span&gt;&lt;span&gt;?: &lt;/span&gt;&lt;span&gt;Post&lt;/span&gt;&lt;span&gt; }&amp;gt; = &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; ({ &lt;/span&gt;&lt;span&gt;props&lt;/span&gt;&lt;span&gt; }) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;post&lt;/span&gt;&lt;span&gt; } = &lt;/span&gt;&lt;span&gt;props&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  return&lt;/span&gt;&lt;span&gt; pngResponse&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    await&lt;/span&gt;&lt;span&gt; renderCard&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      kicker:&lt;/span&gt;&lt;span&gt; `Writing / &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;getCategory&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;post&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;category&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      title:&lt;/span&gt;&lt;span&gt; post&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      footerLeft:&lt;/span&gt;&lt;span&gt; site&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      footerRight:&lt;/span&gt;&lt;span&gt; isoDate&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;post&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;pubDate&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    })&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  );&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;};&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Satori reads WOFF and TTF but not WOFF2, so the cards load the static &lt;code&gt;@fontsource&lt;/code&gt; faces while the site itself serves the variable WOFF2 files. Same fonts, different packaging.&lt;/p&gt;
&lt;p&gt;Previously &lt;code&gt;og:image&lt;/code&gt; pointed at the app icon, so every link I’d ever shared rendered as a generic badge. That was the single worst bug on the site and it took two years to notice.&lt;/p&gt;
&lt;h2&gt;The contact form and its Worker&lt;a href=&quot;#the-contact-form-and-its-worker&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The static site is served by a Worker, and Cloudflare only invokes the script when a request doesn’t match a static asset — so almost nothing reaches it. One route is handled: &lt;code&gt;POST /api/contact&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt; name&lt;/span&gt;&lt;span&gt; !== &lt;/span&gt;&lt;span&gt;&quot;string&quot;&lt;/span&gt;&lt;span&gt; ||&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    typeof&lt;/span&gt;&lt;span&gt; email&lt;/span&gt;&lt;span&gt; !== &lt;/span&gt;&lt;span&gt;&quot;string&quot;&lt;/span&gt;&lt;span&gt; ||&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    typeof&lt;/span&gt;&lt;span&gt; message&lt;/span&gt;&lt;span&gt; !== &lt;/span&gt;&lt;span&gt;&quot;string&quot;&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  return&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;status:&lt;/span&gt;&lt;span&gt; &quot;invalid&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;code:&lt;/span&gt;&lt;span&gt; 400&lt;/span&gt;&lt;span&gt; };&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The rules that validate the request live in &lt;code&gt;src/data/contact.ts&lt;/code&gt;, which the Worker imports &lt;em&gt;and&lt;/em&gt; the form imports. One module, so the length limits and the email pattern can’t drift apart between the two — which is the failure mode of every other form I’ve written, where the browser check and the server check disagree and one of them is wrong.&lt;/p&gt;
&lt;p&gt;It accepts JSON and form-encoded bodies. With JavaScript off the form posts normally and the Worker answers with a 303 to a static page under &lt;code&gt;/contact/&amp;lt;status&amp;gt;/&lt;/code&gt;, so a failed submission still shows a real result. With JavaScript on, it’s a fetch and a status toast.&lt;/p&gt;
&lt;h2&gt;Deploy&lt;a href=&quot;#deploy&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The repository is connected to the Worker. &lt;code&gt;git push&lt;/code&gt; to the default branch &lt;em&gt;is&lt;/em&gt; the deploy: Cloudflare clones, runs &lt;code&gt;npm run build&lt;/code&gt;, publishes. Nothing on my machine, no CI provider in front of it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;assets&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;directory&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;./dist&quot;&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;compatibility_flags&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;span&gt;&quot;nodejs_compat&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;observability&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;enabled&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;assets.directory&lt;/code&gt; is the contract between the two halves — Astro’s output is what gets uploaded and served.&lt;/p&gt;
&lt;p&gt;Publishing a post is now: add a file with valid frontmatter, push. Astro validates it against the schema and renders every route, Pagefind indexes the output, Cloudflare serves it. Because validation happens in the build, a malformed post fails before it reaches the edge and the previous deployment stays up. That’s what makes push-to-publish feel ordinary rather than nerve-wracking.&lt;/p&gt;
&lt;h2&gt;One thing I removed on purpose&lt;a href=&quot;#one-thing-i-removed-on-purpose&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The redesign had a dark mode. It looked fine in isolation — a full second set of tokens under &lt;code&gt;prefers-color-scheme: dark&lt;/code&gt;, background at &lt;code&gt;#14161a&lt;/code&gt;, and the hero canvas re-reading its stroke colour when the scheme flipped. I cut the whole branch.&lt;/p&gt;
&lt;p&gt;The reason is that white here isn’t the absence of dark mode, it’s the material. Sections separate by a change of tone rather than by a card edge, and the canvas fades toward &lt;code&gt;#ffffff&lt;/code&gt; with a &lt;code&gt;rgba(255, 255, 255, 0.006)&lt;/code&gt; wash per frame to get the long-exposure trail — both of those decisions assume a light ground. A dark variant isn’t a reskin of that, it’s a second site. And the only people who saw mine were the ones whose OS asked for it, which meant I was reviewing my work in one material and shipping another.&lt;/p&gt;
&lt;p&gt;So: one theme, white, regardless of preference. The exception is the contact band, which is dark on every device:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;/* Single theme. There is no `prefers-color-scheme` branch: the site is&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;   white on a dark-preference OS, the same as it is on a light one. The&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;   hero canvas no longer re-reads its stroke on a theme flip either. */&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.band-ink&lt;/code&gt; isn’t a theme either. It re-declares the token block inside its own scope, so &lt;code&gt;bg-surface&lt;/code&gt;, &lt;code&gt;text-on-surface&lt;/code&gt; and friends keep working inside the band and no component has to know it landed there. Cheaper to reason about than a &lt;code&gt;data-theme&lt;/code&gt; attribute that half the components would have had to check, and it gets &lt;code&gt;color-scheme: dark&lt;/code&gt; for free — which is what stops the browser rendering a light scrollbar and light form controls in the middle of the page.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;No database, no server, no search service. Twenty HTML pages, an index directory, and a Worker that only wakes up when someone fills in the contact form.&lt;/p&gt;
&lt;p&gt;The structural migration was the whole job and it was a straightforward one. Astro gave me typed content and static output, Pagefind gave me search without a backend, Cloudflare gave me deploys without a pipeline. Nothing about it needed a rewrite of how the site worked — and the parts that did get rewritten later taught me more than the parts that didn’t.&lt;/p&gt;</content:encoded><category>Systems &amp; Infrastructure</category><category>Astro</category><category>Pagefind</category><category>Cloudflare</category><category>Frontend</category></item><item><title>Model Selection on EEG Signals: Least Squares, AIC/BIC and Rejection ABC by Hand</title><link>https://rustamniraula.com.np/blog/eeg-signal-analysis-model-selection-abc/</link><guid isPermaLink="true">https://rustamniraula.com.np/blog/eeg-signal-analysis-model-selection-abc/</guid><description>Fitting five polynomial regression architectures to four EEG input channels using nothing but normal equations, then comparing them with RSS, log-likelihood, AIC and BIC, validating on a held-out split, and running rejection ABC — plus the two places where the numbers quietly disagreed with the method.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;An MSc statistics assignment handed me 201 samples of simulated EEG data: four input channels &lt;code&gt;x1&lt;/code&gt;–&lt;code&gt;x4&lt;/code&gt;, one output channel &lt;code&gt;y&lt;/code&gt;, sampled every 2 ms over 0.4 seconds. Five candidate polynomial regression architectures were given. Pick the right one, justify the choice, validate it, then estimate the two most influential parameters with Approximate Bayesian Computation.&lt;/p&gt;
&lt;p&gt;The interesting constraint was that everything had to be implemented from the mathematics — no &lt;code&gt;lm()&lt;/code&gt;, no &lt;code&gt;AIC()&lt;/code&gt;, no &lt;code&gt;abc&lt;/code&gt; package. Normal equations, log-likelihood, information criteria and rejection sampling all written out in base R matrix algebra. Source and rendered notebook: &lt;a href=&quot;https://github.com/rustamniraula90/eeg-signal-analysis&quot;&gt;github.com/rustamniraula90/eeg-signal-analysis&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;That constraint turned out to matter. Twice, writing a formula out by hand made visible something a library call would have handled silently — and in both cases the pipeline produced numbers that looked fine but weren’t. Those are the parts of this worth reading.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The data, before any model&lt;a href=&quot;#the-data-before-any-model&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Before fitting anything, four questions about the data are worth answering, because each one rules something in or out later.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Are the inputs independent?&lt;/strong&gt; They are emphatically not:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;      x1    x2    x3    x4     y&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;x1 1.000 0.259 0.584 0.805 0.857&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;x2 0.259 1.000 0.523 0.364 0.194&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;x3 0.584 0.523 1.000 0.708 0.526&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;x4 0.805 0.364 0.708 1.000 0.775&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;y  0.857 0.194 0.526 0.775 1.000&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;x1&lt;/code&gt; and &lt;code&gt;x4&lt;/code&gt; correlate at 0.805 with each other, and 0.857 / 0.775 with the output. &lt;code&gt;x2&lt;/code&gt; is nearly useless linearly (0.194 with &lt;code&gt;y&lt;/code&gt;). This predicts something concrete: any model containing both &lt;code&gt;x1&lt;/code&gt; and &lt;code&gt;x4&lt;/code&gt; terms will have a nearly singular &lt;code&gt;XᵀX&lt;/code&gt;, and their individual coefficients will be unstable even if the model’s &lt;em&gt;predictions&lt;/em&gt; are fine. Collinearity attacks interpretation before it attacks fit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is anything non-Gaussian?&lt;/strong&gt; The per-channel skewness says yes, and only in one place. The inputs are all near-symmetric (&lt;code&gt;|skew| ≤ 0.32&lt;/code&gt;), but the output has skewness &lt;strong&gt;−1.79&lt;/strong&gt; — a long left tail, driven by a minimum of −9.60 against a median of 0.41. The inputs are drawn symmetric; the output isn’t. Something in the generating process is asymmetric, and a symmetric polynomial in the inputs can only reproduce that through odd powers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What shape are the relationships?&lt;/strong&gt; Loess curves through &lt;code&gt;y&lt;/code&gt; against each input come back visibly bent, not straight. That’s the whole justification for a polynomial basis rather than plain multiple linear regression.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How much data is there?&lt;/strong&gt; n = 201, and the candidate models want 3–6 parameters. That ratio is comfortable, but it’s also why BIC (whose penalty is &lt;code&gt;k·log n = 5.30k&lt;/code&gt; here) will bite noticeably harder than AIC (&lt;code&gt;2k&lt;/code&gt;).&lt;/p&gt;
&lt;h2&gt;Least squares written out is a conditioning problem&lt;a href=&quot;#least-squares-written-out-is-a-conditioning-problem&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The estimator is one line of linear algebra:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;θ̂ = (Xᵀ X)⁻¹ Xᵀ y&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;least_squares&lt;/span&gt;&lt;span&gt; &amp;lt;- &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Xmat&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;yvec&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  XtX&lt;/span&gt;&lt;span&gt; &amp;lt;- &lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Xmat&lt;/span&gt;&lt;span&gt;) %*% &lt;/span&gt;&lt;span&gt;Xmat&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  theta_hat&lt;/span&gt;&lt;span&gt; &amp;lt;- &lt;/span&gt;&lt;span&gt;solve&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;XtX&lt;/span&gt;&lt;span&gt;) %*% &lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Xmat&lt;/span&gt;&lt;span&gt;) %*% &lt;/span&gt;&lt;span&gt;yvec&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  return&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;theta_hat&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s the textbook form, and calling it is where the polynomial basis starts to hurt. The design matrix for Model 1 holds a column of &lt;code&gt;x4&lt;/code&gt; (range ±8.7) next to a column of &lt;code&gt;x1⁴&lt;/code&gt; (range up to 5,800). Squaring the matrix to form &lt;code&gt;XᵀX&lt;/code&gt; squares that disparity too. The condition number of &lt;code&gt;XᵀX&lt;/code&gt;:&lt;/p&gt;

















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Model&lt;/th&gt;&lt;th&gt;κ(XᵀX)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Model 1 (with &lt;code&gt;x1²&lt;/code&gt;, &lt;code&gt;x1³&lt;/code&gt;, &lt;code&gt;x1⁴&lt;/code&gt;, &lt;code&gt;x2⁴&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;2.8 × 10⁶&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Model 2 (with &lt;code&gt;x4&lt;/code&gt;, &lt;code&gt;x1³&lt;/code&gt;, &lt;code&gt;x3⁴&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;7.4 × 10⁴&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Double precision carries about 16 significant digits, so at κ ≈ 10⁶ you’re spending six of them on the inversion. Model 1 survives it. A model with &lt;code&gt;x1⁵&lt;/code&gt; or &lt;code&gt;x1⁶&lt;/code&gt; in the same basis would not, and &lt;code&gt;solve()&lt;/code&gt; would return numbers rather than an error.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What I’d change.&lt;/strong&gt; &lt;code&gt;solve(t(X) %*% X)&lt;/code&gt; is the formula, not the algorithm. &lt;code&gt;qr.solve(X, y)&lt;/code&gt; reaches the same estimate through a QR decomposition without ever forming &lt;code&gt;XᵀX&lt;/code&gt;, so it operates at κ(X) rather than κ(X)² — roughly half the digits lost. Writing the normal equations out by hand is the right call for an assignment about &lt;em&gt;understanding&lt;/em&gt; least squares; it’s the wrong call for one about &lt;em&gt;computing&lt;/em&gt; it. That the two differ, and why, was the first thing this exercise taught me.&lt;/p&gt;
&lt;h2&gt;RSS ranks fit; it does not rank models&lt;a href=&quot;#rss-ranks-fit-it-does-not-rank-models&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;With five architectures fitted on the full dataset:&lt;/p&gt;









































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Model&lt;/th&gt;&lt;th&gt;Terms&lt;/th&gt;&lt;th&gt;k&lt;/th&gt;&lt;th&gt;RSS&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Model 1&lt;/td&gt;&lt;td&gt;&lt;code&gt;x4, x1², x1³, x2⁴, x1⁴&lt;/code&gt;&lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;35.397&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Model 2&lt;/td&gt;&lt;td&gt;&lt;code&gt;x4, x1³, x3⁴&lt;/code&gt;&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2.140&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Model 3&lt;/td&gt;&lt;td&gt;&lt;code&gt;x3³, x3⁴&lt;/code&gt;&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;463.312&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Model 4&lt;/td&gt;&lt;td&gt;&lt;code&gt;x2, x1³, x3⁴&lt;/code&gt;&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;20.259&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Model 5&lt;/td&gt;&lt;td&gt;&lt;code&gt;x4, x1², x1³, x3⁴&lt;/code&gt;&lt;/td&gt;&lt;td&gt;5&lt;/td&gt;&lt;td&gt;&lt;strong&gt;2.136&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The first thing to notice looks like a bug: &lt;strong&gt;Model 1 has the most parameters and nearly the worst fit.&lt;/strong&gt; Six parameters, RSS 35.4 — sixteen times worse than Model 2’s four parameters.&lt;/p&gt;
&lt;p&gt;It isn’t a bug, and understanding why sharpened my intuition more than the rest of the fitting did. “More parameters never increase RSS” is true &lt;em&gt;within a nested family&lt;/em&gt; — where the bigger model can always reproduce the smaller one by zeroing a coefficient. These five models are not nested. Model 1 contains &lt;code&gt;x2⁴&lt;/code&gt; and &lt;code&gt;x1⁴&lt;/code&gt; but has &lt;strong&gt;no &lt;code&gt;x3⁴&lt;/code&gt; term&lt;/strong&gt;, and Model 2 has one. There is no setting of Model 1’s six coefficients that reproduces Model 2. The count of parameters is irrelevant; what matters is whether the span of the columns contains the truth.&lt;/p&gt;
&lt;p&gt;And &lt;code&gt;x3⁴&lt;/code&gt; is clearly part of the truth. Every model containing it lands at RSS ≈ 2; every model without it lands between 20 and 463. Model 3, which has &lt;code&gt;x3³&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;x3⁴&lt;/code&gt; but nothing else, is the worst of the five — so &lt;code&gt;x3⁴&lt;/code&gt; is necessary but nowhere near sufficient. The signal needs a term from &lt;code&gt;x1&lt;/code&gt; as well.&lt;/p&gt;
&lt;p&gt;The comparison that actually decides the assignment is Model 2 against Model 5, because those two &lt;em&gt;are&lt;/em&gt; nested: Model 5 is Model 2 plus an &lt;code&gt;x1²&lt;/code&gt; term. It fits better, as it must — but by 0.004 in RSS, a 0.2% improvement. And its estimated coefficient on &lt;code&gt;x1²&lt;/code&gt; is 0.00033, which is indistinguishable from zero.&lt;/p&gt;
&lt;h2&gt;Log-likelihood, and the σ² that got assumed&lt;a href=&quot;#log-likelihood-and-the-σ-that-got-assumed&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Turning RSS into a likelihood requires an assumption that RSS itself doesn’t make: that the errors are i.i.d. Gaussian. Under that assumption,&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;ln L = −(n/2)·ln(2π) − (n/2)·ln σ̂²  −  RSS / (2σ̂²)        where  σ̂² = RSS / (n − 1)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;compute_loglik&lt;/span&gt;&lt;span&gt; &amp;lt;- &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;rss&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  sigma2_hat&lt;/span&gt;&lt;span&gt; &amp;lt;- &lt;/span&gt;&lt;span&gt;rss&lt;/span&gt;&lt;span&gt; / (&lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt; - &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  loglik&lt;/span&gt;&lt;span&gt; &amp;lt;- -(&lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;)*&lt;/span&gt;&lt;span&gt;log&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt;pi&lt;/span&gt;&lt;span&gt;) - (&lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;)*&lt;/span&gt;&lt;span&gt;log&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;sigma2_hat&lt;/span&gt;&lt;span&gt;) - (&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;/(&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt;sigma2_hat&lt;/span&gt;&lt;span&gt;))*&lt;/span&gt;&lt;span&gt;rss&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  return&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;list&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;loglik&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;loglik&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;sigma2&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;sigma2_hat&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things about that &lt;code&gt;n - 1&lt;/code&gt; are worth being explicit about, since it’s the specification I was given rather than the one I’d choose.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;unbiased&lt;/strong&gt; residual variance for a regression divides by &lt;code&gt;n - k&lt;/code&gt;, not &lt;code&gt;n - 1&lt;/code&gt;, because fitting k coefficients already consumed k degrees of freedom. With n = 201 and k ≤ 6 the difference is under 3%, and — critically — it’s a &lt;em&gt;monotone&lt;/em&gt; function of RSS applied identically to every model, so the AIC/BIC ranking is untouched. It’s the right shortcut for a comparison and the wrong one for reporting σ² as a quantity. Later in the pipeline, that distinction stops being cosmetic.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;Gaussian assumption&lt;/strong&gt; is testable, and this is where the Q-Q plots earn their place in the pipeline rather than being a box to tick. For Model 2 the residuals sit on the reference line across the full range: skewness −0.04, excess kurtosis 0.15, Shapiro-Wilk &lt;code&gt;W = 0.9975, p = 0.99&lt;/code&gt;. The likelihood is licensed. For Models 1, 3 and 4 the same plots bend away at both tails — their residuals still contain structure the model failed to absorb, which means their reported log-likelihoods are computed under an assumption their own residuals contradict.&lt;/p&gt;
&lt;p&gt;That reframed the diagnostic for me. A Q-Q plot isn’t a check performed &lt;em&gt;after&lt;/em&gt; selection. It’s a check on whether the quantity you’re selecting &lt;em&gt;with&lt;/em&gt; is meaningful, and it should be read before the AIC table, not after.&lt;/p&gt;
&lt;h2&gt;AIC and BIC: the same answer, and a useful disagreement in margin&lt;a href=&quot;#aic-and-bic-the-same-answer-and-a-useful-disagreement-in-margin&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;AIC = 2k − 2·ln L          BIC = k·ln(n) − 2·ln L&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;





















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Model&lt;/th&gt;&lt;th&gt;k&lt;/th&gt;&lt;th&gt;RSS&lt;/th&gt;&lt;th&gt;ln L&lt;/th&gt;&lt;th&gt;AIC&lt;/th&gt;&lt;th&gt;BIC&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Model 2&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;2.140&lt;/td&gt;&lt;td&gt;171.32&lt;/td&gt;&lt;td&gt;&lt;strong&gt;−334.65&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;−321.44&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Model 5&lt;/td&gt;&lt;td&gt;5&lt;/td&gt;&lt;td&gt;2.136&lt;/td&gt;&lt;td&gt;171.52&lt;/td&gt;&lt;td&gt;−333.05&lt;/td&gt;&lt;td&gt;−316.53&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Model 4&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;20.259&lt;/td&gt;&lt;td&gt;−54.59&lt;/td&gt;&lt;td&gt;117.18&lt;/td&gt;&lt;td&gt;130.39&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Model 1&lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;35.397&lt;/td&gt;&lt;td&gt;−110.67&lt;/td&gt;&lt;td&gt;233.34&lt;/td&gt;&lt;td&gt;253.16&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Model 3&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;463.312&lt;/td&gt;&lt;td&gt;−369.14&lt;/td&gt;&lt;td&gt;744.27&lt;/td&gt;&lt;td&gt;754.18&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Both criteria pick Model 2, which is the answer I want — agreement between two criteria with different penalty philosophies is much stronger evidence than either alone.&lt;/p&gt;
&lt;p&gt;The Model 2 / Model 5 pair is where the arithmetic becomes legible. Model 5’s extra &lt;code&gt;x1²&lt;/code&gt; term buys &lt;strong&gt;+0.20 of log-likelihood&lt;/strong&gt;. AIC charges 1 unit of log-likelihood per parameter, so the trade is 0.20 earned against 1.00 charged, and Model 5 loses by 1.60. BIC charges &lt;code&gt;log(201)/2 = 2.65&lt;/code&gt; per parameter, so it loses by 4.11 — the same verdict, 2.6× more emphatic.&lt;/p&gt;
&lt;p&gt;This is the parsimony penalty doing exactly the job it exists for. On RSS alone Model 5 wins every time, and would keep winning as I bolted on more powers of &lt;code&gt;x1&lt;/code&gt;, each one soaking up a little noise. The criteria convert “does this term buy more fit than it costs in flexibility?” into a number, and the answer for &lt;code&gt;x1²&lt;/code&gt; is no. Its coefficient of 0.00033 was already whispering that; AIC and BIC just make it a decision rather than a judgement call.&lt;/p&gt;
&lt;p&gt;So Model 2 is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;y = 0.1436·x4  +  0.01004·x1³  −  0.001913·x3⁴  +  0.4831&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Reading the coefficients requires care.&lt;/strong&gt; &lt;code&gt;x3⁴&lt;/code&gt;’s coefficient is 75× smaller than &lt;code&gt;x4&lt;/code&gt;’s, which says nothing about importance — the regressors live on wildly different scales. Multiply each coefficient by the standard deviation of its own column to get comparable contributions:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Term&lt;/th&gt;&lt;th&gt;|θ|&lt;/th&gt;&lt;th&gt;sd(column)&lt;/th&gt;&lt;th&gt;sd(θ · column)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;x4&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.1436&lt;/td&gt;&lt;td&gt;3.15&lt;/td&gt;&lt;td&gt;0.452&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;x1³&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.01004&lt;/td&gt;&lt;td&gt;144.4&lt;/td&gt;&lt;td&gt;&lt;strong&gt;1.450&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;x3⁴&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.001913&lt;/td&gt;&lt;td&gt;232.9&lt;/td&gt;&lt;td&gt;0.446&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;x1³&lt;/code&gt; is the dominant driver by a factor of three, despite having the second-smallest coefficient. It also explains the output’s −1.79 skewness: a cubic term is the odd-powered, asymmetry-producing part of the model, and &lt;code&gt;−0.0019·x3⁴&lt;/code&gt; pulls hard in one direction only. The structure the exploratory analysis flagged is visible in the fitted parameters. Hold on to this table — it comes back to bite in the ABC section.&lt;/p&gt;
&lt;h2&gt;Validation: 95% intervals that covered 80%&lt;a href=&quot;#validation-95-intervals-that-covered-80&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Splitting 70/30 (140 train, 61 test), re-estimating on the training slice, and predicting the held-out points with 95% intervals:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;pred_var&lt;/span&gt;&lt;span&gt; &amp;lt;- &lt;/span&gt;&lt;span&gt;sapply&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;seq_len&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;nrow&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;X_test&lt;/span&gt;&lt;span&gt;)), &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  xi&lt;/span&gt;&lt;span&gt; &amp;lt;- &lt;/span&gt;&lt;span&gt;X_test&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;, , &lt;/span&gt;&lt;span&gt;drop&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;FALSE&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  sigma2_test&lt;/span&gt;&lt;span&gt; * (&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;xi&lt;/span&gt;&lt;span&gt; %*% &lt;/span&gt;&lt;span&gt;XtX_inv_train&lt;/span&gt;&lt;span&gt; %*% &lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;xi&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;})&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;(1 + xᵢ(XᵀX)⁻¹xᵢᵀ)&lt;/code&gt; factor is right, and worth pausing on: the &lt;code&gt;1&lt;/code&gt; is the irreducible noise on a new observation and the quadratic form is the uncertainty in the fitted surface itself, which grows as you move away from the centre of the training data. Including both makes this a &lt;strong&gt;prediction&lt;/strong&gt; interval rather than a confidence interval on the mean — the correct choice when the question is “where will the next observation fall.”&lt;/p&gt;
&lt;p&gt;The training coefficients came back at &lt;code&gt;(0.1404, 0.01017, −0.00188, 0.48595)&lt;/code&gt; against full-data &lt;code&gt;(0.1436, 0.01004, −0.00191, 0.48307)&lt;/code&gt; — stable to three significant figures on 70% of the data, which is the real evidence that Model 2 isn’t overfitting. Validation RSS was 0.650 on 61 points, versus 2.140 on 201; per-point, 0.0107 against 0.0107. Out-of-sample error is &lt;em&gt;identical&lt;/em&gt; to in-sample error.&lt;/p&gt;
&lt;p&gt;And then the coverage came back at &lt;strong&gt;80.3%&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That number is wrong in an interesting way. Under-coverage normally means overfitting — but the per-point RSS just ruled overfitting out. Something else was shrinking the intervals, and the culprit was the variance estimate:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;sigma2_test&lt;/span&gt;&lt;span&gt; &amp;lt;- &lt;/span&gt;&lt;span&gt;rss_test&lt;/span&gt;&lt;span&gt; / (&lt;/span&gt;&lt;span&gt;nrow&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;X_train&lt;/span&gt;&lt;span&gt;) - &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;)   &lt;/span&gt;&lt;span&gt;# 0.650 / 139&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That divides the &lt;strong&gt;test&lt;/strong&gt; RSS by the &lt;strong&gt;training&lt;/strong&gt; sample count. It’s a units mismatch: 61 points’ worth of squared error spread across 140 degrees of freedom, giving σ² = 0.00468 where the model’s actual noise level is 0.0107 — a factor of 2.4 too small, so every interval is 1.56× too narrow. Rerunning with the denominators aligned:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;σ̂² definition&lt;/th&gt;&lt;th&gt;σ̂²&lt;/th&gt;&lt;th&gt;Mean half-width&lt;/th&gt;&lt;th&gt;Coverage&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;rss_test / (n_train − 1)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.00468&lt;/td&gt;&lt;td&gt;0.136&lt;/td&gt;&lt;td&gt;80.3%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;rss_test / (n_test − k)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.01141&lt;/td&gt;&lt;td&gt;0.213&lt;/td&gt;&lt;td&gt;&lt;strong&gt;93.4%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;rss_train / (n_train − k)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.01133&lt;/td&gt;&lt;td&gt;0.212&lt;/td&gt;&lt;td&gt;&lt;strong&gt;93.4%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;93.4% against a nominal 95%, on 61 points — that’s four misses where you’d expect three, comfortably within binomial noise. The model was calibrated the whole time; the reported interval wasn’t.&lt;/p&gt;
&lt;p&gt;This is the failure mode I’d never have found with &lt;code&gt;predict(fit, interval = &quot;prediction&quot;)&lt;/code&gt;, because that function’s whole job is knowing which n and which k belong in the denominator. Two symbols that both spell “sample size” got crossed, no error was raised, and the pipeline produced a plausible-looking plot of intervals that were half again too tight. &lt;strong&gt;The check that caught it was empirical coverage&lt;/strong&gt; — counting how many held-out points actually landed inside — and it’s now the first thing I compute after any interval, precisely because it tests the arithmetic rather than trusting it.&lt;/p&gt;
&lt;h2&gt;Rejection ABC, and a tolerance that could never accept&lt;a href=&quot;#rejection-abc-and-a-tolerance-that-could-never-accept&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The last phase estimates a posterior over two parameters without writing down a likelihood. Rejection ABC is almost embarrassingly simple:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Draw candidate parameters from a prior.&lt;/li&gt;
&lt;li&gt;Simulate a dataset from them.&lt;/li&gt;
&lt;li&gt;Keep the draw if the simulated data is within ε of the observed data.&lt;/li&gt;
&lt;li&gt;The kept draws approximate the posterior.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Step 3 is the entire method, and step 3 is where I got it wrong.&lt;/p&gt;
&lt;p&gt;The obvious tolerance is a fraction of the reference fit — 5% of Model 2’s RSS, ε = 0.107. Run 20,000 simulations against that and &lt;strong&gt;zero&lt;/strong&gt; are accepted. Not “few.” Zero.&lt;/p&gt;
&lt;p&gt;Writing the simulator by hand is what showed me why, because the noise term is right there in the line:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;y_sim&lt;/span&gt;&lt;span&gt; &amp;lt;- &lt;/span&gt;&lt;span&gt;X_full&lt;/span&gt;&lt;span&gt; %*% &lt;/span&gt;&lt;span&gt;theta_candidate&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;rnorm&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;mean&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;sd&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;sqrt&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;sigma2_ref&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every simulated dataset gets a &lt;strong&gt;fresh&lt;/strong&gt; draw of noise at all 201 points. So even with candidate parameters exactly equal to the truth, the distance &lt;code&gt;Σ(y_obs − y_sim)²&lt;/code&gt; is a sum of squared differences between two independent noise realisations. Its expectation is roughly &lt;code&gt;n·σ² = 201 × 0.0107 = 2.15&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;Naive tolerance (5% of RSS):                  0.107&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Expected distance from noise injection alone: 2.150   (20.1× larger)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Smallest distance seen across 20,000 draws:   3.231&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The tolerance was set 20× below the &lt;em&gt;floor&lt;/em&gt; of the distance distribution. No parameter values could have cleared it, so the acceptance rate was zero for reasons that had nothing to do with parameters at all.&lt;/p&gt;
&lt;p&gt;The fix is to stop guessing an absolute distance and let the simulations calibrate it — run all 20,000, then set ε to the 1st percentile of the observed distances:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;acceptance_quantile&lt;/span&gt;&lt;span&gt; &amp;lt;- &lt;/span&gt;&lt;span&gt;0.01&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;tolerance_epsilon&lt;/span&gt;&lt;span&gt; &amp;lt;- &lt;/span&gt;&lt;span&gt;as.numeric&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;quantile&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;distances&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;probs&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;acceptance_quantile&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ε lands at 4.239 and 200 draws are accepted, by construction. This is standard practice for rejection ABC and it’s better than a guess for a structural reason: the &lt;em&gt;scale&lt;/em&gt; of the distance metric depends on n, on σ², and on how the simulator injects noise, none of which you know before running it. A quantile is scale-free. It also makes the honest trade explicit — ε and the acceptance rate are the same knob, and you’re choosing between posterior sharpness and effective sample size, not discovering a tolerance that’s objectively correct.&lt;/p&gt;
&lt;p&gt;The generalisable lesson: &lt;strong&gt;when a rejection sampler accepts nothing, check the floor of your distance metric before touching the priors.&lt;/strong&gt; I lost time assuming my prior windows were badly placed. They were fine. The threshold was below the minimum achievable value, and one line of arithmetic — &lt;code&gt;n × σ²&lt;/code&gt; — would have said so immediately.&lt;/p&gt;
&lt;h2&gt;What the posterior says, and what selecting by magnitude cost me&lt;a href=&quot;#what-the-posterior-says-and-what-selecting-by-magnitude-cost-me&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The assignment asks for the two “most influential” parameters, operationalised as the two largest by absolute value. For Model 2 that’s &lt;code&gt;bias&lt;/code&gt; (0.4831) and &lt;code&gt;x4&lt;/code&gt; (0.1436), with &lt;code&gt;x1³&lt;/code&gt; and &lt;code&gt;x3⁴&lt;/code&gt; held fixed. Priors were uniform, centred on the least-squares estimates, ±50%.&lt;/p&gt;
&lt;p&gt;The 200 accepted draws:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Parameter&lt;/th&gt;&lt;th&gt;LS estimate&lt;/th&gt;&lt;th&gt;Posterior mean&lt;/th&gt;&lt;th&gt;Prior sd&lt;/th&gt;&lt;th&gt;Posterior sd&lt;/th&gt;&lt;th&gt;Shrinkage&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;bias&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.48307&lt;/td&gt;&lt;td&gt;0.48291&lt;/td&gt;&lt;td&gt;0.1401&lt;/td&gt;&lt;td&gt;0.0238&lt;/td&gt;&lt;td&gt;5.9×&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;x4&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.14358&lt;/td&gt;&lt;td&gt;0.14328&lt;/td&gt;&lt;td&gt;0.0417&lt;/td&gt;&lt;td&gt;0.0081&lt;/td&gt;&lt;td&gt;5.1×&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Posterior means recover the least-squares estimates to four significant figures. That agreement is the real result: a likelihood-free method that only ever &lt;em&gt;simulates&lt;/em&gt; forward, never inverting anything, lands on the same answer as the closed-form estimator. When an analytic solution exists, ABC should reproduce it — and here it does, which is what makes it trustworthy on problems where no closed form is available.&lt;/p&gt;
&lt;p&gt;Shrinkage of ~5-6× from prior to posterior is the data doing work; the joint posterior is a compact blob rather than the ridge that collinearity would produce, and the posterior correlation between the two parameters is −0.08, effectively independent. That last part makes sense: &lt;code&gt;x4&lt;/code&gt; is mean-centred (mean 0.015), so shifting the intercept barely trades off against its slope.&lt;/p&gt;
&lt;p&gt;But the parameter &lt;em&gt;selection&lt;/em&gt; is where I’d push back on the specification. Ranking by |θ| is ranking by coefficient magnitude, and coefficient magnitude is an artifact of the regressor’s units. From the contribution table earlier:&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Term&lt;/th&gt;&lt;th&gt;|θ| rank&lt;/th&gt;&lt;th&gt;Contribution rank&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;bias&lt;/code&gt;&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;— (constant, no variance)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;x4&lt;/code&gt;&lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;2 (sd 0.452)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;x1³&lt;/code&gt;&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;&lt;strong&gt;1 (sd 1.450)&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;x3⁴&lt;/code&gt;&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;3 (sd 0.446)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The |θ| criterion selected the intercept — which contributes no variance at all, being constant — and skipped &lt;code&gt;x1³&lt;/code&gt;, the term that drives three times more variation in &lt;code&gt;y&lt;/code&gt; than anything else in the model. The ABC posterior is over the two parameters that happen to be attached to the smallest-scale columns.&lt;/p&gt;
&lt;p&gt;The estimates are still correct; the question they answer is just less interesting than the one I’d have chosen. Ranking by &lt;code&gt;|θⱼ| · sd(xⱼ)&lt;/code&gt; would have put &lt;code&gt;x1³&lt;/code&gt; and &lt;code&gt;x4&lt;/code&gt; under the microscope — the two terms actually shaping the signal — and the resulting joint posterior would likely have shown the collinearity structure the exploratory correlation matrix predicted, since &lt;code&gt;x1&lt;/code&gt; and &lt;code&gt;x4&lt;/code&gt; correlate at 0.805. That’s the follow-up run worth doing, and it costs nothing but changing one &lt;code&gt;order()&lt;/code&gt; call.&lt;/p&gt;
&lt;h2&gt;What’s worth keeping from this&lt;a href=&quot;#whats-worth-keeping-from-this&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RSS ranks models only within a nested family.&lt;/strong&gt; Across non-nested architectures, six parameters can fit sixteen times worse than four; what matters is whether the column span contains the truth, and here that meant containing &lt;code&gt;x3⁴&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AIC and BIC agreeing is worth more than either alone&lt;/strong&gt;, and the margin between them is informative — BIC’s &lt;code&gt;log(n)/2 = 2.65&lt;/code&gt; per parameter versus AIC’s 1.0 turned the same verdict on &lt;code&gt;x1²&lt;/code&gt; into a 2.6× stronger one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Q-Q plots belong before the AIC table, not after.&lt;/strong&gt; They test whether the Gaussian likelihood you’re ranking with is licensed at all; three of five models here failed that test while still reporting a log-likelihood.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Coefficient magnitude is not importance.&lt;/strong&gt; Multiply by the regressor’s standard deviation before ranking anything, or the intercept wins a contest it isn’t in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Empirical coverage is the check that catches interval bugs.&lt;/strong&gt; Nominal 95% covering 80.3% traced back to one crossed denominator — test RSS over training count — that no error message would ever have flagged.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When a rejection sampler accepts nothing, check the floor of your distance metric first.&lt;/strong&gt; Re-simulating noise puts an &lt;code&gt;n·σ²&lt;/code&gt; lower bound on the distance, and no tolerance below it can accept regardless of the parameters.&lt;/li&gt;
&lt;li&gt;Writing least squares as &lt;code&gt;solve(t(X) %*% X)&lt;/code&gt; teaches you the estimator and hides the conditioning; QR gets the same answer at half the digit loss. &lt;strong&gt;The formula and the algorithm are different things&lt;/strong&gt;, and for polynomial bases that difference is measured in lost significant figures.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>Machine Learning &amp; Statistics</category><category>R</category><category>Statistics</category><category>Machine Learning</category><category>Bayesian</category></item><item><title>Drawing a Family Tree With Thousands of People: Server-Side Layout and Level-of-Detail Rendering</title><link>https://rustamniraula.com.np/blog/scaling-family-tree-layout-and-rendering/</link><guid isPermaLink="true">https://rustamniraula.com.np/blog/scaling-family-tree-layout-and-rendering/</guid><description>Notes on moving tree layout out of the client and into stored geometry, then rendering only the viewport across three levels of detail — so draw cost tracks what&apos;s on screen instead of how big the tree is.</description><pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A family tree has an awkward property: it has no natural size limit. One family adds a few dozen people and stops. Another keeps going for eight generations and passes a thousand. The screen showing it, meanwhile, is always about 1400 by 900 pixels.&lt;/p&gt;
&lt;p&gt;That mismatch is the whole engineering problem, and it splits into two questions that turn out to be independent:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Who decides where each person’s box goes&lt;/strong&gt; — and how often does that get decided?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What does the client actually draw&lt;/strong&gt;, given that almost none of the tree is on screen at any moment?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is a journal of working both out for &lt;a href=&quot;https://srishtiuniverse.com/&quot;&gt;srishtiuniverse.com&lt;/a&gt;, across a Spring Boot backend, a React web client and a Flutter mobile client. I’m only writing about tree building and rendering here; everything else about the system is out of scope.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Where the first version spent its time&lt;a href=&quot;#where-the-first-version-spent-its-time&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The original arrangement was the obvious one. The backend assembled the entire tree — every person, every relationship, every node’s details — and handed it over. Each client then took that graph and worked out the geometry itself: the web app computed a layout and rendered it through ReactFlow, and there was a separate layout implementation on the server side for its own rendering path.&lt;/p&gt;
&lt;p&gt;Two things about that shape bothered me once trees got large.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The layout was recomputed on every single view.&lt;/strong&gt; Opening the tree meant solving the layout again, from scratch, in the client. But the arrangement of a tree only depends on the tree’s &lt;em&gt;shape&lt;/em&gt; — and the shape doesn’t change when you look at it. I was paying a per-view cost for an answer that was the same every time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Geometry had two independent implementations.&lt;/strong&gt; Two layout engines, two sets of spacing rules, two chances to disagree. Any picture of the tree — the interactive view, an export — had to be produced by whichever engine that path happened to use.&lt;/p&gt;
&lt;p&gt;And underneath both: &lt;strong&gt;rendering cost scaled with the whole tree, not the visible part.&lt;/strong&gt; ReactFlow gives every node a real component in the DOM. That’s excellent when nodes are interactive things you want to style and attach handlers to, and a few hundred of them is comfortable. A thousand people means a thousand mounted components, each with layout, style recalculation and paint work, all of it happening whether or not you’re looking at that branch.&lt;/p&gt;
&lt;h2&gt;Concept: layout is a pure function of the tree, so compute it once&lt;a href=&quot;#concept-layout-is-a-pure-function-of-the-tree-so-compute-it-once&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The reframe that made everything else fall out: &lt;strong&gt;a tree and a picture of a tree are two different artifacts with two different lifetimes.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The tree changes when someone edits it — a person is added, a person is removed, and so on. Between those events, every coordinate in the picture is constant. Anything constant between rare events doesn’t belong on the read path at all; it belongs computed once and stored.&lt;/p&gt;
&lt;p&gt;So layout moved to the backend and became &lt;em&gt;stored geometry&lt;/em&gt;. When the shape of the tree changes, the layout is recomputed and the results are saved: an &lt;code&gt;(x, y)&lt;/code&gt; per person, and a list of points per edge. Nothing recalculates on a view.&lt;/p&gt;
&lt;p&gt;That changes what the API returns. Instead of a graph for the client to solve, it returns the solution:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; class&lt;/span&gt;&lt;span&gt; GraphResponse&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    private&lt;/span&gt;&lt;span&gt; List&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;Node&lt;/span&gt;&lt;span&gt;&amp;gt; &lt;/span&gt;&lt;span&gt;nodes&lt;/span&gt;&lt;span&gt;;      &lt;/span&gt;&lt;span&gt;// id, data, children, position&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    private&lt;/span&gt;&lt;span&gt; List&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;Edge&lt;/span&gt;&lt;span&gt;&amp;gt; &lt;/span&gt;&lt;span&gt;edges&lt;/span&gt;&lt;span&gt;;      &lt;/span&gt;&lt;span&gt;// sourceId, targetId, points&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    private&lt;/span&gt;&lt;span&gt; Metadata&lt;/span&gt;&lt;span&gt; metadata&lt;/span&gt;&lt;span&gt;;     &lt;/span&gt;&lt;span&gt;// nodeWidth, nodeHeight, actionWidth&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    private&lt;/span&gt;&lt;span&gt; RootNode&lt;/span&gt;&lt;span&gt; root&lt;/span&gt;&lt;span&gt;;         &lt;/span&gt;&lt;span&gt;// where to point the camera initially&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;metadata&lt;/code&gt; block is small but load-bearing. Box dimensions are an input to the layout, so the clients have to agree with the server about them exactly — if the server spaces nodes for a 250-wide box and a client draws 260, every gap is wrong. Sending the dimensions that produced the layout means the clients can’t drift.&lt;/p&gt;
&lt;h2&gt;Concept: linear-time tidy trees&lt;a href=&quot;#concept-linear-time-tidy-trees&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;With layout server-side and computed only on change, it can be a proper algorithm rather than something that has to be cheap enough to run in a browser on every page view. The one that fits a tree is &lt;strong&gt;Buchheim et al.’s improvement on Walker’s algorithm&lt;/strong&gt; — the linear-time refinement of Reingold–Tilford “tidy” layout.&lt;/p&gt;
&lt;p&gt;It gives you the properties you actually want from a genealogy view: parents centered over their children, siblings evenly spaced, no subtree ever overlapping its neighbour, and the whole thing as narrow as those constraints allow. It runs in two walks over the tree.&lt;/p&gt;
&lt;p&gt;The first walk assigns a preliminary x to every node, bottom-up:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; void&lt;/span&gt;&lt;span&gt; firstWalk&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;BuchheimNode&lt;/span&gt;&lt;span&gt; v) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;isLeaf&lt;/span&gt;&lt;span&gt;()) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        BuchheimNode&lt;/span&gt;&lt;span&gt; ls&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;leftSibling&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;prelim&lt;/span&gt;&lt;span&gt; = ls != &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt; ?&lt;/span&gt;&lt;span&gt; ls&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;prelim&lt;/span&gt;&lt;span&gt; + NODE_WIDTH + X_GAP &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; 0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    } &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        BuchheimNode&lt;/span&gt;&lt;span&gt; defaultAncestor&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;leftmost&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;BuchheimNode&lt;/span&gt;&lt;span&gt; w&lt;/span&gt;&lt;span&gt; :&lt;/span&gt;&lt;span&gt; v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;children&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            firstWalk&lt;/span&gt;&lt;span&gt;(w);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            defaultAncestor = &lt;/span&gt;&lt;span&gt;apportion&lt;/span&gt;&lt;span&gt;(w, defaultAncestor);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        executeShifts&lt;/span&gt;&lt;span&gt;(v);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        double&lt;/span&gt;&lt;span&gt; mid&lt;/span&gt;&lt;span&gt; = (&lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;leftmost&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;prelim&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;rightmost&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;prelim&lt;/span&gt;&lt;span&gt;) / &lt;/span&gt;&lt;span&gt;2.0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        BuchheimNode&lt;/span&gt;&lt;span&gt; ls&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;leftSibling&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        if&lt;/span&gt;&lt;span&gt; (ls != &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;prelim&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;ls&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;prelim&lt;/span&gt;&lt;span&gt; + NODE_WIDTH + X_GAP;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;mod&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;prelim&lt;/span&gt;&lt;span&gt; - mid;       &lt;/span&gt;&lt;span&gt;// remember the offset, don&apos;t push children yet&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        } &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;prelim&lt;/span&gt;&lt;span&gt; = mid;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A leaf goes just right of its left sibling. A parent wants to sit centered over its children — but it also has to clear its own left sibling’s subtree, so it may need to move right. The naive fix is to shift the entire subtree beneath it, which is what makes Walker’s original algorithm quadratic: deep trees shift the same nodes over and over.&lt;/p&gt;
&lt;p&gt;Buchheim’s contribution is to &lt;em&gt;defer&lt;/em&gt; those shifts. A node records a pending offset in &lt;code&gt;mod&lt;/code&gt; rather than applying it, &lt;code&gt;apportion&lt;/code&gt; resolves conflicts between adjacent subtrees using threaded contour pointers (&lt;code&gt;nextLeft&lt;/code&gt;/&lt;code&gt;nextRight&lt;/code&gt; walk the outline of a subtree without traversing its interior), and &lt;code&gt;executeShifts&lt;/code&gt; distributes accumulated corrections across siblings in one pass. Then a single top-down walk turns deferred offsets into absolute coordinates:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; void&lt;/span&gt;&lt;span&gt; secondWalk&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;BuchheimNode&lt;/span&gt;&lt;span&gt; v, &lt;/span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt; m, &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; depth, &lt;/span&gt;&lt;span&gt;Map&lt;/span&gt;&lt;span&gt;&amp;lt;String, &lt;/span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt;[]&amp;gt; out) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    out&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;put&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt;[]{ &lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;prelim&lt;/span&gt;&lt;span&gt; + m, MARGIN + depth * (NODE_HEIGHT + Y_GAP) });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;BuchheimNode&lt;/span&gt;&lt;span&gt; child&lt;/span&gt;&lt;span&gt; :&lt;/span&gt;&lt;span&gt; v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;children&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        secondWalk&lt;/span&gt;&lt;span&gt;(child, m + &lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;mod&lt;/span&gt;&lt;span&gt;, depth + &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;, out);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Accumulating &lt;code&gt;m + v.mod&lt;/code&gt; down the recursion is what makes each node’s final x the sum of every deferred shift above it — collected in one traversal instead of applied repeatedly. Every node is touched a constant number of times, so the whole layout is O(n).&lt;/p&gt;
&lt;p&gt;Two properties matter more than the speed, given that this output is stored and shared:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It’s deterministic.&lt;/strong&gt; The same tree produces byte-identical geometry every time. No random seeds, no iteration counts, no convergence thresholds — which is what makes the result safe to persist and to hand to different clients.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Depth maps directly to y.&lt;/strong&gt; &lt;code&gt;depth * (NODE_HEIGHT + Y_GAP)&lt;/code&gt; means generations are rows. That’s not a coincidence to be grateful for; it’s the reason a hierarchical layout is the right family here rather than a force-directed one.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Edges are precomputed too, as orthogonal elbows — four points from the bottom-center of the parent, out to a midpoint row, across, and into the top-center of the child:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt; px&lt;/span&gt;&lt;span&gt; = pp[&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;] + NODE_WIDTH / &lt;/span&gt;&lt;span&gt;2.0&lt;/span&gt;&lt;span&gt;, py = pp[&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;] + NODE_HEIGHT;   &lt;/span&gt;&lt;span&gt;// parent bottom-center&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt; cx&lt;/span&gt;&lt;span&gt; = cp[&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;] + NODE_WIDTH / &lt;/span&gt;&lt;span&gt;2.0&lt;/span&gt;&lt;span&gt;, cy = cp[&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;];                 &lt;/span&gt;&lt;span&gt;// child top-center&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt; my&lt;/span&gt;&lt;span&gt; = (py + cy) / &lt;/span&gt;&lt;span&gt;2.0&lt;/span&gt;&lt;span&gt;;                                      &lt;/span&gt;&lt;span&gt;// shared midpoint row&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;updateEdge&lt;/span&gt;&lt;span&gt;(parentId, childId, &lt;/span&gt;&lt;span&gt;List&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        new&lt;/span&gt;&lt;span&gt; GraphResponse.&lt;/span&gt;&lt;span&gt;Position&lt;/span&gt;&lt;span&gt;(px, py),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        new&lt;/span&gt;&lt;span&gt; GraphResponse.&lt;/span&gt;&lt;span&gt;Position&lt;/span&gt;&lt;span&gt;(px, my),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        new&lt;/span&gt;&lt;span&gt; GraphResponse.&lt;/span&gt;&lt;span&gt;Position&lt;/span&gt;&lt;span&gt;(cx, my),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        new&lt;/span&gt;&lt;span&gt; GraphResponse.&lt;/span&gt;&lt;span&gt;Position&lt;/span&gt;&lt;span&gt;(cx, cy)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the clients never route a line. An edge arrives as a polyline; drawing it is &lt;code&gt;moveTo&lt;/code&gt; then &lt;code&gt;lineTo&lt;/code&gt; per point. Every geometric decision in the picture was made once, upstream.&lt;/p&gt;
&lt;h2&gt;Concept: one canvas, and a camera&lt;a href=&quot;#concept-one-canvas-and-a-camera&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;With geometry arriving precomputed, the client’s job collapses to painting. That makes the per-node-component model the wrong trade: you’re paying for a thousand mounted, styled, individually-tracked elements to display what is really a single picture.&lt;/p&gt;
&lt;p&gt;Both clients now draw the whole tree into one surface — Canvas 2D on the web, a &lt;code&gt;CustomPainter&lt;/code&gt; in Flutter — under a camera transform:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;translate&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scale&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scale&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scale&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s the entire panning and zooming implementation. The viewport (&lt;code&gt;vp&lt;/code&gt;) holds a translation and a zoom, everything is drawn in world coordinates, and the transform maps world to screen. Panning changes two numbers.&lt;/p&gt;
&lt;p&gt;The payoff is that you can now ask a question you couldn’t ask before: &lt;em&gt;what part of the world is on screen?&lt;/em&gt; Invert the transform and you have it as a rectangle:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; viewL&lt;/span&gt;&lt;span&gt; = -&lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; / &lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scale&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; viewT&lt;/span&gt;&lt;span&gt; = -&lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt; / &lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scale&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; viewR&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;viewL&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;cw&lt;/span&gt;&lt;span&gt; / &lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scale&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; viewB&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;viewT&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;ch&lt;/span&gt;&lt;span&gt; / &lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scale&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Culling: draw cost tracks the viewport, not the tree&lt;a href=&quot;#culling-draw-cost-tracks-the-viewport-not-the-tree&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Given that rectangle, every node gets one cheap rejection test before any drawing happens:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;nx&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;metadata&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodeWidth&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;viewL&lt;/span&gt;&lt;span&gt; || &lt;/span&gt;&lt;span&gt;nx&lt;/span&gt;&lt;span&gt; &amp;gt; &lt;/span&gt;&lt;span&gt;viewR&lt;/span&gt;&lt;span&gt; ||&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ny&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;metadata&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodeHeight&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;viewT&lt;/span&gt;&lt;span&gt; || &lt;/span&gt;&lt;span&gt;ny&lt;/span&gt;&lt;span&gt; &amp;gt; &lt;/span&gt;&lt;span&gt;viewB&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four comparisons to skip a node entirely. Zoomed in on one family, the loop still visits every node but &lt;em&gt;draws&lt;/em&gt; maybe twenty — so the expensive part of a frame is proportional to what’s visible, and a tree with 200 people costs the same to render as one with 2000 at the same zoom.&lt;/p&gt;
&lt;p&gt;Edges need one extra consideration, and it’s the kind of thing you only find by looking at the result: an edge can be visible while &lt;em&gt;neither&lt;/em&gt; of its endpoints is. A line from a parent above the screen to a child below it crosses the viewport, and testing endpoints alone would drop it. So edges are culled on their own bounding box, with padding:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; padding&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;500&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;maxX&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;viewL&lt;/span&gt;&lt;span&gt; - &lt;/span&gt;&lt;span&gt;padding&lt;/span&gt;&lt;span&gt; || &lt;/span&gt;&lt;span&gt;minX&lt;/span&gt;&lt;span&gt; &amp;gt; &lt;/span&gt;&lt;span&gt;viewR&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;padding&lt;/span&gt;&lt;span&gt; ||&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    maxY&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;viewT&lt;/span&gt;&lt;span&gt; - &lt;/span&gt;&lt;span&gt;padding&lt;/span&gt;&lt;span&gt; || &lt;/span&gt;&lt;span&gt;minY&lt;/span&gt;&lt;span&gt; &amp;gt; &lt;/span&gt;&lt;span&gt;viewB&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;padding&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Flutter side precomputes each edge’s bounds once instead of recomputing per frame — the same idea, cached: &lt;code&gt;if (!edge.bounds.overlaps(visibleRect)) continue;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;And since a static tree shouldn’t burn frames, painting is behind a dirty flag: an animation-frame loop runs continuously but only repaints when something actually changed — a pan, a zoom, a newly loaded image.&lt;/p&gt;
&lt;h2&gt;Concept: three levels of detail&lt;a href=&quot;#concept-three-levels-of-detail&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Culling handles “zoomed in.” Zoomed out is the opposite problem: now &lt;em&gt;everything&lt;/em&gt; is on screen, and the naive approach draws a thousand rounded cards, a thousand avatar images and a thousand measured, truncated text strings — to produce a picture in which each node is eight pixels wide and none of the text is legible.&lt;/p&gt;
&lt;p&gt;The observation is that &lt;strong&gt;at low zoom, the most expensive things to draw are the ones carrying the least information&lt;/strong&gt;. Nobody reads names at 6% zoom; they’re reading the &lt;em&gt;shape&lt;/em&gt; of the tree — where the branches are, how deep it goes, which subtree is dense. So detail is tied to zoom, in three tiers:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; lod&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;isExporting&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  ? &lt;/span&gt;&lt;span&gt;&quot;full&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  : &lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scale&lt;/span&gt;&lt;span&gt; &amp;gt; &lt;/span&gt;&lt;span&gt;0.3&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ? &lt;/span&gt;&lt;span&gt;&quot;full&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    : &lt;/span&gt;&lt;span&gt;vp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;scale&lt;/span&gt;&lt;span&gt; &amp;gt; &lt;/span&gt;&lt;span&gt;0.12&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ? &lt;/span&gt;&lt;span&gt;&quot;simple&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      : &lt;/span&gt;&lt;span&gt;&quot;minimal&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;full&lt;/code&gt;&lt;/strong&gt; (zoom above 30%) — rounded card, avatar clipped into a circle, name with ellipsis truncation measured against the box width, action affordance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;simple&lt;/code&gt;&lt;/strong&gt; (above 12%) — card and border, one centered name string. No images, no per-node measurement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;minimal&lt;/code&gt;&lt;/strong&gt; — a filled rectangle, and thinner edge strokes. Nothing else.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;lod&lt;/span&gt;&lt;span&gt; === &lt;/span&gt;&lt;span&gt;&quot;minimal&quot;&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  ctx&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;fillRect&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;nx&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ny&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;metadata&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodeWidth&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;metadata&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nodeHeight&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  return&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That early return is doing a lot of work. It skips a rounded-rect path, a stroke, an image draw, a font set, a text measurement and a fill — per node, for every node on screen, in exactly the regime where the most nodes are on screen. The whole-tree view is the cheapest view to draw rather than the most expensive one.&lt;/p&gt;
&lt;p&gt;Text measurement is the sneaky cost, incidentally. &lt;code&gt;measureText&lt;/code&gt; in the truncation loop shortens a string one character at a time until it fits — fine for twenty visible cards, not fine for a thousand. Tiering it away at &lt;code&gt;simple&lt;/code&gt; matters more than the images do.&lt;/p&gt;
&lt;p&gt;Two supporting details in the same spirit: images are cached by URL with a load callback that flips the dirty flag, so a scroll past 50 faces doesn’t re-request anything and a late-arriving image triggers exactly one repaint. And past 20 nodes, a minimap draws the whole tree as dots with a rectangle marking the current viewport — cheap to draw from positions you already have, and it’s what makes navigating a tree far larger than the screen feel bounded.&lt;/p&gt;
&lt;p&gt;Both clients use the same thresholds and the same culling maths. Web and mobile are different rendering APIs, but the geometry they consume and the decisions they make about it are identical, which means the tree &lt;em&gt;looks&lt;/em&gt; the same on a phone and a laptop.&lt;/p&gt;
&lt;h2&gt;What you give up, and getting it back&lt;a href=&quot;#what-you-give-up-and-getting-it-back&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Drawing into a canvas means there are no per-node objects. Nothing to attach a click handler to, nothing to query, nothing the accessibility tree or the browser’s own hit-testing knows about. That’s the real cost of this approach, and it has to be paid back deliberately.&lt;/p&gt;
&lt;p&gt;Since positions are stored server-side, both interactions resolve through coordinates. A click is converted from screen space back into world space with the same transform, inverted:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; worldX&lt;/span&gt;&lt;span&gt; = (&lt;/span&gt;&lt;span&gt;mouseX&lt;/span&gt;&lt;span&gt; - &lt;/span&gt;&lt;span&gt;vpx&lt;/span&gt;&lt;span&gt;) / &lt;/span&gt;&lt;span&gt;scale&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; worldY&lt;/span&gt;&lt;span&gt; = (&lt;/span&gt;&lt;span&gt;mouseY&lt;/span&gt;&lt;span&gt; - &lt;/span&gt;&lt;span&gt;vpy&lt;/span&gt;&lt;span&gt;) / &lt;/span&gt;&lt;span&gt;scale&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and the person occupying that point is looked up by coordinate. The x-offset within the box even distinguishes a tap on the card from a tap on the action strip beside it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;GetPersonCoordinateResponse&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;CoordinateType&lt;/span&gt;&lt;span&gt; type&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;GetPersonCoordinateResponse&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;CoordinateType&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;NODE&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (x &amp;gt; (&lt;/span&gt;&lt;span&gt;position&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getX&lt;/span&gt;&lt;span&gt;() + NODE_WIDTH)) type = &lt;/span&gt;&lt;span&gt;GetPersonCoordinateResponse&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;CoordinateType&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ACTION&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Search works the same way in reverse: a name resolves to a position, and the camera animates there. The client never scans the tree for a match — it asks where to point, then points.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Design note.&lt;/strong&gt; This trades a round trip per interaction for not maintaining a spatial index in each client. Given that both clients need identical behaviour and the positions are already stored, resolving centrally keeps one implementation instead of three. A client-side quadtree or uniform grid is the alternative if local, zero-latency hit-testing becomes the priority — the positions are all there in the payload to build one from.&lt;/p&gt;
&lt;h2&gt;Design notes for the next pass&lt;a href=&quot;#design-notes-for-the-next-pass&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Culling is a linear scan.&lt;/strong&gt; Every frame iterates all nodes to test four inequalities each. That’s cheap next to drawing, and it kept the code simple, but it’s still O(n) per frame — the part that would matter first on a tree an order of magnitude larger. Bucketing nodes into a coarse grid by position, then visiting only the buckets the viewport touches, turns the scan into a lookup. Worth doing when profiles say the scan, rather than the drawing, is the frame budget.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The LOD thresholds are tuned to the current box size.&lt;/strong&gt; &lt;code&gt;0.3&lt;/code&gt; and &lt;code&gt;0.12&lt;/code&gt; were chosen by zooming until text stopped being readable, against a 250×80 box. A more principled version derives them from the &lt;em&gt;rendered&lt;/em&gt; size — switch away from text when cap height falls below ~10 device pixels — which would then hold automatically if node dimensions ever change.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The edge padding is a heuristic.&lt;/strong&gt; 500 world units comfortably covers the longest elbow at the spacing in use. Deriving it from the maximum edge extent would make it self-adjusting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Export takes the other path deliberately.&lt;/strong&gt; Saving a PNG or PDF renders off-screen at &lt;code&gt;full&lt;/code&gt; detail regardless of zoom, then scales down to stay inside browser canvas size limits — the one case where you want every name drawn, because the output is meant to be zoomed into later.&lt;/p&gt;
&lt;h2&gt;What I took away&lt;a href=&quot;#what-i-took-away&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The idea I keep reusing from this is the split I started with: &lt;strong&gt;the tree and the picture of the tree are different artifacts with different lifetimes.&lt;/strong&gt; Once that was explicit, most of the design followed. Geometry that only changes on edit gets computed once and stored. Geometry the client already has doesn’t need recomputing. Detail nobody can see doesn’t need drawing.&lt;/p&gt;
&lt;p&gt;The satisfying part is how the scaling behaviour inverted. Before, every additional person made every view of the tree more expensive for everyone. Now the layout cost is paid once per edit, and the drawing cost is bounded by the size of the screen — which is the one quantity in this whole problem that never grows.&lt;/p&gt;</content:encoded><category>Algorithms</category><category>Algorithms</category><category>Performance</category><category>System Design</category><category>Frontend</category></item><item><title>UDP Hole Punching: How Two Machines Behind NAT Talk Directly</title><link>https://rustamniraula.com.np/blog/gop2p-udp-hole-punching-file-transfer/</link><guid isPermaLink="true">https://rustamniraula.com.np/blog/gop2p-udp-hole-punching-file-transfer/</guid><description>Working through NAT traversal and bulk transfer over an unreliable datagram protocol — rendezvous, simultaneous open, keepalives, chunking, integrity and flow control — by building a peer-to-peer file sharing system in Go.</description><pubDate>Mon, 22 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Two laptops on opposite sides of the world, each behind a home router. Neither has a public IP. Neither owner will configure port forwarding. They want to send each other a file &lt;em&gt;directly&lt;/em&gt;, without a server in the middle paying for the bandwidth.&lt;/p&gt;
&lt;p&gt;My instinct said that was impossible. Every packet aimed at either machine arrives at a router with no idea which internal device it belongs to, and gets dropped. Yet this is exactly how Zoom calls, BitTorrent swarms and multiplayer game sessions work. The technique is &lt;strong&gt;UDP hole punching&lt;/strong&gt;, and it exploits a specific property of how NAT devices decide what to let in.&lt;/p&gt;
&lt;p&gt;I wanted to understand two things properly: how a direct path gets opened between two unaddressable hosts, and what you have to rebuild yourself once you’re moving bulk data over a protocol that guarantees nothing. So I built a peer-to-peer chat and file sharing system in Go and worked through both: &lt;a href=&quot;https://github.com/rustamniraula90/gop2p&quot;&gt;github.com/rustamniraula90/gop2p&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;These are my notes from that build.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Concept 1: NAT is a phone system with no direct extensions&lt;a href=&quot;#concept-1-nat-is-a-phone-system-with-no-direct-extensions&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A router doing Network Address Translation has one public address and many internal devices. When an internal device sends a packet out, the router picks an unused public port, rewrites the source address, and records the pairing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;internal 192.168.1.14:3001  ⇄  public 203.0.113.7:51820&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Return traffic hitting &lt;code&gt;203.0.113.7:51820&lt;/code&gt; gets rewritten back and delivered. Traffic arriving at any &lt;em&gt;other&lt;/em&gt; port has no entry to match and is discarded.&lt;/p&gt;
&lt;p&gt;Three properties follow, and they turn out to be the whole basis of hole punching:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Mappings are created by outbound packets.&lt;/strong&gt; Nothing else creates them. You cannot ask for one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mappings are usually destination-filtered.&lt;/strong&gt; Most consumer routers only accept return packets from the address the outbound packet went to. (This is “port-restricted cone” NAT — it matters later.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mappings expire.&lt;/strong&gt; UDP has no connection state, so the router guesses. Idle entries can be evicted in as little as 30 seconds.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So neither peer can be &lt;em&gt;called&lt;/em&gt;. But both can &lt;em&gt;dial&lt;/em&gt;, and a mapping created by dialing will accept the reply. If both dial each other at the same moment, each one’s outbound packet opens the hole the other’s packet needs. That simultaneity is the trick, and arranging it is the entire problem.&lt;/p&gt;
&lt;h2&gt;Concept 2: A rendezvous server that learns your address for you&lt;a href=&quot;#concept-2-a-rendezvous-server-that-learns-your-address-for-you&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Peer A cannot know its own public address — the NAT that assigned it sits outside A’s view. But any server A talks to &lt;em&gt;can&lt;/em&gt; see it, because translation already happened by the time the packet arrives. So the first job of a coordination server is to be a mirror.&lt;/p&gt;
&lt;p&gt;This is why I have the server read the address off the socket rather than trust anything the client claims:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;remote&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;err&lt;/span&gt;&lt;span&gt; := &lt;/span&gt;&lt;span&gt;conn&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ReadFromUDP&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;// ...&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;go&lt;/span&gt;&lt;span&gt; handler&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;HandlePacket&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;remote&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;buf&lt;/span&gt;&lt;span&gt;[:&lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;])&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;func&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;r &lt;/span&gt;&lt;span&gt;Registry&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;Register&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt; string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt; string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;addr&lt;/span&gt;&lt;span&gt; *&lt;/span&gt;&lt;span&gt;net&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;UDPAddr&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;clients&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;] = &amp;amp;&lt;/span&gt;&lt;span&gt;ClientInfo&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ID&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;Name&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        IP&lt;/span&gt;&lt;span&gt;:   &lt;/span&gt;&lt;span&gt;addr&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;IP&lt;/span&gt;&lt;span&gt;,     &lt;/span&gt;&lt;span&gt;// the post-NAT address, observed&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        Port&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;addr&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Port&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        LastSeen&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;time&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Now&lt;/span&gt;&lt;span&gt;(),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once I’d written that, STUN made sense to me for the first time: it’s built on the same principle — &lt;em&gt;the observer tells you who you appear to be&lt;/em&gt;. The server here isn’t a relay for data, just a directory of “where each peer appears to live,” assembled from packets it received.&lt;/p&gt;
&lt;h3&gt;Keepalives exist to fight timeouts, not to detect death&lt;a href=&quot;#keepalives-exist-to-fight-timeouts-not-to-detect-death&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Because mappings expire on idle, a peer that goes quiet becomes unreachable even though it’s still running. So the client re-announces itself on a timer:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;ticker&lt;/span&gt;&lt;span&gt; := &lt;/span&gt;&lt;span&gt;time&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;NewTicker&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;time&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Second&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; range&lt;/span&gt;&lt;span&gt; ticker&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;C&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    pm&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sendHeartbeat&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ten seconds against a 30-second eviction window. What I found interesting is the inversion from what “heartbeat” normally means: this isn’t the client proving liveness to the server, it’s the client keeping its own NAT hole propped open. That’s why the server handles register and heartbeat with the same function — a heartbeat is a re-register that skips the acknowledgement.&lt;/p&gt;
&lt;h2&gt;Concept 3: Address exchange as a consent step&lt;a href=&quot;#concept-3-address-exchange-as-a-consent-step&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Building the directory made a privacy consequence obvious that I hadn’t considered up front: listing peers means publishing where people live. So I split discovery from address exchange. Browsing the directory returns identities with addresses deliberately withheld:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;peers&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;append&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;peers&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;protocol&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;PeerInfoPayload&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ID&lt;/span&gt;&lt;span&gt;:   &lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ID&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    Name&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Name&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    IP&lt;/span&gt;&lt;span&gt;:   &lt;/span&gt;&lt;span&gt;nil&lt;/span&gt;&lt;span&gt;,   &lt;/span&gt;&lt;span&gt;// withheld until both sides consent&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    Port&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;})&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Addresses only go out after both parties agree to connect, which makes the handshake a four-message consent flow through the server:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;A                          Server                          B&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|-- CONNECT_REQUEST(B) -------&amp;gt;|                             |&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|                             |-- CONNECT_FORWARD(A) -------&amp;gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|                             |&amp;lt;------- CONNECT_ACCEPT(A) ---|&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|&amp;lt;-- PEER_INFO(B: ip:port) ----|--- PEER_INFO(A: ip:port) --&amp;gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|                                                            |&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|============= PUNCH / PUNCH_ACK (direct) ==================&amp;gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;|&amp;lt;========= the server is now out of the picture ============|&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The last server step is the load-bearing one: &lt;strong&gt;both &lt;code&gt;PEER_INFO&lt;/code&gt; messages go out from the same handler&lt;/strong&gt;, microseconds apart. That’s not tidiness, it’s the mechanism. Hole punching needs both sides transmitting at nearly the same instant, and the only shared clock they have is “when the server told me.”&lt;/p&gt;
&lt;h2&gt;Concept 4: Simultaneous open, and why the first packets are supposed to fail&lt;a href=&quot;#concept-4-simultaneous-open-and-why-the-first-packets-are-supposed-to-fail&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Once each side knows where to aim, both fire. The first packets die, and that’s the design working as intended:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A’s punch reaches B’s router. B hasn’t sent anything to A yet, so there’s no mapping for A. Dropped.&lt;/li&gt;
&lt;li&gt;But A’s punch &lt;em&gt;did&lt;/em&gt; create a mapping on A’s side, valid for B’s address.&lt;/li&gt;
&lt;li&gt;B’s punch, moments later, reaches A’s router and finds that hole waiting. It gets through.&lt;/li&gt;
&lt;li&gt;A replies. B’s router now has its own mapping from B’s punch, so the reply lands too.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A dropped packet isn’t a failure — it’s the cost of opening your own side. Which means the strategy is to retry on an interval until confirmation arrives:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;func&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;pm &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt;P2PManager&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;startPunching&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;peer&lt;/span&gt;&lt;span&gt; *&lt;/span&gt;&lt;span&gt;Peer&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    addr&lt;/span&gt;&lt;span&gt; := &amp;amp;&lt;/span&gt;&lt;span&gt;net&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;UDPAddr&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;IP&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;peer&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;IP&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;Port&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;peer&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Port&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    // send packet and pray&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; i&lt;/span&gt;&lt;span&gt; := &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;++ {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        if&lt;/span&gt;&lt;span&gt; pm&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Peers&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;peer&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ID&lt;/span&gt;&lt;span&gt;].&lt;/span&gt;&lt;span&gt;State&lt;/span&gt;&lt;span&gt; == &lt;/span&gt;&lt;span&gt;StateConnected&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            return&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        pm&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;udp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Send&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;addr&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;protocol&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;UDPMessage&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            Type&lt;/span&gt;&lt;span&gt;:    &lt;/span&gt;&lt;span&gt;protocol&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;TypePunch&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            Payload&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;map&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;&quot;from&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;pm&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;identity&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ID&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        })&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        time&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Sleep&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;500&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;time&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Millisecond&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ten attempts over five seconds converts a race into a near-certainty. The &lt;code&gt;// send packet and pray&lt;/code&gt; comment is the one I wrote while figuring this out, and I kept it because it describes the mechanism accurately: you can’t verify the hole is open except by something coming back through it.&lt;/p&gt;
&lt;p&gt;And when something does come back, I take the address from the packet rather than the directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;peer&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;State&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;StateConnected&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;peer&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;IP&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;remote&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;IP&lt;/span&gt;&lt;span&gt;     // trust the socket, not the directory&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;peer&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Port&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;remote&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Port&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the NAT reassigned a port between registration and the punch, the server’s record is stale and the source address on the wire is ground truth. Same principle as the server observing &lt;code&gt;remote&lt;/code&gt; in the first place, one layer down.&lt;/p&gt;
&lt;h3&gt;Where hole punching stops working&lt;a href=&quot;#where-hole-punching-stops-working&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Property 2 said &lt;em&gt;most&lt;/em&gt; routers filter by destination address. &lt;strong&gt;Symmetric NAT&lt;/strong&gt; goes further: it allocates a different public port for every distinct destination. The port the server observed is therefore not the port your peer’s traffic will use, so your peer aims at a hole that will never exist. Retrying can’t fix that.&lt;/p&gt;
&lt;p&gt;This is where production systems pair STUN with &lt;strong&gt;TURN&lt;/strong&gt; — a relay that carries traffic when punching fails. Falling back costs you the bandwidth savings that are the whole point of P2P, so it’s a last resort rather than a default. My implementation punches, and if punching fails the peer stays disconnected; understanding &lt;em&gt;why&lt;/em&gt; it fails was the goal, and a relay would have hidden that.&lt;/p&gt;
&lt;h2&gt;Concept 5: UDP gives you a datagram, and nothing else&lt;a href=&quot;#concept-5-udp-gives-you-a-datagram-and-nothing-else&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Suppose the hole is open. I now had a channel that can lose packets, reorder them, duplicate them and corrupt them, with no notification of any of it. Moving a file across that meant rebuilding, by hand, the guarantees TCP would have provided:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Guarantee&lt;/th&gt;&lt;th&gt;Who provides it&lt;/th&gt;&lt;th&gt;Mechanism&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Message boundaries&lt;/td&gt;&lt;td&gt;you&lt;/td&gt;&lt;td&gt;framing, or one message per datagram&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Integrity&lt;/td&gt;&lt;td&gt;you&lt;/td&gt;&lt;td&gt;checksum/hash per unit&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Ordering&lt;/td&gt;&lt;td&gt;you&lt;/td&gt;&lt;td&gt;explicit sequence numbers&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Completeness&lt;/td&gt;&lt;td&gt;you&lt;/td&gt;&lt;td&gt;track what’s missing, re-request&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Flow control&lt;/td&gt;&lt;td&gt;you&lt;/td&gt;&lt;td&gt;limit outstanding requests&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Writing that table out is what turned “UDP is unreliable” from a slogan into a checklist. The transfer became a request/response protocol over indexed pieces:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;Downloader                                   Uploader&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    |--- DOWNLOAD_REQUEST(file, reqID) ---------&amp;gt;|   compress + hash&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    |&amp;lt;-- DOWNLOAD_INFO(chunkCount, sizes) -------|&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    |--- CHUNK_REQUEST(reqID, 0) ---------------&amp;gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    |&amp;lt;-- CHUNK_RESPONSE(0, data, sha256) --------|&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    |            ... repeat ...                  |&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Chunking: sequence numbers are what make loss recoverable&lt;a href=&quot;#chunking-sequence-numbers-are-what-make-loss-recoverable&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Splitting the payload into fixed-size indexed chunks buys you everything else. An index means you can detect a gap, re-request one piece without restarting, and reassemble out-of-order arrivals. Negotiating up front tells the receiver how many pieces exist, so it can allocate its whole table before the first byte arrives:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;chunkCount&lt;/span&gt;&lt;span&gt; := &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;upload&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;CompressedSize&lt;/span&gt;&lt;span&gt;) / &lt;/span&gt;&lt;span&gt;upload&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ChunkSize&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; int&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;upload&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;CompressedSize&lt;/span&gt;&lt;span&gt;)%&lt;/span&gt;&lt;span&gt;upload&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ChunkSize&lt;/span&gt;&lt;span&gt; != &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    chunkCount&lt;/span&gt;&lt;span&gt;++&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I compress before chunking rather than per chunk, for two reasons: the compressor sees the whole file’s redundancy instead of a 16 KB window, and the piece count is known in advance.&lt;/p&gt;
&lt;h3&gt;Integrity: verify each piece before trusting it&lt;a href=&quot;#integrity-verify-each-piece-before-trusting-it&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Corruption is silent, so each chunk carries a hash of its own contents, computed by the sender during preparation, and the receiver refuses to store anything that doesn’t match:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;hash&lt;/span&gt;&lt;span&gt; := &lt;/span&gt;&lt;span&gt;sha256&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Sum256&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;computedSHA&lt;/span&gt;&lt;span&gt; := &lt;/span&gt;&lt;span&gt;hex&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;EncodeToString&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;hash&lt;/span&gt;&lt;span&gt;[:])&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; computedSHA&lt;/span&gt;&lt;span&gt; != &lt;/span&gt;&lt;span&gt;sha&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    download&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Chunks&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;index&lt;/span&gt;&lt;span&gt;].&lt;/span&gt;&lt;span&gt;Status&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;ChunkError&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    download&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Chunks&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;index&lt;/span&gt;&lt;span&gt;].&lt;/span&gt;&lt;span&gt;RetryCount&lt;/span&gt;&lt;span&gt;++&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; fmt&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Errorf&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;chunk SHA mismatch&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verified chunks land as individual files named by index, which is what makes ordering a non-problem: &lt;code&gt;chunk_37&lt;/code&gt; is correct whenever it arrives, and order only matters at assembly, where pieces are concatenated by index and decompressed.&lt;/p&gt;
&lt;p&gt;There’s a second integrity check hiding in that design, and I only noticed it afterwards. Gzip has its own internal structure, so if the pieces were assembled in the wrong order, decompression &lt;em&gt;fails&lt;/em&gt; rather than silently producing a corrupt file. Layering a format that detects its own corruption on top of per-chunk hashes gave me end-to-end validation I hadn’t written.&lt;/p&gt;
&lt;h3&gt;Flow control: a window, because both extremes are wrong&lt;a href=&quot;#flow-control-a-window-because-both-extremes-are-wrong&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;One request at a time wastes a full round-trip per chunk — on a 100 ms link that’s 160 KB/s regardless of how fat the pipe is. All requests at once overruns buffers and drops most of them. So I keep a bounded number in flight:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;windowSize&lt;/span&gt;&lt;span&gt; := &lt;/span&gt;&lt;span&gt;8&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; i&lt;/span&gt;&lt;span&gt; := &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;windowSize&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;++ {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    pm&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;requestNextChunk&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;requestID&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    time&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Sleep&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;time&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Millisecond&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and refill on each arrival, which holds the window full for the rest of the transfer:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; err&lt;/span&gt;&lt;span&gt; := &lt;/span&gt;&lt;span&gt;pm&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Downloader&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;SaveChunk&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;resp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;RequestID&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;resp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ChunkIndex&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    resp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ChunkData&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;resp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ChunkSHA&lt;/span&gt;&lt;span&gt;); &lt;/span&gt;&lt;span&gt;err&lt;/span&gt;&lt;span&gt; != &lt;/span&gt;&lt;span&gt;nil&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;pm&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;requestNextChunk&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;resp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;RequestID&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a &lt;strong&gt;receiver-driven&lt;/strong&gt; window, the inverse of TCP’s sender-driven one, and I like it for peer-to-peer: the side that knows its own downlink capacity sets the rate, and a peer serving five downloads doesn’t have to reason about any of them.&lt;/p&gt;
&lt;h2&gt;Design notes for the next iteration&lt;a href=&quot;#design-notes-for-the-next-iteration&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The table listed five guarantees. This version implements framing, integrity, ordering and flow control; the notes below are where I stopped, and why each one clarified something.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retransmission is out of scope in this pass.&lt;/strong&gt; A chunk marked &lt;code&gt;ChunkRequesting&lt;/code&gt; stays there — nothing re-arms it, so a lost datagram narrows the window by one. I built the state machine a timer would drive (&lt;code&gt;RetryCount&lt;/code&gt; is in the struct, ready for it) before building the timer, and doing it in that order is what made the role of timers concrete: reliability over UDP is fundamentally about &lt;em&gt;deciding how long you’re willing to wait&lt;/em&gt;, because you can’t otherwise distinguish “lost” from “slow.” Adding a per-chunk deadline that returns &lt;code&gt;ChunkRequesting&lt;/code&gt; to &lt;code&gt;ChunkPending&lt;/code&gt; is the natural next step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Frame size is a fragmentation trade-off.&lt;/strong&gt; &lt;code&gt;ChunkData []byte&lt;/code&gt; inside a JSON message is base64-encoded, so a 16 KB chunk is ~22 KB on the wire — past a 1500-byte MTU, so each chunk is IP-fragmented into ~15 packets, and losing any one fragment discards the chunk. I chose 16 KB for fewer round-trips; measured against fragmentation, a binary frame under ~1400 bytes trades round-trips for a smaller blast radius per loss, and pairs better with retransmission. Worth measuring both.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The relay’s registry is single-process simple.&lt;/strong&gt; Each packet is handled in its own goroutine and they share one &lt;code&gt;map[string]*ClientInfo&lt;/code&gt;. That’s fine for the two-client scenario I was testing; a &lt;code&gt;sync.RWMutex&lt;/code&gt; (or moving registration onto a single owning goroutine) is what makes it safe under real concurrency. &lt;code&gt;LastSeen&lt;/code&gt; is recorded and not yet consumed — it’s the hook for evicting stale peers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Request and response share a type tag.&lt;/strong&gt; &lt;code&gt;TypeListPeersRequest&lt;/code&gt; and &lt;code&gt;TypeListPeersResponse&lt;/code&gt; are both &lt;code&gt;0x04&lt;/code&gt;, disambiguated by direction: the server never receives a response and the client never receives a request. It’s compact and works for a two-role topology. Distinct tags become worthwhile as soon as a node can play both roles.&lt;/p&gt;
&lt;h2&gt;What I took away&lt;a href=&quot;#what-i-took-away&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The thing I actually wanted — two clients behind separate NATs, no port forwarding, no relay carrying their data, finding each other through a coordinator that only ever tells them where to aim — works, and files arrive byte-for-byte intact.&lt;/p&gt;
&lt;p&gt;What I didn’t expect was how much I’d learn about TCP by not having it. Sequence numbers, acknowledgements, retransmission timers, MSS negotiation: I’d always filed those as protocol trivia. Building a transfer that needs each one, and feeling exactly which problem each solves, made TCP look less like a spec to memorise and more like a list of hard-won answers.&lt;/p&gt;</content:encoded><category>Systems &amp; Infrastructure</category><category>Go</category><category>Networking</category><category>P2P</category><category>System Design</category></item><item><title>Backpropagation From First Principles: What a Neural Network Actually Does</title><link>https://rustamniraula.com.np/blog/neural-network-from-scratch-java-mnist/</link><guid isPermaLink="true">https://rustamniraula.com.np/blog/neural-network-from-scratch-java-mnist/</guid><description>Working out the forward pass, softmax with cross-entropy, the chain rule and mini-batch gradient descent by building a multi-layer perceptron in plain Java — no ML libraries — and learning why data layout decides what the algorithm can express.</description><pubDate>Thu, 25 Jul 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code&gt;model.fit(x, y)&lt;/code&gt; is where my understanding of neural networks used to stop, because that one call hides everything I wanted to know. What is being adjusted? By how much? How does a number computed at the &lt;em&gt;output&lt;/em&gt; end up changing a weight five layers back?&lt;/p&gt;
&lt;p&gt;It turns out the concepts are simpler than the framework APIs suggest: a network is a pile of multiply-add, its error is a single number, and training is repeatedly asking “which direction should each weight move to make that number smaller?” All of it fits in your head at once.&lt;/p&gt;
&lt;p&gt;To force myself to actually hold it there, I built a multi-layer perceptron in plain Java that learns to recognise handwritten digits — no TensorFlow, no DL4J, not even a matrix library. The only dependency is a logging framework: &lt;a href=&quot;https://github.com/rustamniraula90/hand-written-number-recognition&quot;&gt;github.com/rustamniraula90/hand-written-number-recognition&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;These are my notes from working through it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Concept 0: A network is a function you can shape&lt;a href=&quot;#concept-0-a-network-is-a-function-you-can-shape&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Before any learning happens, a network is just a parameterised function. Mine turns 784 numbers (a 28×28 image, flattened) into 10 numbers (confidence per digit) through two intermediate layers:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;784  ──[W₁ 784×128]──▶  128  ──[W₂ 128×64]──▶  64  ──[W₃ 64×10]──▶  10&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      +bias, ReLU              +bias, ReLU           +bias, softmax&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those weight matrices &lt;em&gt;are&lt;/em&gt; the model — 784×128 + 128×64 + 64×10 ≈ 109,000 numbers. Training means finding values for them. Everything else is bookkeeping.&lt;/p&gt;
&lt;p&gt;I declared the shape rather than hardcoding it, mostly to keep that idea visible while I worked:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;MLPNetworkOptimized&lt;/span&gt;&lt;span&gt; network&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;MLPNetworkOptimized&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span&gt;addInputLayer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;28&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;28&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span&gt;addOutputLayer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span&gt;addHiddenLayer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;128&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span&gt;addHiddenLayer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;64&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span&gt;setLearningRate&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;0.001&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span&gt;setBatchSize&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span&gt;setEpoch&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;20&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        .&lt;/span&gt;&lt;span&gt;build&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Why initialization isn’t arbitrary&lt;a href=&quot;#why-initialization-isnt-arbitrary&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Start every weight at zero and every neuron in a layer computes the same thing forever — identical gradients, identical updates, no differentiation. Start them too large and activations compound layer over layer until they saturate. So weights start random, but &lt;em&gt;scaled to layer width&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; static&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt; xavier&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; in, &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; out) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; random&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;nextGaussian&lt;/span&gt;&lt;span&gt;() * &lt;/span&gt;&lt;span&gt;Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sqrt&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;2.0&lt;/span&gt;&lt;span&gt; / (in + out));&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s Xavier initialization: variance inversely proportional to fan-in plus fan-out, so signal magnitude stays roughly constant as it propagates.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Design note.&lt;/strong&gt; I pass the network’s overall input and output sizes for every layer rather than each layer’s own fan-in and fan-out, so all layers share one variance. It trains fine, which taught me something useful about the shape of this parameter: initialization is a &lt;em&gt;conditioning&lt;/em&gt; concern and fairly forgiving of imprecision, unlike a gradient sign error, which is fatal immediately. Per-layer fan-in/fan-out is the textbook form and the easy refinement.&lt;/p&gt;
&lt;h2&gt;Concept 1: The forward pass is multiply, add, squash&lt;a href=&quot;#concept-1-the-forward-pass-is-multiply-add-squash&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Each layer computes &lt;code&gt;output = activation(W·input + b)&lt;/code&gt;. Written out per neuron, the whole idea is eight lines:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt; z&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0.0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; j&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; j &amp;lt; &lt;/span&gt;&lt;span&gt;neuron&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;weights&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;; j++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    z += &lt;/span&gt;&lt;span&gt;neuron&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;weights&lt;/span&gt;&lt;span&gt;[j] * activations[j];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;z += &lt;/span&gt;&lt;span&gt;neuron&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;bias&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;neuron&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;output&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;relu&lt;/span&gt;&lt;span&gt;(z);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;activation function&lt;/strong&gt; is the only nonlinear step, and without it the network collapses. Stack two linear layers and you get another linear layer — depth would buy nothing. ReLU (&lt;code&gt;max(0, x)&lt;/code&gt;) is the usual choice: trivially differentiable, and it doesn’t saturate for positive inputs.&lt;/p&gt;
&lt;p&gt;I used the bounded variant, ReLU6:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; static&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt; relu&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt; x) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;clamp&lt;/span&gt;&lt;span&gt;(x, &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;6&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; static&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt; reluDerivative&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt; x) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; x &amp;gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt; ?&lt;/span&gt;&lt;span&gt; 1&lt;/span&gt;&lt;span&gt; :&lt;/span&gt;&lt;span&gt; 0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Design note.&lt;/strong&gt; The ceiling of 6 is the trick MobileNet uses to keep activations bounded, and I liked it for stability. The paired derivative here is plain ReLU’s, which returns 1 above the clamp where ReLU6’s true derivative is 0. In this network it makes no observable difference — the small learning rate and scaled init keep pre-activations well below 6 — but writing the two functions next to each other is what made the general rule stick for me: &lt;strong&gt;an activation and its derivative are a matched pair, and changing one means revisiting the other.&lt;/strong&gt; Clamping the derivative at 6 as well is a one-line change and the right call if the ceiling ever binds.&lt;/p&gt;
&lt;h3&gt;The output layer answers a different question&lt;a href=&quot;#the-output-layer-answers-a-different-question&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Hidden layers produce features. The output layer has to produce a &lt;em&gt;probability distribution over 10 classes&lt;/em&gt; — ten numbers, non-negative, summing to 1. That’s &lt;strong&gt;softmax&lt;/strong&gt;: exponentiate, then normalise.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; static&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt;[] &lt;/span&gt;&lt;span&gt;softmax&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt;[] x) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    double&lt;/span&gt;&lt;span&gt; sum&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    double&lt;/span&gt;&lt;span&gt;[] &lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; i&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; i &amp;lt; &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;; i++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        result[i] = &lt;/span&gt;&lt;span&gt;Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;exp&lt;/span&gt;&lt;span&gt;(x[i]);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        sum += result[i];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; i&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; i &amp;lt; &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;; i++) result[i] /= sum;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; result;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Exponentiating first is what makes it a &lt;em&gt;soft&lt;/em&gt; max: it amplifies differences, so a logit slightly ahead becomes a lot more probable, without the hard winner-take-all of picking the maximum.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Design note.&lt;/strong&gt; Library implementations subtract the maximum logit before exponentiating, because &lt;code&gt;Math.exp&lt;/code&gt; overflows around 710. Mine skips that, which is fine for MNIST-scale logits and is the first thing I’d add if inputs got wilder. Finding out &lt;em&gt;why&lt;/em&gt; that subtraction exists — by not having it — was more instructive than reading about it.&lt;/p&gt;
&lt;h2&gt;Concept 2: Error has to be one number&lt;a href=&quot;#concept-2-error-has-to-be-one-number&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;You can’t optimise ten outputs at once. Gradient descent needs a single scalar to reduce, so the ten probabilities and the correct answer collapse into one &lt;strong&gt;loss&lt;/strong&gt;. For classification that’s &lt;strong&gt;cross-entropy&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; static&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt; crossEntropy&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt;[][] actual, &lt;/span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt;[][] expected) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    double&lt;/span&gt;&lt;span&gt; loss&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; i&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; i &amp;lt; &lt;/span&gt;&lt;span&gt;actual&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;; i++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; j&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; j &amp;lt; actual[i].&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;; j++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            loss += expected[i][j] * &lt;/span&gt;&lt;span&gt;Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;log&lt;/span&gt;&lt;span&gt;(actual[i][j]);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; -loss;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With one-hot targets every term but the correct class multiplies by zero, so the loss is just &lt;code&gt;-log(probability assigned to the right answer)&lt;/code&gt;. Assign the truth 0.9 and you’re barely penalised; assign it 0.01 and the log punishes you enormously. That asymmetry is the point: confident wrong answers should hurt far more than uncertain ones.&lt;/p&gt;
&lt;h2&gt;Concept 3: Backpropagation is the chain rule, applied backwards&lt;a href=&quot;#concept-3-backpropagation-is-the-chain-rule-applied-backwards&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;This is the part frameworks hide, and the part I built this for. We have one number (the loss) and 109,000 weights, and we need &lt;code&gt;∂loss/∂w&lt;/code&gt; for each. Computing them independently would mean 109,000 passes through the network. Backpropagation gets all of them in &lt;strong&gt;one&lt;/strong&gt; backwards pass by reusing work.&lt;/p&gt;
&lt;p&gt;The insight that unlocked it for me: for any weight, &lt;code&gt;∂loss/∂weight&lt;/code&gt; = (how much the loss changes with that neuron’s output) × (what that neuron’s input was). The first factor — call it &lt;strong&gt;delta&lt;/strong&gt; — is the only hard part, and each layer’s delta can be computed from the layer &lt;em&gt;after&lt;/em&gt; it. So you compute deltas once, right to left, and every weight update reads off a delta you already have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step one: the delta at the output layer.&lt;/strong&gt; This is where the earlier choices pay off. Differentiate cross-entropy composed with softmax and all the exponentials and logarithms cancel:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;delta[i][j] = outputs[i][outputs[i].&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt; - &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;][j] - expectedOutputs[i][j];&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;prediction − target&lt;/code&gt;. That’s the entire output-layer gradient. Softmax and cross-entropy are paired &lt;em&gt;because&lt;/em&gt; their derivatives collapse like this, and seeing it fall out of the algebra was the most satisfying moment of the project.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step two: update this layer, then push delta backwards.&lt;/strong&gt; For each weight, gradient = delta × the input that flowed through it, averaged over the batch:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; i&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;weight&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt; - &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;; i &amp;gt;= &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; i--) {          &lt;/span&gt;&lt;span&gt;// layers, back to front&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; j&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; j &amp;lt; weight[i].&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;; j++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; k&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; k &amp;lt; weight[i][j].&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;; k++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            double&lt;/span&gt;&lt;span&gt; gradientSum&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0.0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; l&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; l &amp;lt; batchSize; l++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                gradientSum += delta[l][k] * inputs[l][i][j];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            weight[i][j][k] -= learningRate * (gradientSum / batchSize);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    // ... bias update, same shape ...&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (i &amp;gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        // delta for the previous layer: weighted sum of this layer&apos;s deltas,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        // scaled by the derivative of that layer&apos;s activation&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        newDelta[j][k] = sum * &lt;/span&gt;&lt;span&gt;MathUtil&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;reluDerivative&lt;/span&gt;&lt;span&gt;(outputs[j][i-&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;][k]);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        delta = newDelta;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three ideas live in that loop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;weight -= learningRate * gradient&lt;/code&gt; is &lt;strong&gt;gradient descent&lt;/strong&gt;. The gradient says which direction increases loss; step the other way, scaled small so you don’t overshoot.&lt;/li&gt;
&lt;li&gt;The previous layer’s delta is this layer’s deltas &lt;strong&gt;weighted by the connections between them&lt;/strong&gt; — error attributed backwards in proportion to contribution.&lt;/li&gt;
&lt;li&gt;Multiplying by the activation derivative closes the chain rule. A neuron whose ReLU output was zero contributed nothing, so it receives no blame.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Concept 4: Mini-batches, and why batch size is a real choice&lt;a href=&quot;#concept-4-mini-batches-and-why-batch-size-is-a-real-choice&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;You could update after every sample (noisy, slow) or after all 10,000 (smooth, expensive, glacial progress). Mini-batching splits the difference: accumulate gradients over N samples, then step once.&lt;/p&gt;
&lt;p&gt;I implemented it as buffer-and-fire rather than a nested loop — the backward pass is called after every sample but only &lt;em&gt;acts&lt;/em&gt; when the buffer fills:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; void&lt;/span&gt;&lt;span&gt; backwardPass&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; epoch) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (index == batchSize) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        index = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        double&lt;/span&gt;&lt;span&gt; loss&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;MathUtil&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;crossEntropy&lt;/span&gt;&lt;span&gt;(finalOutput, &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;expectedOutputs&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        tuneParameters&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;inputs&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt;[batchSize][&lt;/span&gt;&lt;span&gt;weight&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;][];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;outputs&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt;[batchSize][&lt;/span&gt;&lt;span&gt;weight&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;][];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;expectedOutputs&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt;[batchSize][];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It keeps the training loop flat, at the cost of coupling one &lt;code&gt;index&lt;/code&gt; to three buffers. Averaging over the batch (&lt;code&gt;gradientSum / batchSize&lt;/code&gt;) is what makes the learning rate independent of batch size — otherwise doubling the batch would double every step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Design note.&lt;/strong&gt; &lt;code&gt;predict()&lt;/code&gt; calls the same &lt;code&gt;forwardPass()&lt;/code&gt;, which writes into those training buffers at the current index. Train-then-predict is exactly how the program runs, so this is fine as written; separating inference state from training state is the change I’d make before ever interleaving the two.&lt;/p&gt;
&lt;h2&gt;The lesson I didn’t expect: data layout decides what you can express&lt;a href=&quot;#the-lesson-i-didnt-expect-data-layout-decides-what-you-can-express&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I wrote this network twice, and the comparison taught me more than either version alone.&lt;/p&gt;
&lt;p&gt;The first, &lt;code&gt;MLPNetwork&lt;/code&gt;, is the design an OO instinct reaches for: a &lt;code&gt;Layer&lt;/code&gt; of &lt;code&gt;Neuron&lt;/code&gt; objects, each owning its weights, bias, input and output. It reads beautifully. Watch where the weight update sources its input activation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt; input&lt;/span&gt;&lt;span&gt; = layerIndex == &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt; ?&lt;/span&gt;&lt;span&gt; inputs[k][j] &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; prevLayer&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;neurons&lt;/span&gt;&lt;span&gt;[j].&lt;/span&gt;&lt;span&gt;output&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;gradientSum += delta[k][i] * input;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For the first layer, &lt;code&gt;inputs[k][j]&lt;/code&gt; indexes sample &lt;code&gt;k&lt;/code&gt; of the batch. Everywhere else, &lt;code&gt;prevLayer.neurons[j].output&lt;/code&gt; is &lt;strong&gt;one field on a shared object&lt;/strong&gt; — it holds whatever the most recent forward pass left there. A neuron object can hold one activation; mini-batch backprop needs one per sample in flight. The structure quietly asserts something the algorithm doesn’t agree with.&lt;/p&gt;
&lt;p&gt;The second version, &lt;code&gt;MLPNetworkOptimized&lt;/code&gt;, replaced objects with flat arrays and kept per-sample buffers:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt;[][][] &lt;/span&gt;&lt;span&gt;inputs&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span&gt;// [batchIndex][layer][neuron]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; double&lt;/span&gt;&lt;span&gt;[][][] &lt;/span&gt;&lt;span&gt;outputs&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;which is why its update can say &lt;code&gt;inputs[l][i][j]&lt;/code&gt; — sample &lt;code&gt;l&lt;/code&gt;, layer &lt;code&gt;i&lt;/code&gt;, neuron &lt;code&gt;j&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I named it “Optimized” because I was chasing speed: flat &lt;code&gt;double[][][]&lt;/code&gt; beats chasing pointers through a hundred thousand objects, and the JIT keeps the inner loop in registers. What I got alongside the speed was a structure that could hold the state the maths actually needs. That reframed the whole exercise for me, and it’s the part that generalises furthest beyond neural networks: &lt;strong&gt;choosing the data layout is choosing which behaviours are expressible.&lt;/strong&gt; &lt;code&gt;Neuron.output&lt;/code&gt; looked like good encapsulation and was really a claim about dimensionality.&lt;/p&gt;
&lt;p&gt;It also explained something I’d taken for granted. Every framework represents a batch as a tensor with a leading batch dimension, and I’d always read that as a vectorisation detail. It isn’t only that — the batch axis has to exist somewhere, and putting it in the data structure is what makes the gradient definable.&lt;/p&gt;
&lt;h2&gt;Before any of it: the data is a binary blob&lt;a href=&quot;#before-any-of-it-the-data-is-a-binary-blob&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;One more concept, because it’s the first wall I hit. MNIST doesn’t ship as CSV — it’s IDX, a big-endian header followed by raw bytes. There’s no library call; you assemble integers from bytes yourself:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; static&lt;/span&gt;&lt;span&gt; int&lt;/span&gt;&lt;span&gt; readInt&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;FileInputStream&lt;/span&gt;&lt;span&gt; fis) throws IOException {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;fis&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;read&lt;/span&gt;&lt;span&gt;() &amp;lt;&amp;lt; &lt;/span&gt;&lt;span&gt;24&lt;/span&gt;&lt;span&gt;) | (&lt;/span&gt;&lt;span&gt;fis&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;read&lt;/span&gt;&lt;span&gt;() &amp;lt;&amp;lt; &lt;/span&gt;&lt;span&gt;16&lt;/span&gt;&lt;span&gt;) | (&lt;/span&gt;&lt;span&gt;fis&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;read&lt;/span&gt;&lt;span&gt;() &amp;lt;&amp;lt; &lt;/span&gt;&lt;span&gt;8&lt;/span&gt;&lt;span&gt;) | &lt;/span&gt;&lt;span&gt;fis&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;read&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then you assert the magic number (2051 for images, 2049 for labels) so a wrong file fails loudly instead of training on garbage, and you normalise pixels into a small range so the first layer’s weights don’t have to absorb a factor of 255. Mine divides by &lt;code&gt;254.0&lt;/code&gt;, which puts a saturated pixel a hair above 1.0 — immaterial at this scale, and a good reminder that scaling constants deserve the same attention as the algorithm. The most effective verification I found was writing a few parsed samples back out as PNGs and looking at them; a transposed dataset is instantly obvious to an eye and invisible to an assertion.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Design note on the run configuration.&lt;/strong&gt; &lt;code&gt;main&lt;/code&gt; trains on the &lt;code&gt;t10k&lt;/code&gt; files (10,000 images) and evaluates on the &lt;code&gt;train&lt;/code&gt; files (60,000). Conventionally that’s the other way round, and I kept it because the property that matters held — the evaluation set is strictly disjoint from what it learned on — and it makes for a harsher test: less data to learn from, six times as much to be judged on. Shuffling between epochs is the other knob I left alone; with fixed batch composition repeating 20 times, adding a shuffle is the cheapest available improvement.&lt;/p&gt;
&lt;h2&gt;What’s worth keeping from this&lt;a href=&quot;#whats-worth-keeping-from-this&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A network is multiply-add plus one nonlinearity; depth without the nonlinearity is worthless.&lt;/li&gt;
&lt;li&gt;Softmax and cross-entropy are paired because their gradients collapse to &lt;code&gt;prediction − target&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Backprop is one reverse pass that reuses each layer’s delta, and that reuse is the entire reason training is tractable.&lt;/li&gt;
&lt;li&gt;An activation and its derivative are one decision, not two.&lt;/li&gt;
&lt;li&gt;Frameworks aren’t hiding the calculus. They’re hiding numerical stability guards, initialization scaling, shuffling and the batch axis — and the clearest way to see the shape of that work is to leave it out and notice what starts mattering.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>Machine Learning &amp; Statistics</category><category>Java</category><category>Machine Learning</category><category>Algorithms</category></item><item><title>Observability: Why Your Logs Need Structure, Correlation and a Pipeline</title><link>https://rustamniraula.com.np/blog/observability-structured-logs-traces-elk/</link><guid isPermaLink="true">https://rustamniraula.com.np/blog/observability-structured-logs-traces-elk/</guid><description>Working through structured logging, trace correlation, agent-based instrumentation and the ship-parse-store-visualise pipeline by wiring a Spring Boot service to Elasticsearch, Logstash, Kibana and Elastic APM in Docker Compose.</description><pubDate>Tue, 01 Dec 2020 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A request comes in and takes 4 seconds. Nobody knows why. You SSH into a box, &lt;code&gt;grep&lt;/code&gt; a log file, find the line, and it says:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;2020-12-01 14:23:07 INFO  Getting test with id: 4471&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which tells you almost nothing. Was the database slow? Which of the 40 requests in flight was this one? Did it call another service, and was &lt;em&gt;that&lt;/em&gt; slow? You have a fact with no context, on one machine, in a format only a human can read.&lt;/p&gt;
&lt;p&gt;I’d hit that wall enough times to want to understand the alternative properly rather than copy a config from a blog post. Three concepts do almost all the work: making log events &lt;strong&gt;machine-readable&lt;/strong&gt;, giving every event a &lt;strong&gt;correlation identifier&lt;/strong&gt; tying it to the request it belongs to, and running a &lt;strong&gt;pipeline&lt;/strong&gt; that collects events off ephemeral machines into one queryable place.&lt;/p&gt;
&lt;p&gt;So I built a working stack end to end — a Spring Boot service instrumented and shipped into Elasticsearch, Logstash, Kibana and Elastic APM, all in Docker Compose: &lt;a href=&quot;https://gitlab.com/rustam.niraula90/apm&quot;&gt;gitlab.com/rustam.niraula90/apm&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;These are my notes from wiring it up.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Concept 1: Logs are data, so stop formatting them as prose&lt;a href=&quot;#concept-1-logs-are-data-so-stop-formatting-them-as-prose&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The line above is a &lt;em&gt;string&lt;/em&gt;. To answer “all requests slower than 2s for user 4471 on the checkout path,” something has to re-derive fields from that string with regular expressions — and those regexes break every time someone edits a log message.&lt;/p&gt;
&lt;p&gt;Structured logging inverts the relationship: emit the fields, let the tooling render the sentence. Same event, as JSON:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;@timestamp&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;2020-12-01T14:23:07.412Z&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;level&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;INFO&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;logger_name&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;com.apm.apm.test.TestController&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;thread_name&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;http-nio-8080-exec-3&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;message&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;Getting test with id: 4471&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;trace&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;trace_id&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;af3d1c...&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;span_id&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;b1e4...&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now &lt;code&gt;level:ERROR AND trace.trace_id:af3d1c*&lt;/code&gt; is a query, not a parsing project.&lt;/p&gt;
&lt;p&gt;The thing that sold me is that in Spring Boot this is a logging &lt;em&gt;configuration&lt;/em&gt; change, not a code change. Application code keeps making ordinary SLF4J calls, and an encoder turns each event into JSON on the way out:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt; name&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;jsonConsoleAppender&quot;&lt;/span&gt;&lt;span&gt; class&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;ch.qos.logback.core.ConsoleAppender&quot;&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;lt;&lt;/span&gt;&lt;span&gt;encoder&lt;/span&gt;&lt;span&gt; class&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;net.logstash.logback.encoder.LogstashEncoder&quot;&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        &amp;lt;&lt;/span&gt;&lt;span&gt;providers&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &amp;lt;&lt;/span&gt;&lt;span&gt;timestamp&lt;/span&gt;&lt;span&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span&gt;timeZone&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;UTC&lt;/span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;timeZone&lt;/span&gt;&lt;span&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;timestamp&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &amp;lt;&lt;/span&gt;&lt;span&gt;logLevel&lt;/span&gt;&lt;span&gt;/&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &amp;lt;&lt;/span&gt;&lt;span&gt;message&lt;/span&gt;&lt;span&gt;/&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &amp;lt;&lt;/span&gt;&lt;span&gt;loggerName&lt;/span&gt;&lt;span&gt;/&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &amp;lt;&lt;/span&gt;&lt;span&gt;threadName&lt;/span&gt;&lt;span&gt;/&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &amp;lt;&lt;/span&gt;&lt;span&gt;mdc&lt;/span&gt;&lt;span&gt;/&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            &amp;lt;&lt;/span&gt;&lt;span&gt;stackTrace&lt;/span&gt;&lt;span&gt;/&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        &amp;lt;/&lt;/span&gt;&lt;span&gt;providers&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;lt;/&lt;/span&gt;&lt;span&gt;encoder&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That separation is the part worth internalising: &lt;strong&gt;structured logging is a transport decision, not an API your business logic should know about.&lt;/strong&gt; The controller still just writes &lt;code&gt;log.info(&quot;Getting test with id: {}&quot;, id)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Two choices in that config I made deliberately. &lt;strong&gt;Timestamps in UTC&lt;/strong&gt;, because correlating events across machines in different zones is otherwise guesswork. And &lt;strong&gt;&lt;code&gt;&amp;lt;stackTrace/&amp;gt;&lt;/code&gt; as its own field&lt;/strong&gt;, so a Java exception stays one event instead of becoming 30 unparseable lines — the classic failure mode of line-oriented log shipping.&lt;/p&gt;
&lt;h2&gt;Concept 2: Correlation IDs — the field that makes logs useful&lt;a href=&quot;#concept-2-correlation-ids--the-field-that-makes-logs-useful&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Structure alone isn’t enough. With 40 concurrent requests you have 40 interleaved conversations and no way to follow one. You need a shared key on every event belonging to the same request.&lt;/p&gt;
&lt;p&gt;That’s a &lt;strong&gt;trace ID&lt;/strong&gt;: generated when a request enters the system, attached to every log line it produces, and &lt;em&gt;propagated&lt;/em&gt; to every downstream service it calls. Filter by it and you get one request’s complete story, in order, across every process it touched.&lt;/p&gt;
&lt;p&gt;The plumbing that makes this ergonomic is MDC — Mapped Diagnostic Context — a thread-local map the logging framework merges into every event. Instrumentation puts the IDs in; the encoder pulls them out:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;pattern&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;trace&quot;: {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;trace_id&quot;: &quot;%mdc{X-B3-TraceId}&quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;span_id&quot;: &quot;%mdc{X-B3-SpanId}&quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;parent_span_id&quot;: &quot;%mdc{X-B3-ParentSpanId}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;pattern&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The three fields encode a hierarchy, and the distinction is what took me longest to internalise:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;trace_id&lt;/strong&gt; — one per end-to-end request, identical across every service.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;span_id&lt;/strong&gt; — one per unit of work inside it (an HTTP handler, a DB query).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;parent_span_id&lt;/strong&gt; — which unit spawned this one.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With parent links, a flat event stream reconstructs into a tree, which is what lets a UI show a waterfall — and a waterfall is what answers “where did the 4 seconds go?”&lt;/p&gt;
&lt;p&gt;&lt;code&gt;X-B3-*&lt;/code&gt; is the B3 propagation format from Zipkin: the IDs travel as HTTP headers, so a downstream service adopts the caller’s trace instead of starting its own. Realising that this header contract &lt;em&gt;is&lt;/em&gt; the entire mechanism of distributed tracing — everything else is presentation — was the moment the topic stopped feeling like magic.&lt;/p&gt;
&lt;p&gt;One detail in that config: the same keys are excluded from the generic &lt;code&gt;&amp;lt;mdc/&amp;gt;&lt;/code&gt; provider.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;mdc&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;lt;&lt;/span&gt;&lt;span&gt;excludeMdcKeyName&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;traceId&lt;/span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;excludeMdcKeyName&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;lt;&lt;/span&gt;&lt;span&gt;excludeMdcKeyName&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;X-B3-TraceId&lt;/span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;excludeMdcKeyName&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ...&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;mdc&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Otherwise every trace ID appears twice — once flat, once nested — and Elasticsearch indexes both. Structure your fields once, in the shape you’ll query.&lt;/p&gt;
&lt;h2&gt;Concept 3: Instrumentation without touching your code&lt;a href=&quot;#concept-3-instrumentation-without-touching-your-code&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Logs tell you what your code chose to say. Traces need timing around &lt;em&gt;everything&lt;/em&gt; — every HTTP handler, every JDBC call, every outbound request — and hand-wrapping all of that isn’t realistic.&lt;/p&gt;
&lt;p&gt;The answer is a &lt;strong&gt;runtime agent&lt;/strong&gt;. On the JVM, &lt;code&gt;-javaagent&lt;/code&gt; loads before &lt;code&gt;main&lt;/code&gt; and rewrites bytecode as classes load, wrapping known framework entry points with timing and ID propagation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;entrypoint&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;span&gt;&quot;java&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;-javaagent:/app/libs/elastic-apm-agent-1.19.0.jar&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;-Delastic.apm.application_packages=com.apm&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;-Delastic.apm.service_name=apm-service&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;-Delastic.apm.server_urls=http://apm-server:8200&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;-cp&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;/app/resources:/app/classes:/app/libs/*&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;com.apm.apm.ApmApplication&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four flags is the whole integration. There’s no tracing code in the application, and yet every controller method, every Hibernate query and every outgoing call gets a span with a parent link. The agent also seeds the MDC keys the logging config above reads — which is exactly how logs and traces end up sharing an ID without either subsystem knowing about the other. Watching that happen for the first time is what made the design click.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;application_packages&lt;/code&gt; is the flag I’d have skipped if I hadn’t read what it does: it tells the agent which frames are &lt;em&gt;yours&lt;/em&gt;, so stack traces highlight your code rather than 60 lines of framework internals.&lt;/p&gt;
&lt;p&gt;The trade-off is worth stating plainly: agents give you breadth for free, but they only know the frameworks they were taught, they cost startup time and some overhead, and they’re opaque when they misbehave. Modern practice has largely converged on OpenTelemetry for this role — same agent concept, vendor-neutral wire format — and that’s what I’d reach for now.&lt;/p&gt;
&lt;h2&gt;Concept 4: The pipeline — collect, parse, store, visualise&lt;a href=&quot;#concept-4-the-pipeline--collect-parse-store-visualise&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Application containers are ephemeral. Logs written inside them die with them, so events have to be &lt;em&gt;moved&lt;/em&gt; somewhere durable. Every observability stack, whatever the brand names, is the same four stages:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;  app (stdout, JSON)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ┌───▼────┐   collect       ┌──────────┐   parse/enrich&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │Filebeat│ ──────────────▶ │ Logstash │ ─────────────┐&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    └────────┘                 └──────────┘              │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                                                    ┌────▼─────────┐&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  app (APM agent) ──── traces ───▶ APM Server ────▶ │Elasticsearch │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                                                    └────┬─────────┘&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                                                         │ query&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                                                    ┌────▼───┐&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                                                    │ Kibana │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                                                    └────────┘&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Collect&lt;/strong&gt; (Filebeat) — a lightweight agent that tails logs and forwards them. Deliberately dumb, because it runs everywhere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parse and enrich&lt;/strong&gt; (Logstash) — heavier processing: reshape fields, add metadata, route.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Store and index&lt;/strong&gt; (Elasticsearch) — the inverted index that makes ad-hoc queries over billions of events fast.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Visualise&lt;/strong&gt; (Kibana) — search, dashboards, trace waterfalls.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Splitting collect from parse is the architectural decision that matters. Collection has to be cheap and ubiquitous; parsing is CPU-hungry and centralised. Keeping the sidecar dumb is what makes running one per host affordable.&lt;/p&gt;
&lt;h3&gt;Discovery by label, not by path&lt;a href=&quot;#discovery-by-label-not-by-path&quot;&gt;#&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The obvious way to collect container logs is to mount known paths. That breaks the moment a container is rescheduled or a new service appears — someone has to go update config.&lt;/p&gt;
&lt;p&gt;The better concept is &lt;strong&gt;autodiscovery&lt;/strong&gt;: the collector watches the Docker socket and configures itself from container metadata, so a service opts &lt;em&gt;itself&lt;/em&gt; in.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;filebeat.autodiscover&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  providers&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - &lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;docker&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      labels.dedot&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      templates&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        - &lt;/span&gt;&lt;span&gt;condition&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            contains&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              container.labels.collect_logs_with_filebeat&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;true&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          config&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            - &lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;container&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              paths&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                - &lt;/span&gt;&lt;span&gt;&quot;/var/lib/docker/containers/${data.docker.container.id}/*.log&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              processors&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                - &lt;/span&gt;&lt;span&gt;decode_json_fields&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                    when.equals&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                      docker.container.labels.decode_log_event_to_json_object&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;true&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                    fields&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;span&gt;&quot;message&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                    target&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                    overwrite_keys&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The service declares its own participation with two labels:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;labels&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  collect_logs_with_filebeat&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;true&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  decode_log_event_to_json_object&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;true&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add a service, give it the labels, and it appears in Kibana. Nothing about the logging infrastructure changes. &lt;strong&gt;Configuration flows from the workload to the platform, rather than the platform maintaining a registry of workloads&lt;/strong&gt; — the same inversion Kubernetes label selectors are built on, which is where I recognised the pattern from.&lt;/p&gt;
&lt;p&gt;That second label solves a specific annoyance I ran straight into. Docker wraps container stdout in its &lt;em&gt;own&lt;/em&gt; JSON envelope, so a carefully structured event arrives as a string stuffed inside a &lt;code&gt;message&lt;/code&gt; field — JSON inside JSON, and none of the inner fields are queryable. &lt;code&gt;decode_json_fields&lt;/code&gt; with &lt;code&gt;target: &quot;&quot;&lt;/code&gt; re-parses that inner string and hoists the fields to the top level. Anyone shipping JSON logs through Docker meets this; knowing the name of the fix saves an afternoon.&lt;/p&gt;
&lt;h2&gt;Concept 5: Reproducible topology&lt;a href=&quot;#concept-5-reproducible-topology&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Six services with a specific wiring order isn’t something to set up by hand twice. Declaring the topology — dependency order, private network, environment — is what makes the whole thing one command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;services&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  apm&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    depends_on&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;apm_db&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;filebeat&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;apm-server&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    environment&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      SPRING.DATASOURCE.URL&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;jdbc:mysql://apm_db:3306/APM?createDatabaseIfNotExist=true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    networks&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      - &lt;/span&gt;&lt;span&gt;apm-network&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things this taught me beyond “use Compose.” Service names are DNS inside the network, so &lt;code&gt;jdbc:mysql://apm_db:3306&lt;/code&gt; resolves by name and no IPs appear in config anywhere. And environment-variable overrides mean the &lt;em&gt;same&lt;/em&gt; build artifact runs locally and in the stack with only its wiring changed — the twelve-factor rule, and Spring’s relaxed binding (&lt;code&gt;SPRING.DATASOURCE.URL&lt;/code&gt; → &lt;code&gt;spring.datasource.url&lt;/code&gt;) exists precisely to make it painless.&lt;/p&gt;
&lt;p&gt;I built the image with Jib rather than a Dockerfile:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;plugin&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;lt;&lt;/span&gt;&lt;span&gt;groupId&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;com.google.cloud.tools&lt;/span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;groupId&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;lt;&lt;/span&gt;&lt;span&gt;artifactId&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;jib-maven-plugin&lt;/span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;artifactId&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;lt;&lt;/span&gt;&lt;span&gt;configuration&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        &amp;lt;&lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span&gt;image&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;openjdk:8-jdk-alpine&lt;/span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;image&lt;/span&gt;&lt;span&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        &amp;lt;&lt;/span&gt;&lt;span&gt;to&lt;/span&gt;&lt;span&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span&gt;image&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt;${docker.registry.host}/${docker.registry.repository}:${docker.registry.version}&lt;/span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;image&lt;/span&gt;&lt;span&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;to&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &amp;lt;/&lt;/span&gt;&lt;span&gt;configuration&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&amp;lt;/&lt;/span&gt;&lt;span&gt;plugin&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Jib layers dependencies separately from application classes, so a code change rebuilds only the thin top layer while dependencies stay cached. That layering insight generalises to any Dockerfile: &lt;strong&gt;order build steps from least-frequently-changed to most.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Design notes: what this stack is scoped for&lt;a href=&quot;#design-notes-what-this-stack-is-scoped-for&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I built this to understand the concepts on a laptop, and the configuration reflects that. Worth being explicit about the gap between this and a production deployment, since the delta is itself a useful list:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Elasticsearch runs &lt;code&gt;discovery.type=single-node&lt;/code&gt; as root with no authentication.&lt;/strong&gt; One node means no replicas, and open unauthenticated search is fine on a private Docker network and nowhere else. Production wants a multi-node cluster with TLS and role-based access.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Credentials are inline&lt;/strong&gt; (&lt;code&gt;MYSQL_ROOT_PASSWORD: root&lt;/code&gt;). Fine for a disposable local stack; secrets management is the real answer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No retention policy.&lt;/strong&gt; Log volume grows without bound, so index lifecycle management — rollover and delete — is the next thing to add.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No sampling.&lt;/strong&gt; Tracing every request is exactly right at demo traffic and untenable at scale, which is what head-based and tail-based sampling exist for.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Logstash output index is &lt;code&gt;logstash-s%{+YYYY.MM.dd}&lt;/code&gt;&lt;/strong&gt;, one character off the conventional &lt;code&gt;logstash-&lt;/code&gt; prefix. Harmless in isolation, and a neat demonstration that index naming is a contract: dashboards and lifecycle policies match on those patterns, so the convention is worth pinning down before you have dashboards.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What I took away&lt;a href=&quot;#what-i-took-away&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The concepts turned out to be small and to compose cleanly. Structure your events so machines can read them. Stamp every one with the ID of the request that caused it. Let an agent instrument what you’d never wrap by hand. Ship it all somewhere durable and queryable. Get those four right and the 4-second request stops being a mystery — you filter by its trace ID and read the waterfall.&lt;/p&gt;
&lt;p&gt;What I hadn’t expected was how much of the work is &lt;em&gt;plumbing decisions&lt;/em&gt; rather than tooling choices: UTC or local, nested or flat fields, labels or paths, collect-then-parse or parse-at-source. The brand names change every few years; those decisions don’t.&lt;/p&gt;</content:encoded><category>Systems &amp; Infrastructure</category><category>Java</category><category>Spring Boot</category><category>Observability</category><category>Docker</category></item><item><title>Backtracking: Solving Problems by Guessing Systematically</title><link>https://rustamniraula.com.np/blog/backtracking-sudoku-solver/</link><guid isPermaLink="true">https://rustamniraula.com.np/blog/backtracking-sudoku-solver/</guid><description>Working through constraint search — pruned tree exploration, candidate checks, recursive descent and undo — by building a browser Sudoku solver that animates its own search using async/await to yield to the event loop.</description><pubDate>Tue, 17 Mar 2020 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;An empty Sudoku grid has 81 cells and 9 possible values each. That’s 9⁸¹ — around 10⁷⁷ — grids, comparable to the number of atoms in the observable universe. Yet a laptop solves Sudoku in milliseconds.&lt;/p&gt;
&lt;p&gt;The gap between those two facts is &lt;strong&gt;backtracking&lt;/strong&gt;, and once I understood it I started seeing it everywhere. It’s how you search a space too large to enumerate: not by being clever about which answer is right, but by being disciplined about abandoning wrong ones early.&lt;/p&gt;
&lt;p&gt;I built a Sudoku solver in the browser mainly so I could &lt;em&gt;watch&lt;/em&gt; it happen — the grid updates cell by cell as the algorithm guesses, gets stuck and reverses out. Code: &lt;a href=&quot;https://gitlab.com/rustam.niraula90/sudoku-solver&quot;&gt;gitlab.com/rustam.niraula90/sudoku-solver&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;These are my notes from building it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Concept 1: A solution space is a tree, and most of it is dead&lt;a href=&quot;#concept-1-a-solution-space-is-a-tree-and-most-of-it-is-dead&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The reframing that made this click: stop thinking about 10⁷⁷ complete grids and think about &lt;em&gt;partial&lt;/em&gt; ones. Fill the first cell — 9 branches. Fill the second — 9 more each. That’s a tree, and a path from the root to depth 81 is a candidate solution.&lt;/p&gt;
&lt;p&gt;The crucial part is that the tree is mostly &lt;strong&gt;dead on arrival&lt;/strong&gt;. If the first two cells both hold a 5 in the same row, every one of the 9⁷⁹ grids below that node is invalid. You never visit them; you don’t even need to know how many there are. Cutting a branch at depth 2 discards an astronomical subtree for the cost of one comparison.&lt;/p&gt;
&lt;p&gt;That’s the whole trick. Backtracking is depth-first search plus the discipline of testing validity &lt;em&gt;at every step&lt;/em&gt; rather than at the end. The exponential space never materialises because you’re never holding more than one partial candidate at a time.&lt;/p&gt;
&lt;h2&gt;Concept 2: The constraint check is the real engine&lt;a href=&quot;#concept-2-the-constraint-check-is-the-real-engine&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Everything depends on cheaply answering one question: &lt;em&gt;can this value go in this cell?&lt;/em&gt; For Sudoku the rules are row, column and 3×3 box uniqueness:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; possible&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;let&lt;/span&gt;&lt;span&gt; i&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;9&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;puzzle&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;][&lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;] == &lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt; || &lt;/span&gt;&lt;span&gt;puzzle&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;][&lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;] == &lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            return&lt;/span&gt;&lt;span&gt; false&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    let&lt;/span&gt;&lt;span&gt; x0&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;floor&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; / &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;) * &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    let&lt;/span&gt;&lt;span&gt; y0&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;floor&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt; / &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;) * &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;let&lt;/span&gt;&lt;span&gt; i&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;let&lt;/span&gt;&lt;span&gt; j&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;j&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;j&lt;/span&gt;&lt;span&gt;++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;puzzle&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;x0&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;][&lt;/span&gt;&lt;span&gt;y0&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;j&lt;/span&gt;&lt;span&gt;] == &lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                return&lt;/span&gt;&lt;span&gt; false&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two details I’ve reused many times since:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Row and column share one loop.&lt;/strong&gt; &lt;code&gt;puzzle[x][i]&lt;/code&gt; walks the row while &lt;code&gt;puzzle[i][y]&lt;/code&gt; walks the column — same index, two constraints, one pass. Constraint checks run millions of times, so this is the one place in a backtracking solver where micro-optimisation genuinely pays for itself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The box origin is integer division.&lt;/strong&gt; &lt;code&gt;Math.floor(x / 3) * 3&lt;/code&gt; snaps any coordinate to the top-left corner of its 3×3 block. Mapping a point to the region containing it by rounding down to a multiple of the region size turns out to be a pattern that shows up constantly — tiling, spatial hashing, pagination.&lt;/p&gt;
&lt;h2&gt;Concept 3: Guess, recurse, undo&lt;a href=&quot;#concept-3-guess-recurse-undo&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;With a validity test in hand, the solver is remarkably short. Find an empty cell, try each candidate, recurse, and &lt;strong&gt;undo the guess if the recursion doesn’t pan out&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; function&lt;/span&gt;&lt;span&gt; solve&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;let&lt;/span&gt;&lt;span&gt; x&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;9&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;let&lt;/span&gt;&lt;span&gt; y&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;9&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;puzzle&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;][&lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;] == &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;let&lt;/span&gt;&lt;span&gt; n&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;sudokuSolver&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;possible&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;)) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                        puzzle&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;][&lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;] = &lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;;                        &lt;/span&gt;&lt;span&gt;// guess&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                        sudokuSolver&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;updateViewValue&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                        await&lt;/span&gt;&lt;span&gt; new&lt;/span&gt;&lt;span&gt; Promise&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt; =&amp;gt;&lt;/span&gt;&lt;span&gt; setTimeout&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;sudokuSolver&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;bufferTime&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                        await&lt;/span&gt;&lt;span&gt; sudokuSolver&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;solve&lt;/span&gt;&lt;span&gt;();              &lt;/span&gt;&lt;span&gt;// recurse&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                        if&lt;/span&gt;&lt;span&gt; (!&lt;/span&gt;&lt;span&gt;sudokuSolver&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;isSolved&lt;/span&gt;&lt;span&gt;()) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                            puzzle&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;][&lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;] = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;                    &lt;/span&gt;&lt;span&gt;// undo&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                        } &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                            return&lt;/span&gt;&lt;span&gt;;                              &lt;/span&gt;&lt;span&gt;// done&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                        }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                return&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span&gt;// no candidate worked — dead end, unwind&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three lines carry the idea:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;puzzle[x][y] = n&lt;/code&gt; — commit to a guess.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await solve()&lt;/code&gt; — explore everything that follows from it.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;puzzle[x][y] = 0&lt;/code&gt; — &lt;strong&gt;restore the state exactly as it was&lt;/strong&gt; before trying the next candidate.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That third line is the one I’d underestimated. Your state has to be restorable. Here it’s trivial, because a guess writes one cell and undoing is one assignment — and that’s not luck, it’s a consequence of representing the puzzle as a plain mutable grid. In harder problems (graph colouring, N-queens with incremental constraint tables, dependency resolution) you either design the mutation to be cheaply reversible or you pay to copy state at every branch. &lt;strong&gt;Picking a representation you can undo is the actual design work.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The bare &lt;code&gt;return&lt;/code&gt; at the end of the cell loop is subtle and load-bearing: if no value fits an empty cell, this branch is unsolvable, so stop immediately. That early return is the pruning.&lt;/p&gt;
&lt;h2&gt;Concept 4: How a recursive search reports success&lt;a href=&quot;#concept-4-how-a-recursive-search-reports-success&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;My version detects completion by scanning the grid for empty cells:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; isSolved&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;let&lt;/span&gt;&lt;span&gt; x&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;9&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;let&lt;/span&gt;&lt;span&gt; y&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;9&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;puzzle&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;][&lt;/span&gt;&lt;span&gt;y&lt;/span&gt;&lt;span&gt;] == &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; false&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Design note.&lt;/strong&gt; This checks a global condition — “is the whole grid full?” — after each recursive call, rather than having &lt;code&gt;solve()&lt;/code&gt; return a boolean that propagates up the stack. Both terminate correctly, and pruning dominates the runtime either way, so the visible behaviour is the same. The difference is in how the stack unwinds: a boolean return lets each of the ~81 frames exit immediately, while a global check re-derives the answer at every frame and restarts its cell scan from &lt;code&gt;(0,0)&lt;/code&gt; on each descent.&lt;/p&gt;
&lt;p&gt;Writing it the second way and then reasoning about the first is what gave me the principle I now apply by reflex: &lt;strong&gt;a recursive function should report its own outcome rather than have its caller re-derive it from shared state.&lt;/strong&gt; Threading the answer through the return value is what keeps unwinding O(depth) instead of O(depth × grid). It’s a one-word change here, and a habit worth having before the problem is bigger.&lt;/p&gt;
&lt;h2&gt;Concept 5: Animating a recursive algorithm in a single-threaded language&lt;a href=&quot;#concept-5-animating-a-recursive-algorithm-in-a-single-threaded-language&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;This is the part I actually built the project to explore. JavaScript has one thread running both my code and the DOM. A synchronous recursive solve finishes in a few milliseconds and repaints exactly once — you’d see an empty grid, then a finished one, and learn nothing about the search.&lt;/p&gt;
&lt;p&gt;To &lt;em&gt;watch&lt;/em&gt; it, the algorithm has to hand control back to the browser between steps:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;sudokuSolver&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;updateViewValue&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; new&lt;/span&gt;&lt;span&gt; Promise&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt; =&amp;gt;&lt;/span&gt;&lt;span&gt; setTimeout&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;sudokuSolver&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;bufferTime&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;new Promise(r =&amp;gt; setTimeout(r, ms))&lt;/code&gt; is the idiomatic sleep — a promise that resolves on a timer. Awaiting it suspends the function, returns control to the event loop so the browser can lay out and paint, and resumes where it left off. &lt;strong&gt;Cooperative yielding&lt;/strong&gt;: the algorithm volunteers the thread rather than being preempted.&lt;/p&gt;
&lt;p&gt;What makes this practical is that &lt;code&gt;async&lt;/code&gt;/&lt;code&gt;await&lt;/code&gt; composes through recursion. &lt;code&gt;await solve()&lt;/code&gt; inside &lt;code&gt;solve()&lt;/code&gt; means a pause at depth 40 correctly suspends all 40 frames above it, and they resume in order. Doing the same with callbacks would mean turning the recursion inside out into an explicit state machine with a manual stack. Coroutines let me keep the natural recursive shape &lt;em&gt;and&lt;/em&gt; get the pause, and that realisation has outlived the project — it’s the same reason generators and async iterators feel so useful for streaming work.&lt;/p&gt;
&lt;p&gt;The delay is a slider bound to &lt;code&gt;bufferTime&lt;/code&gt;, 0 to 500 ms. At 0 the search rips through the grid; at 250 ms you can follow individual guesses and watch it reverse out of a dead end. Making speed a &lt;em&gt;control&lt;/em&gt; instead of a constant is what turned this from a visualisation into something I learned from — seeing it stall and unwind is what made the concept concrete in a way pseudocode never did.&lt;/p&gt;
&lt;h2&gt;Where this idea showed up next&lt;a href=&quot;#where-this-idea-showed-up-next&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Sudoku was the friendly introduction. The same four-part structure — candidate generation, constraint check, recursive descent, undo — turned out to be the backbone of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;N-Queens&lt;/strong&gt; and other placement puzzles&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Graph colouring&lt;/strong&gt; and scheduling under conflict constraints&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regular expression backtracking&lt;/strong&gt; (also where catastrophic blowup comes from, when the pruning &lt;em&gt;isn’t&lt;/em&gt; effective)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SAT solvers&lt;/strong&gt;, where DPLL is backtracking plus much smarter propagation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependency resolution&lt;/strong&gt; in package managers, picking versions under compatibility constraints&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the natural next step is the same in all of them: better ordering. My solver takes cells in reading order and values in ascending order. Choose the &lt;em&gt;most constrained&lt;/em&gt; cell first — fewest legal candidates remaining — and you fail faster, which means you prune higher in the tree. That single heuristic (MRV, minimum remaining values) typically cuts the search by orders of magnitude without changing the structure of the algorithm at all. Adding it is the obvious next iteration here, and it’s cheap: sort the empty cells by candidate count instead of scanning them in order.&lt;/p&gt;
&lt;p&gt;Backtracking isn’t a trick for one puzzle. It’s a general answer to “the space is too big to enumerate,” and the answer is: don’t enumerate it — walk it, and leave early.&lt;/p&gt;</content:encoded><category>Algorithms</category><category>JavaScript</category><category>Algorithms</category><category>Frontend</category></item><item><title>Genetic Algorithms: Evolving a Solution You Don&apos;t Know How to Compute</title><link>https://rustamniraula.com.np/blog/genetic-algorithms-evolving-a-solution/</link><guid isPermaLink="true">https://rustamniraula.com.np/blog/genetic-algorithms-evolving-a-solution/</guid><description>Working through genome design, fitness shaping, selection, mutation and elitism by building a browser simulation where a population of dots evolves its way to a goal — and learning what population diversity actually buys you.</description><pubDate>Wed, 16 Oct 2019 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Some problems you can solve directly. Others you can only &lt;em&gt;recognise&lt;/em&gt; a good answer to. Given a route through 50 cities you can measure its length instantly, but computing the shortest one means confronting 50! possibilities. Given a walking gait you can measure how far the robot got, but deriving the optimal joint sequence from first principles is hopeless.&lt;/p&gt;
&lt;p&gt;For that second class — evaluating is cheap, deriving is impossible — you can borrow the only algorithm known to have designed a working eye: &lt;strong&gt;evolution&lt;/strong&gt;. Generate random candidates, keep the ones that score better, copy them with small errors, repeat. No gradient, no model of the problem, no idea in advance what a good solution looks like.&lt;/p&gt;
&lt;p&gt;I built a browser simulation to see whether that really works: a population of dots, each executing a fixed sequence of random moves, evolving over generations until they find a goal. Code: &lt;a href=&quot;https://gitlab.com/rustam.niraula90/genetic-algorithm&quot;&gt;gitlab.com/rustam.niraula90/genetic-algorithm&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;These are my notes from building it and from what surprised me afterwards.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Concept 1: The genome — encode the &lt;em&gt;solution&lt;/em&gt;, not the solver&lt;a href=&quot;#concept-1-the-genome--encode-the-solution-not-the-solver&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The first decision in any genetic algorithm is what a candidate solution &lt;em&gt;is&lt;/em&gt; as data. I didn’t appreciate how much rides on this until later: get it wrong and nothing else can work.&lt;/p&gt;
&lt;p&gt;Here a candidate is an entire pre-committed movement plan — 500 direction codes, generated at random:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; default&lt;/span&gt;&lt;span&gt; class&lt;/span&gt;&lt;span&gt; Brain&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  constructor&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;step&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;size&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;window&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;$BRAIN_SIZE&lt;/span&gt;&lt;span&gt;;      &lt;/span&gt;&lt;span&gt;// 500&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;directions&lt;/span&gt;&lt;span&gt; = [];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;mutationRate&lt;/span&gt;&lt;span&gt; = (&lt;/span&gt;&lt;span&gt;window&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;$MUTATION_PERCENTAGE&lt;/span&gt;&lt;span&gt; / &lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;randomize&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  randomize&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; i&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;size&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;directions&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;] = &lt;/span&gt;&lt;span&gt;getRndInteger&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;);   &lt;/span&gt;&lt;span&gt;// 0=stay, 1=left, 2=up, 3=right, 4=down&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three properties make this workable, and I’ve since treated them as the general criteria:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fixed length.&lt;/strong&gt; Every genome is 500 integers, so any two are structurally interchangeable and mutation can’t produce something malformed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Every value is legal.&lt;/strong&gt; A direction code is 0–4 and all five mean something (0 is “stand still”). There’s no such thing as an invalid genome, so mutation never needs repair logic — a large simplification worth designing for deliberately.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No intelligence inside.&lt;/strong&gt; The dot has no goal-seeking behaviour at all. It cannot see the target. It just replays its list:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;move&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;brain&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;directions&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt; &amp;gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;brain&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;step&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;currentMove&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;brain&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;directions&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;brain&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;step&lt;/span&gt;&lt;span&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;brain&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;step&lt;/span&gt;&lt;span&gt;++;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    } &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;dead&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;      &lt;/span&gt;&lt;span&gt;// out of moves&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    switch&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;currentMove&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      case&lt;/span&gt;&lt;span&gt; 1&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionX&lt;/span&gt;&lt;span&gt; -= &lt;/span&gt;&lt;span&gt;window&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;$ONE_STEP&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;break&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span&gt;// left&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      case&lt;/span&gt;&lt;span&gt; 2&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionY&lt;/span&gt;&lt;span&gt; -= &lt;/span&gt;&lt;span&gt;window&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;$ONE_STEP&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;break&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span&gt;// up&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      case&lt;/span&gt;&lt;span&gt; 3&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionX&lt;/span&gt;&lt;span&gt; += &lt;/span&gt;&lt;span&gt;window&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;$ONE_STEP&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;break&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span&gt;// right&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      case&lt;/span&gt;&lt;span&gt; 4&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionY&lt;/span&gt;&lt;span&gt; += &lt;/span&gt;&lt;span&gt;window&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;$ONE_STEP&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;break&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span&gt;// down&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      default&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;break&lt;/span&gt;&lt;span&gt;;                                       &lt;/span&gt;&lt;span&gt;// stay&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That last property is what made the result convincing to me. Any competence that emerges came from selection, not from logic I wrote.&lt;/p&gt;
&lt;h2&gt;Concept 2: Fitness — the only thing you’re allowed to tell it&lt;a href=&quot;#concept-2-fitness--the-only-thing-youre-allowed-to-tell-it&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The fitness function is your entire communication channel with the algorithm. It isn’t a hint or a heuristic; it’s the definition of “better,” and evolution optimises exactly what you write, including the parts you didn’t mean.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;calculateFitness&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;reachedGoal&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;fitness&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;1.0&lt;/span&gt;&lt;span&gt; / (&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;brain&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;step&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;brain&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;step&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    } &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      var&lt;/span&gt;&lt;span&gt; distanceToGoal&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;calculateDistance&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionX&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionY&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;30&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;fitness&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;1.0&lt;/span&gt;&lt;span&gt; / &lt;/span&gt;&lt;span&gt;distanceToGoal&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two regimes, and the structure matters more than the formulas:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Not there yet: &lt;code&gt;1/distance&lt;/code&gt;.&lt;/strong&gt; Closer is better, and inverting distance turns “minimise” into “maximise” while compressing the scale — halving your distance doubles your score, so improvements near the goal count for far more than the same absolute gain far away.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Arrived: &lt;code&gt;1 + 1/steps²&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;1 +&lt;/code&gt; guarantees &lt;em&gt;any&lt;/em&gt; dot that reaches the goal outscores &lt;em&gt;every&lt;/em&gt; dot that didn’t, no matter how close the near-miss got. That hard tier boundary encodes a priority: first solve it, then optimise it. Inside the winners’ tier, &lt;code&gt;1/steps²&lt;/code&gt; rewards doing it faster, and squaring makes that pressure sharp.&lt;/p&gt;
&lt;p&gt;This shape — &lt;strong&gt;a discontinuous jump between “task achieved” and “not achieved,” with a smooth gradient inside each tier&lt;/strong&gt; — is the most reusable thing I took from the project. The smooth part gives the population a slope to climb when nobody has succeeded yet; the jump makes sure partial credit never outranks real success.&lt;/p&gt;
&lt;p&gt;The gradient part is not optional. If fitness were simply “1 if you reached the goal, else 0,” the first generation would be all zeroes, selection would have nothing to prefer, and the search would never start. Distance gives evolution something to work with before anyone has won. Designing that intermediate signal is usually the hard part of applying a GA to a real problem, and it’s where I’d expect to spend my time on the next one.&lt;/p&gt;
&lt;h2&gt;Concept 3: The generational loop&lt;a href=&quot;#concept-3-the-generational-loop&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Evolution is four phases repeated. Run every candidate until it can’t continue; score them; build a new population from the survivors; perturb it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (!&lt;/span&gt;&lt;span&gt;population&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;allDotsDead&lt;/span&gt;&lt;span&gt;() &amp;amp;&amp;amp; !&lt;/span&gt;&lt;span&gt;population&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;complete&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    population&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;update&lt;/span&gt;&lt;span&gt;();               &lt;/span&gt;&lt;span&gt;// 1. simulate this generation&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    population&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;calculateFitness&lt;/span&gt;&lt;span&gt;();     &lt;/span&gt;&lt;span&gt;// 2. score everyone&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    population&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;naturalSelection&lt;/span&gt;&lt;span&gt;();     &lt;/span&gt;&lt;span&gt;// 3. breed the next generation&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    population&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;mutateBabies&lt;/span&gt;&lt;span&gt;();         &lt;/span&gt;&lt;span&gt;// 4. introduce variation&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    population&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;show&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    population&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;complete&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A generation ends when every dot is either dead (out of bounds or out of moves) or has arrived:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;update&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (!&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;dead&lt;/span&gt;&lt;span&gt; &amp;amp;&amp;amp; !&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;reachedGoal&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;move&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionX&lt;/span&gt;&lt;span&gt; &amp;lt;= &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt; || &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionY&lt;/span&gt;&lt;span&gt; &amp;lt;= &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt; ||&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionX&lt;/span&gt;&lt;span&gt; &amp;gt;= &lt;/span&gt;&lt;span&gt;198&lt;/span&gt;&lt;span&gt; || &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionY&lt;/span&gt;&lt;span&gt; &amp;gt;= &lt;/span&gt;&lt;span&gt;398&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;dead&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      } &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; if&lt;/span&gt;&lt;span&gt; ((&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionX&lt;/span&gt;&lt;span&gt; &amp;gt; &lt;/span&gt;&lt;span&gt;90&lt;/span&gt;&lt;span&gt; &amp;amp;&amp;amp; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionX&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;105&lt;/span&gt;&lt;span&gt;) &amp;amp;&amp;amp;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                 (&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionY&lt;/span&gt;&lt;span&gt; &amp;gt; &lt;/span&gt;&lt;span&gt;20&lt;/span&gt;&lt;span&gt; &amp;amp;&amp;amp; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;positionY&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;35&lt;/span&gt;&lt;span&gt;)) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;reachedGoal&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Hitting a wall is fatal, which means the constraint is expressed through &lt;strong&gt;mortality&lt;/strong&gt; rather than through the fitness function — an obstacle doesn’t reduce your score, it truncates your run, freezing your position at the wall and therefore your distance. I like this more than I expected to: constraints implemented as early termination avoid the penalty-weight tuning that constraint terms in a fitness function usually drag in.&lt;/p&gt;
&lt;h2&gt;Concept 4: Mutation is the only source of novelty&lt;a href=&quot;#concept-4-mutation-is-the-only-source-of-novelty&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Copying survivors preserves what works. Mutation invents anything new:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;mutate&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; i&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;directions&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      var&lt;/span&gt;&lt;span&gt; rand&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;random&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;rand&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;mutationRate&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;directions&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;] = &lt;/span&gt;&lt;span&gt;getRndInteger&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each gene independently has a &lt;code&gt;mutationRate&lt;/code&gt; chance of being redrawn. The rate is the central tuning knob, and both extremes fail in opposite ways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Too low&lt;/strong&gt; → offspring are near-copies, the population converges on a local optimum and stops improving.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Too high&lt;/strong&gt; → good genomes get scrambled faster than selection can consolidate them, and the search degenerates into random guessing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I exposed it as a live input in the UI, along with population size, genome length and step length, which turned out to be the best decision in the project — you can watch convergence stall at 2% and watch it thrash at 80%. My default is 30%, which is high next to the conventional 1–5%, and the reason is directly tied to the selection design in the next section.&lt;/p&gt;
&lt;h2&gt;Concept 5: Elitism — don’t lose the best thing you found&lt;a href=&quot;#concept-5-elitism--dont-lose-the-best-thing-you-found&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Because reproduction is random, the best solution in a generation can fail to be reproduced accurately and the population gets &lt;em&gt;worse&lt;/em&gt;. &lt;strong&gt;Elitism&lt;/strong&gt; fixes that: copy the champion into the next generation unmutated.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;newDots&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;] = &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;dotList&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;bestDotIndex&lt;/span&gt;&lt;span&gt;].&lt;/span&gt;&lt;span&gt;getBaby&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;newDots&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;].&lt;/span&gt;&lt;span&gt;isBest&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;bestDot&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;dotList&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;bestDotIndex&lt;/span&gt;&lt;span&gt;];&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;mutateBabies&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;dotList&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;forEach&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;dot&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      if&lt;/span&gt;&lt;span&gt; (!&lt;/span&gt;&lt;span&gt;dot&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;isBest&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        dot&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;brain&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;mutate&lt;/span&gt;&lt;span&gt;();     &lt;/span&gt;&lt;span&gt;// the elite is deliberately skipped&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This guarantees monotonic improvement: best-ever fitness can never decrease. Cheap insurance, and nearly every practical GA includes some form of it. The elite is also rendered in a distinct colour, so you can watch the current champion’s path against the cloud of its mutated descendants — the clearest visual in the whole simulation, and the thing that made the mechanism obvious to me.&lt;/p&gt;
&lt;h2&gt;Design note: what my selection strategy is, and what it costs&lt;a href=&quot;#design-note-what-my-selection-strategy-is-and-what-it-costs&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Here’s the part I find most interesting in hindsight. Parents are chosen like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;selectParent&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;dotList&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;bestDotIndex&lt;/span&gt;&lt;span&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every child descends from the single best dot. Meanwhile the code computes a total fitness across the population:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;calculateFitnessSum&lt;/span&gt;&lt;span&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;fitnessSum&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; i&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt; &amp;lt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;dotList&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;++) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;fitnessSum&lt;/span&gt;&lt;span&gt; += &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;dotList&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;i&lt;/span&gt;&lt;span&gt;].&lt;/span&gt;&lt;span&gt;fitness&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A fitness &lt;em&gt;sum&lt;/em&gt; has one classical purpose: &lt;strong&gt;fitness-proportionate selection&lt;/strong&gt;, better known as roulette wheel. Pick a random number in &lt;code&gt;[0, fitnessSum)&lt;/code&gt;, walk the population accumulating fitness, take whoever you land on. A dot with twice the fitness is twice as likely to be a parent, but a mediocre dot still gets a chance. I built the odometer while working out how selection should behave, and shipped the simpler strategy — pure elitist selection — because it was enough to solve the maze.&lt;/p&gt;
&lt;p&gt;What I have, then, is closer to &lt;strong&gt;parallel hill climbing&lt;/strong&gt;: one parent, cloned a thousand times, each copy randomly perturbed, keep the best, repeat. It works, and having both designs side by side in my head is what finally explained what proportionate selection contributes:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Diversity as insurance against local optima.&lt;/strong&gt; With one parent, the whole population sits in one neighbourhood of the search space. If that neighbourhood is a dead end — a path hugging a wall that can’t be extended without dying — the only escape is a lucky mutation from that single lineage. With proportionate selection the second- and fifth- and fiftieth-best dots also reproduce, so the population explores several regions at once, and one of them may route around the obstacle that trapped the leader.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ancestral variety, which is what crossover needs.&lt;/strong&gt; Full GAs also &lt;em&gt;combine&lt;/em&gt; parents: first half of one genome, second half of another. That only helps if the parents are meaningfully different. Descend everyone from one ancestor and crossover has nothing to mix — which is why there’s no crossover in this version. The two choices are linked, and I only saw that after the fact.&lt;/p&gt;
&lt;p&gt;It also explains my 30% mutation rate. With a population collapsed to one lineage, mutation is the &lt;em&gt;only&lt;/em&gt; source of diversity, so it has to carry a load that selection would normally share. That gave me the sharpest lesson of the project: &lt;strong&gt;a genetic algorithm’s power comes from maintaining a diverse population, not from mutation volume.&lt;/strong&gt; Adding roulette-wheel selection and single-point crossover — both small changes, and the sum is already computed — should let the mutation rate drop by an order of magnitude while searching better, because variation would come from having many good ideas instead of from repeatedly perturbing one. That’s the next iteration.&lt;/p&gt;
&lt;h2&gt;When to reach for this&lt;a href=&quot;#when-to-reach-for-this&quot;&gt;#&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Evolutionary search fits when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You can &lt;strong&gt;evaluate&lt;/strong&gt; a candidate but not &lt;strong&gt;derive&lt;/strong&gt; the best one.&lt;/li&gt;
&lt;li&gt;There’s no usable gradient — the space is discrete, or the objective isn’t differentiable.&lt;/li&gt;
&lt;li&gt;Very good is acceptable; provably optimal isn’t required.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And it’s the wrong tool when a gradient exists (use it — it’s vastly faster), when evaluation is expensive (thousands of candidates × many generations gets brutal), or when the problem has structure a purpose-built algorithm exploits.&lt;/p&gt;
&lt;p&gt;What made this worth building wasn’t the maze getting solved. It was watching a thousand dots spray outward in generation one and, by generation forty, trace a coherent path to a goal none of them could see — carrying nothing but a list of 500 random numbers, filtered by the only feedback they ever got.&lt;/p&gt;</content:encoded><category>Algorithms</category><category>JavaScript</category><category>Algorithms</category><category>Simulation</category></item></channel></rss>