Building a difficulty ladder generator

How python_pkg/byox_ladder turns the build-your-own-x README into two ranked, offline HTML pages.

commit 6342eaf · testsAndMisc · 2026-07-17

1 · Background

build-your-own-x is a hugely popular README: a curated list of ~360 step-by-step tutorials for re-creating technologies from scratch — a 3D renderer, a Git, a database, an operating system. The README groups guides under 30 category headings like #### Build your own `Regex Engine`, and each guide is one bullet:

* [**Go**: _Building a BitTorrent client from the ground up_](https://blog.jse.li/posts/torrent/)

The task was to sort all of this from easiest to hardest. This commit adds a small, self-contained tool that does exactly that and emits two browsable pages: a category ladder (the 30 headings) and a guide ladder (every individual tutorial), each grouped into four difficulty tiers — Beginner, Intermediate, Advanced, Expert — plus an Unsorted bucket.

It lives in testsAndMisc, a monorepo with strict gates: all Python must sit under python_pkg/, hit 100% branch coverage, and pass ruff (select=ALL), mypy, pylint 10/10, and bandit. Those gates shaped the design as much as the feature did — more on that in the Code section.

2 · Intuition

The heart of the change is one question: how do you assign a difficulty to a single guide? The naïve answer — give every guide a unique rank from 1 to 360 — is a trap. There's no honest signal that says tutorial #147 is harder than #148. So the design deliberately refuses that false precision and uses a two-part rule instead:

1 · CategoryThe guide's category sets its tier and a base rank (1–30). This is the dominant signal.
→
2 · LanguageThe language adds a tiny nudge (0.2–0.9). Lower-level languages nudge higher.
→
3 · Scorerank×10 + nudge. Sorting by score keeps whole tiers contiguous.

Because the nudge is always under 1 and the tier gap is 10 per rank, language can reorder guides inside a tier but can never move one across a tier line. An operating-system guide is Expert whether it's written in C or Python; the language just decides which OS guide you might reach for first.

Score bands. Rank 1–5 → Beginner, 6–12 → Intermediate, 13–22 → Advanced, 23–30 → Expert.

Play with the rule

Pick a category and a language. Watch the score, the tier, and where this guide lands among the other languages in the same category. Notice the tier badge only changes when the category changes.

score = …
Tier: —

Within-category order (by score, easiest first)

    This widget runs the real scoring logic from parse_guides.py — the same CATEGORIES and LANG_EFFORT tables, the same min()-of-languages nudge, the same rank×10 + nudge score.

    3 · Code

    a · The scoring transform — parse_guides.py

    The parser walks the README line by line, tracking the current category header, and turns each guide bullet into a record. Two small pure functions carry the difficulty model:

    def lang_effort(langs):
        # the EASIEST listed language wins — you'd pick your best one
        values = [LANG_EFFORT.get(l.lower(), DEFAULT_EFFORT) for l in langs]
        return min(values) if values else DEFAULT_EFFORT
    
    # inside parse_guide_line(...):
    rank, tier = CATEGORIES.get(category, (UNSORTED_RANK, "unsorted"))
    score = round(rank * 10 + lang_effort(langs), 2)

    A guide with an unknown category falls through to UNSORTED_RANK = 99 and the "unsorted" tier — that's how the repo's own "Uncategorized" bucket is kept honest instead of being force-fit into a difficulty tier.

    b · From data to a runnable page — build_pages.py

    The guide page is a template with a placeholder. The builder inlines the parsed data as a JS constant and wraps everything in a minimal HTML skeleton so the file opens straight from disk:

    payload = json.dumps(guides, separators=(",", ":")).replace("</", "<\\/")
    content = template.replace(MARKER, f"const GUIDES = {payload};")
    return standalone(content)   # adds <!doctype html>…</html>

    The replace("</", "<\\/") guard matters: the data is dropped inside a <script> tag, and any literal </script> in a tutorial title would otherwise close the block early. Everything is inlined — no fonts, no CDN, no network — so the page works offline and in your OS light/dark theme.

    c · Reproducible, not hand-built

    A Makefile chains the pipeline; the fetched README and guides.json are gitignored derived artifacts, while the built *-ladder.html pages are committed as the runnable output. Re-running make build reproduces byte-identical HTML — the sign of a deterministic build.

    build: guides.json
    	python3 build_pages.py
    guides.json: byox-readme.md parse_guides.py
    	python3 parse_guides.py
    byox-readme.md:
    	curl -fsSL "$(README_URL)" -o byox-readme.md

    d · Tests to 100% branch coverage

    Because the code lives under python_pkg/, the repo's fail_under=100 gate applies. The two functions with branches — lang_effort (empty vs non-empty) and the parse/build error paths — are each exercised from both sides. lang_effort([]) is called directly to cover the else branch that normal input can't reach.

    e · Governance artifacts

    The monorepo rejects any code commit that lacks a validated evidence JSON (intent, scope, verification with real command output, rollback), and a contract JSON once a change spans four or more code files. One line was also added to .pre-commit-config.yaml to exclude the generated guide page from codespell, since it inlines upstream titles verbatim — including a genuine spelling slip carried over from one of the upstream tutorial titles.

    4 · Quiz

    Five questions on the reasoning behind the change. Click to reveal.

    Score: 0 / 0
    Generated by /explain-diff · adapts Geoffrey Litt's "understanding is the bottleneck" idea. Diagrams and the widget run the change's real logic.