This commit is contained in:
parent
f5f017b01f
commit
fcec028c74
26 changed files with 46 additions and 115 deletions
|
|
@ -1,10 +1,8 @@
|
|||
# schmelczer.dev
|
||||
|
||||
A static personal blog for Andras Schmelczer, built with Astro.
|
||||
Engineering writeups by Andras Schmelczer: finished projects with the design constraints left in. Built with Astro, no required client JavaScript.
|
||||
|
||||
The site is article-first: articles live in `src/content/posts`, project index entries
|
||||
live in `src/content/projects`, and normal pages are rendered as static HTML with no
|
||||
required client JavaScript.
|
||||
Articles live in `src/content/posts`, project index entries in `src/content/projects`, and normal pages are rendered as static HTML.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
|
|||
|
|
@ -26,17 +26,9 @@ media:
|
|||
transcript: No spoken dialogue. The handheld board runs its OLED game; the player moves through the small display while the IR input controls gameplay.
|
||||
---
|
||||
|
||||
**The short version:**
|
||||
I'd done microcontroller work on dev boards before and it always felt like I was renting the hardware. As soon as I had a real board with my own soldering on it, bugs stopped feeling like software inconveniences and started feeling like consequences of choices I'd made in KiCad. That shift was most of the value of doing it this way. Four years on from [my first hardware project](/articles/lights-synchronized-to-music/), the lesson was that owning the whole stack down to the copper changes how you debug.
|
||||
|
||||
- A handheld game built from the PCB up: ATtiny85V, 0.96" SPI OLED, TSOP4838 IR receiver, 3.3V regulator, battery.
|
||||
- 8-bit ALU at 8 MHz. Every byte and every cycle has a price tag, and that's the whole point.
|
||||
- 15–20 ms peak frame time, so gameplay never drops below 50 FPS. Power draw ~31 mW peak, ~1.5 mA standby.
|
||||
|
||||
## Why the PCB changed the project
|
||||
|
||||
I'd done microcontroller work on dev boards before and it always felt like I was renting the hardware. As soon as I had a real board with my soldering on it, bugs stopped feeling like software inconveniences and started feeling like consequences of choices I'd made in KiCad. That shift was most of the value of doing it this way.
|
||||
|
||||
The constraint that mattered: an 8-bit ALU at 8 MHz, with no FPU, no SIMD instruction set, and 8 KB of flash. Anything I built had to fit inside that, or I'd be staring at a brick.
|
||||
This one is a handheld game built from the PCB up around an ATtiny85V: 8-bit ALU at 8 MHz, no FPU, no SIMD, 8 KB of flash. Anything I built had to fit inside that, or I'd be staring at a brick.
|
||||
|
||||
## The bits worth showing
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,4 @@ outcome: My first browser game; kept for the timeline
|
|||
audience: general
|
||||
---
|
||||
|
||||
The first browser game I wrote. It's not good, but it was the moment a `<canvas>` element stopped being mysterious. Keeping it here because pretending the older work didn't happen would be dishonest.
|
||||
Keeping it here because pretending the older work didn't happen would be dishonest. The first browser game I wrote, January 2018. It isn't good, but it was the moment a `<canvas>` element stopped being mysterious.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
title: Backing Up Running Databases Without Stopping Them
|
||||
description: A Bash container around BorgBackup. BTRFS snapshots give atomic consistency, numeric env vars give multi-target 3-2-1, the loop is sleep not cron.
|
||||
date: 2026-05-29
|
||||
draft: true
|
||||
projectPeriod: '2024-2026'
|
||||
thumbnail:
|
||||
src: ./_assets/backup.png
|
||||
|
|
@ -20,11 +19,7 @@ links:
|
|||
url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container
|
||||
---
|
||||
|
||||
**The short version:**
|
||||
|
||||
- One Alpine container, ~75 lines of Bash, that snapshots a BTRFS volume and pushes the snapshot to one or more [Borg](https://borgbackup.readthedocs.io/) repositories on a fixed interval. The snapshot is the only thing standing between "consistent backup" and "corrupt database in the archive."
|
||||
- Multi-target via numeric env vars (`BORG_REPO_0`, `BORG_REPO_1`, ...). The wrapper iterates until the next index isn't set. No config format, no DSL; the env file is the configuration.
|
||||
- Two years of self-hosting, multiple restored incidents, zero data loss I noticed.
|
||||
Once you self-host a few services with live databases, the backup question stops being theoretical. A Postgres or SQLite file half-written when `tar` reads it goes into the archive in a state nothing on Earth will replay; you just don't find out until the restore. Two years in, with multiple incidents I had to actually recover from (including the photos behind the [e-ink frame](/articles/frame-eink-photo-display/)), I trust this stack precisely because the correctness argument is short: BTRFS gives me an atomic snapshot, and everything above it can be a shell script. One Alpine container, ~75 lines of Bash, pushes that snapshot to one or more [Borg](https://borgbackup.readthedocs.io/) repositories on a fixed interval. Multi-target is numeric env vars (`BORG_REPO_0`, `BORG_REPO_1`, ...). No config format, no DSL; the env file is the configuration.
|
||||
|
||||
## The problem the snapshot solves
|
||||
|
||||
|
|
|
|||
|
|
@ -14,12 +14,8 @@ audience: technical
|
|||
links: []
|
||||
---
|
||||
|
||||
A small city in Unity where the traffic lights were driven by a REST API. Contestants in a PLC cybersecurity event would write control logic; bad logic made cars crash, and they'd watch it happen.
|
||||
Most security challenges punish wrong answers with a red "incorrect." This one punished them with car wrecks, and people learned faster. A PLC cybersecurity event in the summer of 2018 needed something visceral; I built a small Unity city where the traffic lights were driven by a REST API and contestants wrote the control logic.
|
||||
|
||||
Three things are worth saying about it:
|
||||
All decisions ran on the server and got broadcast to clients. The harder problem wasn't the simulation; it was making the broadcast fault-tolerant on conference Wi-Fi without flooding it. I built it solo, including the models and animations in Blender. Not a flex, just context for why everything's a little janky.
|
||||
|
||||
- **Visual feedback was the whole point.** Most security challenges punish wrong answers with a red "incorrect." This one punished them with car wrecks, and people learned faster.
|
||||
- **Server-client, all decisions on the server.** Every agent's behaviour was computed centrally and broadcast. The harder problem wasn't simulation; it was getting the broadcast to be fault-tolerant on the conference Wi-Fi without flooding it.
|
||||
- **Built it solo, including the models and animations in Blender.** Not a flex, just context for why everything's a little janky.
|
||||
|
||||
There was also a HUD overlay for tweets, which felt clever at the time and dated horribly. Skip that part.
|
||||
There was also a HUD overlay for tweets. It felt clever at the time and dated horribly. Skip that part.
|
||||
|
|
|
|||
|
|
@ -25,11 +25,7 @@ media:
|
|||
caption: A real game loop is a worse audience than a tech demo. That's the point.
|
||||
---
|
||||
|
||||
**The short version:**
|
||||
|
||||
- Conquest-style space shooter. Two teams, small planets, points for control, ray-traced 2D rendering.
|
||||
- Built on top of [SDF-2D](/articles/sdf-2d-ray-tracing/), partly to prove the renderer worked outside a thesis demo.
|
||||
- The architecture decision worth remembering: one TypeScript module containing the game rules, linked by both the Node server and the browser client. Same code, both sides of the wire.
|
||||
My thesis was a renderer; proving it in a real multiplayer loop was the point. A real game loop is a worse audience than a tech demo. That's the point. So through autumn 2020 I built decla.red on top of [SDF-2D](/articles/sdf-2d-ray-tracing/): a conquest-style space shooter, two teams, small planets, ray-traced 2D rendering, browser and mobile. The architecture decision worth remembering came out of needing the server and the client to stop lying to each other: one TypeScript module containing the game rules, linked by both sides of the wire.
|
||||
|
||||
## The split that usually goes wrong
|
||||
|
||||
|
|
|
|||
|
|
@ -30,4 +30,4 @@ What I'd change if I were starting it now:
|
|||
- **Markdown source, not a hand-edited JSON file.** Editing questions in JSON is fine until you forget a comma at 1am and the site stops loading.
|
||||
- **A real licence note on the question text.** The papers are public exam material, but it's worth saying so somewhere on the page.
|
||||
|
||||
It's been online in some form for eight years. Every spring I get a few emails from students asking whether I'll add the latest year's paper. I usually do, eventually. That's a feedback loop I didn't design for and don't want to lose.
|
||||
It's been online in some form for eight years. Every spring I get a few emails from students asking whether I'll add the latest year's paper. I usually do, eventually. The thing I made for myself in 2017 is still doing its job for someone else's last year of high school, and that's the only metric on it I actually care about.
|
||||
|
|
|
|||
|
|
@ -25,11 +25,7 @@ media:
|
|||
caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone.
|
||||
---
|
||||
|
||||
**The three-bullet pitch:**
|
||||
|
||||
- Draw a stroke; agents spawn along it, follow the trail, and slowly overwrite the patch you laid down. You shape the gesture, the garden owns the response.
|
||||
- Six "vibes" share the same simulation. The personality of each one lives in a 3×3 matrix of nine numbers in `{-1, 0, 1}`. That's the entire behaviour rule.
|
||||
- One static HTML file. `vite build`, `rsync`, done. Runs offline, can be emailed, no account, no save state.
|
||||
Nine numbers in `{-1, 0, 1}` arranged in a 3×3 matrix decide an entire vibe's personality. That constraint is what kept me up: proving simplicity can be expressive, that you don't need a behaviour function per preset. A WebGPU drawing toy where you stroke a colour, agents spawn along it, and the garden slowly overwrites the patch you laid down. One static HTML file, six compute stages, none of them skippable.
|
||||
|
||||
## Why physarum needed a knob
|
||||
|
||||
|
|
@ -54,7 +50,7 @@ Adding a tenth number to the matrix would tax every existing vibe. Tuning the ni
|
|||
Six stages, ten WGSL files, each one short enough that I can hold it in my head when something breaks:
|
||||
|
||||
1. **Agent step:** sample the trail at a sensor offset, pick a turn, move, deposit colour. ~300 lines, the longest one.
|
||||
2. **Diffusion:** blur and decay so old marks soften.
|
||||
2. **Diffusion:** blur and decay so old marks soften. The boring one, and the one you can't skip: without it, strokes stay forever and the garden collapses into noise.
|
||||
3. **Brush:** write user strokes into both the trail texture and a separate "source" texture the agents can read.
|
||||
4. **Eraser:** two variants: one clears a region of the trail, the other kills agents in a radius.
|
||||
5. **Agent generation:** spawn along strokes, resize the buffer when the cap changes, compact after erasure so dead slots don't waste GPU time.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ audience: technical
|
|||
links: []
|
||||
---
|
||||
|
||||
A linear regression in the frequency domain, dressed up. Old portfolio screenshots showed the prediction (blue) tracking the actual rate (green) closely enough to be flattering. I did not trade real money with it.
|
||||
In the autumn of 2019 I was an undergrad with a few weekends free and the quiet conviction that I could find a small edge on EUR/USD. The screenshots were flattering: the prediction (blue) hugged the actual rate (green) in a way that looked like skill. A linear regression in the frequency domain, dressed up. I did not trade real money with it, and that restraint is the only thing about the project that aged well.
|
||||
|
||||
The pipeline:
|
||||
|
||||
|
|
|
|||
|
|
@ -31,11 +31,9 @@ media:
|
|||
caption: The bottom corners carry the photo's age and EXIF location. Painted as text on top, so the dither can't smear them.
|
||||
---
|
||||
|
||||
**The short version:**
|
||||
In 2024, researchers found family-blog photos of Brazilian children inside the LAION training set. Self-hosting your photos used to be a preference; it's a safeguarding decision now. Nixplay's cloud-tied frames have bricked. Funimation deleted libraries people had paid for. I wanted a photo frame on the hallway wall, and I wasn't going to hand the family album to a vendor who could close the doors on it.
|
||||
|
||||
- A Raspberry Pi Zero 2W drives Waveshare's [PhotoPainter](https://www.waveshare.com/wiki/PhotoPainter), a 7.3" 6-colour ACeP e-ink panel. Cron fires every 15 minutes; if [Home Assistant](https://www.home-assistant.io/) says the house is empty (or it's between midnight and 7am), the script quits.
|
||||
- Photo source is my self-hosted [Immich](https://immich.app/) library. The pool is weighted toward "on this day," favourites, and recent uploads, with a 7-day rolling history to avoid repeats.
|
||||
- Each accepted candidate is face-aware cropped, contrast and saturation boosted (e-ink lacks both), Atkinson-dithered to the 6-colour palette, then labelled with capture age and EXIF location before pushing. A few hundred lines of stdlib Python on top of Waveshare's reference driver.
|
||||
So it's a Raspberry Pi Zero 2W driving Waveshare's [PhotoPainter](https://www.waveshare.com/wiki/PhotoPainter) panel, pulling from my self-hosted [Immich](https://immich.app/) library, part of the same [self-hosting setup I back up with btrfs and borg](/articles/backup-container-btrfs-borg/). A few hundred lines of stdlib Python on top of the reference driver.
|
||||
|
||||
## Why a stupid amount of engineering for a picture on a wall
|
||||
|
||||
|
|
@ -67,15 +65,11 @@ The dither is where the choice visibly matters. The panel can only show black, w
|
|||
|
||||
Hundred Rabbits, a couple who live offshore on a sailboat doing permacomputing in practice, hold themselves to a rule: any system they depend on should be reimplementable in a weekend. The frame meets the bar. A few hundred lines of stdlib Python on a documented panel, reading from an HTTP endpoint that returns JPEGs. It came together over an afternoon with Claude Code plus a couple of weekends tuning the picker and the dither; the repo is public partly as a reference for anyone wanting to do something similar. If Immich disappears tomorrow the selection logic is eighty lines I can repoint at whatever replaces it.
|
||||
|
||||
This stopped being hobbyist territory around 2024, when researchers found family-blog photos of Brazilian children inside the LAION training set. Self-hosting your photos used to be a preference; it's becoming a safeguarding decision. Don't ask whether the hassle is worth it now. Ask what state you'd be in if any one of your platforms went dark, and notice that this isn't a hypothetical. Nixplay's cloud-tied frames have bricked. Funimation deleted libraries people had paid for. The parenthetical in _useless when the company closes its doors_ does the whole argument's work.
|
||||
|
||||
## Smaller calls
|
||||
|
||||
- **Capture age and EXIF location painted as text.** White on a black stroke, written _after_ dithering, so the labels stay sharp on the 6-colour palette.
|
||||
- **CLI flags for the awkward photos.** `--album`, `--people`, `-o 90` (portrait), and `--saturation`/`--contrast`/`--gamma` are flags on the cron command. The defaults are tuned for the average photo; the flags exist for the few that aren't.
|
||||
- **`flock` around the render.** A slow refresh can't overlap the next 15-minute tick.
|
||||
- **Wifi power-save reconnect job.** The Pi Zero 2W's wifi drops if power-save kicks in. A separate `wifi-check.sh` every five minutes brings it back.
|
||||
- **Swap masked, journald volatile.** The SD card is the most likely thing to die on this build. Don't write to it unless you have to.
|
||||
- **Wifi power-save reconnect job.** The Pi Zero 2W's wifi drops if power-save kicks in. A separate `wifi-check.sh` every five minutes brings it back.
|
||||
|
||||
## What I'd change
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ audience: technical
|
|||
links: []
|
||||
---
|
||||
|
||||
The input-side companion to the [cooling system sim](/articles/nuclear-cooling-simulation/). A JavaFX desktop tool where you'd lay out the plant as a graph, edit each element's parameters in a side panel, export JSON, or upload directly to the simulator backend.
|
||||
Non-technical event organisers needed to rewire a cooling plant in real time without me hovering. That was the brief, and it ruled out every interface I'd have enjoyed writing. The [cooling system sim](/articles/nuclear-cooling-simulation/) was only as useful as the tool that fed it, so in late 2018 I built a JavaFX desktop editor: lay out the plant as a graph, edit each element's parameters in a side panel, export JSON, or upload straight to the backend.
|
||||
|
||||
It was small but it punched above its weight at the actual event. The simulator's value depended entirely on the organisers being able to change the plant without me sitting next to them. The editor was what made that possible.
|
||||
|
||||
If I built it again I'd skip JavaFX and put the editor in the browser, the same place the monitoring clients lived. One install fewer for everyone.
|
||||
Small tool, and the whole event hinged on it. If I built it again I'd skip JavaFX and put the editor in the browser next to the monitoring clients. One install fewer for everyone, and one fewer reason for someone to call me over.
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@ media:
|
|||
caption: A working GreatAI service is about ten lines on top of a plain prediction function.
|
||||
---
|
||||
|
||||
**The short version:**
|
||||
|
||||
- I surveyed working data scientists and engineers on a catalogue of 33 ML deployment habits. The honest finding: people skip habits not because they disagree, but because adopting one means writing five lines of glue, and they have a deadline.
|
||||
- That's a design problem, not a discipline problem. The thesis built a Python framework around one bet: put the deployment behaviour next to the prediction function with a single decorator, and the right thing becomes the path of least resistance.
|
||||
- Pip-installable as `great-ai`. Worth a look mainly for the survey methodology behind it.
|
||||
By the end of 2021 I had stopped believing the people skipping ML deployment best practices were the problem. They knew the list. They agreed with the list. They had a deadline, and every item on the list cost five lines of glue. My MSc thesis turned that into the actual research question: not "what should engineers do" but "what API shape makes doing the right thing cheaper than not." The framework that fell out, `great-ai`, is a decorator on a plain Python function. The thesis behind it is the part worth reading.
|
||||
|
||||
## The thing nobody wants to admit
|
||||
|
||||
|
|
|
|||
|
|
@ -23,11 +23,7 @@ media:
|
|||
caption: The interface was a 2019 weekend experiment. The trie underneath aged better.
|
||||
---
|
||||
|
||||
**The short version:**
|
||||
|
||||
- Built as a personal task tracker with a tower metaphor. The UI was the fun bit; the sync model is the part I'd still write the same way today.
|
||||
- Clients and server hold the same data as an immutable trie. Comparing two versions is a walk over shared nodes. Sending the delta is sending only the nodes that aren't shared.
|
||||
- No CRDT, no OT, no clever conflict resolution. The structure does the work, the protocol stays boring.
|
||||
In August 2019 I wanted a goal tracker I'd actually open, on whichever device was nearest, without watching it disagree with itself. Nothing off the shelf fit, so I built one over a couple of weekends. The tower metaphor was the part friends saw; the part that aged well was the sync model that fell out of needing the same state in three places at once.
|
||||
|
||||
## The problem in one paragraph
|
||||
|
||||
|
|
|
|||
|
|
@ -14,12 +14,10 @@ audience: technical
|
|||
links: []
|
||||
---
|
||||
|
||||
Spring 2016. I had a Raspberry Pi, a couple of 12V RGB LED strips someone had given me, a handful of MOSFETs from an electronics kit, and zero idea what I was doing. The plan was something like: play music, look at it, make the lights match.
|
||||
Spring 2016. I had a Raspberry Pi, a couple of 12V RGB LED strips someone had given me, a handful of MOSFETs from an electronics kit, and zero idea what I was doing. I wired one of the MOSFETs backwards and it got hot enough to leave a small mark on the breadboard. I learned to read a datasheet, slowly, by needing one. This was the first thing I started and actually finished.
|
||||
|
||||
I got bands wrong first. I tried mapping raw audio amplitude to brightness, which made the lights pulse with anything: clipping, voice, fan noise. It produced a strobing mess that hurt to look at. Reading about Fourier transforms long enough to type `numpy.fft.fft(audio_chunk)` into a REPL was the moment the project started actually behaving like the thing I'd imagined. Bass-heavy frequency bins went to red; mids to green; highs to blue. Smoothing the output over a few frames stopped the seizure-inducing flicker.
|
||||
|
||||
The MOSFETs took longer than they should have. I wired one backwards and it got hot enough to leave a small mark on the breadboard. I learned to read a datasheet, slowly, by needing one.
|
||||
The plan was something like: play music, look at it, make the lights match. I got bands wrong first. Mapping raw audio amplitude to brightness made the lights pulse with anything (clipping, voice, fan noise), a strobing mess that hurt to look at. Reading about Fourier transforms long enough to type `numpy.fft.fft(audio_chunk)` into a REPL was the moment the project started actually behaving like the thing I'd imagined. Bass-heavy frequency bins went to red; mids to green; highs to blue. Smoothing the output over a few frames stopped the seizure-inducing flicker.
|
||||
|
||||
The frontend was a vanilla web page on the same Pi: pick a track, tweak the band thresholds, see what changed. No framework. Just a `<select>`, a few sliders, and an `XMLHttpRequest`. It worked.
|
||||
|
||||
It's not impressive in 2026. The thing I actually keep from it isn't the FFT or the MOSFETs; it's the discovery that I'd rather have a finished janky thing than an elegant unfinished one. Most of the projects on this site are downstream of that discovery. I'd still recommend the same path to anyone learning: pick something physical, plug things together until they work, accept that the first version will be ugly.
|
||||
It's not impressive in 2026. The thing I actually keep from it isn't the FFT or the MOSFETs; it's the discovery that I'd rather have a finished janky thing than an elegant unfinished one. Most of the projects on this site are downstream of that discovery; [the ATtiny85 handheld](/articles/ad-astra-attiny85-game-engine/) four years later is the same instinct with the soldering iron held steadier. I'd still recommend the same path to anyone learning: pick something physical, plug things together until they work, accept that the first version will be ugly.
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ links:
|
|||
url: https://github.com/schmelczer/my-notes
|
||||
---
|
||||
|
||||
A small Android app for writing Markdown notes and filtering them by hashtag. Built on top of Markwon for the rendering.
|
||||
In November 2019 I wrote my own notes app for Android, used it daily for a while, and then it lost a long battle with Obsidian. The loss was the lesson: I learned what I actually wanted from a notes app by watching mine fail to be it. Years later that same itch is why I wrote [reconcile-text](/articles/reconcile-text-3-way-merge/); by then I was editing the same notes in Vim, VS Code, and Obsidian, and nothing existed to merge three independently-edited copies back into one.
|
||||
|
||||
The idea wasn't original; every developer writes their own notes app eventually, and the bar for shipping one wasn't high. What I actually wanted from the project was a few weeks somewhere outside the web stack, in a platform with different conventions about lifecycle, storage, and resource constraints. Android delivered that.
|
||||
|
||||
I don't use the app anymore (it lost a long battle with Obsidian, which is also why I later wrote [reconcile-text](/articles/reconcile-text-3-way-merge/)). I'd still recommend "write a small thing on a new platform" as a way to recalibrate what you take for granted.
|
||||
The app itself was small: Markdown notes, hashtag filtering, Markwon for rendering. Every developer writes their own notes app eventually and the bar for shipping one isn't high. What I actually wanted was a few weeks outside the web stack, somewhere with different conventions about lifecycle, storage, and resource constraints. Android delivered that. I'd still recommend "write a small thing on a new platform" as a way to recalibrate what you take for granted.
|
||||
|
|
|
|||
|
|
@ -25,11 +25,7 @@ media:
|
|||
caption: The JavaFX editor produced JSON that the simulator ate as input.
|
||||
---
|
||||
|
||||
**The short version:**
|
||||
|
||||
- A cooling-system simulator with reactors, coolers, pumps, heat exchangers, drains, and pipes. Built for a cybersecurity event where contestants poked at PLCs and watched the consequences in real time.
|
||||
- Not physically accurate. Aimed at "cheap to compute, plausible to a non-specialist, runs all weekend on one server."
|
||||
- The useful design move was modelling flow and heat as **two separate graph passes**, not one combined PDE.
|
||||
Trying to solve flow and heat as a coupled system would have been a real CFD problem and I had two weeks. A cybersecurity event in late 2018 needed a cooling-system simulator that contestants could poke at through PLCs over a weekend, and the deadline shaped every decision after it: cheap to compute, plausible to a non-specialist, runs all weekend on one server. The useful design move was modelling flow and heat as **two separate graph passes**, not one combined PDE.
|
||||
|
||||
## What the event needed
|
||||
|
||||
|
|
@ -41,7 +37,7 @@ The challenge was about PLCs. Contestants would change setpoints, valves, or pum
|
|||
|
||||
## The split that made it cheap
|
||||
|
||||
Trying to solve flow and heat as a coupled system would have been a real CFD problem and I had two weeks. Instead:
|
||||
Instead of the coupled solver:
|
||||
|
||||
1. **Flow first, as graph traversal.** Walk the pipe graph from the pumps, accumulate pressure, distribute water to nodes.
|
||||
2. **Heat second, as a linear system.** Build the adjacency matrix from the flow result, add boundary conditions (heaters, exchangers, base temperatures), solve for node temperatures with NumPy.
|
||||
|
|
|
|||
|
|
@ -35,11 +35,7 @@ media:
|
|||
caption: A normal user pan triggers a hexagon aggregation under filter. The hot path holds itself to two u16 compares per row.
|
||||
---
|
||||
|
||||
**The short version:**
|
||||
|
||||
- One Rust binary (Axum, Polars, h3o, rayon) holds the entire UK property history in RAM: ~25M historical transactions, ~150 numeric features per row, plus postcode features, POIs, places, sparse travel-time matrices, and PMTiles. The whole resident set fits inside a VM you can rent.
|
||||
- The hot loop dictates the data layout. Every numeric feature is u16-quantised against a per-feature `(min, scale)`. Filter evaluation per row, per filter, is `raw != NAN_U16 && raw >= min_u16 && raw <= max_u16`: three integer compares, no floats, no decoding.
|
||||
- An H3 cell is precomputed per property at resolution 12. A CSR-laid-out 0.01°-cell grid handles bbox queries. Aggregation goes serial under 50,000 candidate rows and parallel above it.
|
||||
A user told me the map felt sluggish when they dragged it across Manchester with four filters on. They were right. The previous version round-tripped to a database, decoded floats, and lost the budget for a single pan inside the first filter. The rewrite is one Rust binary that holds the entire UK property history in RAM and treats every filter as three integer compares. Everything else in this post is the consequence of refusing to break that latency again.
|
||||
|
||||
## The constraint that shapes everything
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ audience: technical
|
|||
links: []
|
||||
---
|
||||
|
||||
A web-based photo grader I built to try one specific interaction idea: you pick colours from the image, and any transformation you apply spreads to other colours weighted by how far they are from your selection in colour space.
|
||||
In June 2018 I got tired of every grader I tried making me think in masks. I wanted to point at "this orange" in a photo from one of my [walks](/articles/photo-site-generator/), nudge it, and have the neighbouring reds and yellows come along by however much made sense. Distance in colour space, not a brush. So I built the proof.
|
||||
|
||||
The UI was a colour wheel where you'd click to drop a marker, drag to move it, click anywhere to add another. Each marker had its own settings. The transformations smoothly fell off with distance, so editing "this orange" subtly nudged the nearby reds and yellows without me ever having to think in masks.
|
||||
The UI was a colour wheel where you'd click to drop a marker, drag to move it, click anywhere to add another. Each marker had its own settings; transformations fell off smoothly with distance from the picked colour. No masks, ever.
|
||||
|
||||
I never built it into a real tool. The idea still feels right to me: distance in colour space is the natural unit for prose-style editing of an image. If I returned to it, I'd reach for WebGL instead of canvas to make the interaction live-preview-able on a real photo.
|
||||
I never built it into a real tool. The idea still feels right: distance in colour space is the natural unit for prose-style editing of an image. If I returned to it, I'd reach for WebGL instead of canvas. The interaction only earns its keep if the preview is live on a real photo, and canvas couldn't get there.
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ audience: general
|
|||
links: []
|
||||
---
|
||||
|
||||
A Webpack script that took a directory of full-size photos and produced a static site with multiple resized variants per image. Drop a new photo into the source folder, run the build, get a deployable site.
|
||||
I take walks with a camera. Most of what I shoot isn't good, but the act of walking slowly with a frame to think about is the most reliable way I know to come back with an idea for whatever I'm working on. In the summer of 2016 I wanted somewhere to put the few frames that survived, and I wasn't going to maintain a CMS for it.
|
||||
|
||||
The reason this exists at all: I take walks with a camera. Most of what I shoot isn't good, but the act of walking slowly with a frame to think about is the most reliable way I know to come back with an idea for whatever I'm working on. The site was an excuse to make that habit visible.
|
||||
So a Webpack script: point it at a directory of full-size photos, get a static site with responsive variants per image. Drop in a new photo, run the build, deploy. The pipeline mattered less than making the habit visible. The same habit later produced a [colour grader](/articles/photo-colour-grader/) for the same shots.
|
||||
|
||||
If I rebuilt it today I'd use Astro, which is what this site runs on.
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ outcome: A playable course project, and the moment programming clicked
|
|||
audience: technical
|
||||
---
|
||||
|
||||
The course project that convinced me to keep going.
|
||||
Autumn 2017, Basics of Programming, a deadline that forced me to learn C the hard way. I'd write almost none of it the same way today, and I'd defend every choice in it anyway. A 3D voxel platformer in pure C with SDL 1.2. No engine, no scripting layer.
|
||||
|
||||
Pure C, SDL 1.2, no engine, no scripting layer. The maps were randomly generated and destructible voxel by voxel, which meant the player could dig their way out of trouble, or build walls against flying enemies that would merge into larger ones as they got closer. Powerups let you shoot, or slow down time at the cost of points.
|
||||
Maps were randomly generated and destructible voxel by voxel, so the player could dig their way out of trouble or wall off flying enemies that merged into larger ones as they got closer. Powerups let you shoot, or slow down time at the cost of points.
|
||||
|
||||
What I actually learned was pointers, painfully, through an adequate number of segfaults. The course was meant to teach the basics of programming; for me it was the moment programming stopped feeling like a list of facts and started feeling like a thing I could build with.
|
||||
What I actually learned was pointers, painfully, through an adequate number of segfaults. The course was meant to teach the basics of programming; for me it was the moment programming stopped feeling like a list of facts and started feeling like a thing I could build with. The next time I reached for C it was on hardware that punished waste; see [Ad Astra](/articles/ad-astra-attiny85-game-engine/).
|
||||
|
||||
I'd write almost none of it the same way today, and I'd defend every choice in it anyway. First-project privilege.
|
||||
First-project privilege.
|
||||
|
|
|
|||
|
|
@ -31,11 +31,6 @@ media:
|
|||
caption: reconcile-text weaves conflicting edits together instead of asking a human to choose.
|
||||
---
|
||||
|
||||
**The two-bullet version:**
|
||||
|
||||
- Given a parent text and two edited versions, return one merged string. No conflict markers, no dropped edits, no operation log required.
|
||||
- Single Rust core, shipped as a crate, an npm package (via wasm-bindgen), and a PyPI package (via pyo3). The Obsidian sync plugin I wrote alongside it is the first consumer.
|
||||
|
||||
## Why I wrote it
|
||||
|
||||
I keep Markdown notes in three editors I don't control the internals of: Vim on my laptop, VS Code on my work machine, Obsidian on my phone. When two of them edit the same note between syncs, I have three files: the last-synced parent and two divergent children. That's the input. I want one merged file out, and I want to hand it back to the editors without conflict markers, because `<<<<<<< HEAD` is not something a notes app should ever show me.
|
||||
|
|
@ -54,7 +49,7 @@ So the library does exactly one thing: pure function from three strings to one.
|
|||
|
||||
**Tokeniser is the user knob.** This is the choice I'd defend hardest. Most of what people want when they say "merge differently" isn't a new algorithm; it's a different unit. Word-level tokenisation turns most "conflicts" in prose into two adjacent edits that coexist. Line-level makes it behave like `git merge-file`. Markdown-level merges on headings and list items. Same engine, four different products depending on what you call a token.
|
||||
|
||||
**Cursors are first-class merge inputs.** Each cursor has a stable ID and rides through the merge so a collaborative editor can ask "where did this cursor go?" without reconstructing it from the output text. This is the bit that made it useful to anything that wasn't just my sync script.
|
||||
**Cursors are first-class merge inputs.** Each cursor has a stable ID and rides through the merge so a collaborative editor can ask "where did this cursor go?" without reconstructing it from the output text. This is the bit that made it useful to anything that wasn't just [the Obsidian sync plugin I wrote alongside it](/articles/vault-link-obsidian-sync/).
|
||||
|
||||
**The Rust core is generic; the FFI surface is not.** Inside Rust, the tokeniser is a `dyn Fn(&str) -> Vec<Token<T>>`. That dies the moment you try to pass it through wasm-bindgen or pyo3. The fix was a closed enum of built-in tokenisers for non-Rust callers, with the generic version reserved for Rust users. Not elegant, but the alternative was per-binding glue forever.
|
||||
|
||||
|
|
|
|||
|
|
@ -29,11 +29,7 @@ media:
|
|||
caption: SDF-2D shipped as a TypeScript library, not a one-shot demo. That distinction shaped most of the design.
|
||||
---
|
||||
|
||||
**The short version:**
|
||||
|
||||
- 2D ray tracing in the browser via signed distance fields. Soft shadows, smooth reflections, no triangle mesh.
|
||||
- The hard constraint was running on a mid-range Android phone, not a desktop GPU. That single requirement picked tile-based passes, deferred shading, and shaders generated per scene and per device.
|
||||
- Half of my BSc thesis. The other half was [decla.red](/articles/declared-shared-simulation-code/), the multiplayer game built on top of it, which proved the library survived a real game loop.
|
||||
Winter 2020, BSc thesis deadline closing in, and the thing had to run acceptably on my advisor's laptop the day he graded it. That single shipping pressure exposed every lazy assumption in the architecture and picked the design: tile-based passes, deferred shading, shaders generated per scene and per device. A 2D ray tracer in the browser via signed distance fields: soft shadows, smooth reflections, no triangle mesh. The other half of the thesis was [decla.red](/articles/declared-shared-simulation-code/), the multiplayer game that proved the renderer survived a real game loop.
|
||||
|
||||
## What "mobile GPU" actually meant
|
||||
|
||||
|
|
@ -50,7 +46,7 @@ Three constraints did most of the design work:
|
|||
- **Tile-based rendering.** Group pixels and reason about them together. Most regions of a frame share the same nearby geometry, so you can early-out enormous swathes of pixel work if you know the tile's bounds. This was the single biggest perf win.
|
||||
- **Deferred shading.** Separate "find the surface" from "shade the surface." Shadow casting and reflections need the same geometry queries; doing them once per pixel and reusing the result was worth the extra texture bandwidth.
|
||||
- **Generated shaders per scene and device.** If a scene has no reflective surfaces, the generated shader doesn't carry the reflection path. If the device only supports WebGL1, the shader doesn't reach for WebGL2 features. Static feature flags do this badly; runtime generation does it well.
|
||||
- **TypeScript scene descriptions, no DSL.** I prototyped a small DSL for SDF authoring and threw it away. Users describe scenes in plain TypeScript and the library compiles them down. A DSL would have meant one more language to teach and one more compiler to debug.
|
||||
- **TypeScript scene descriptions, no DSL.** I prototyped a small DSL for SDF authoring and threw it away. Pride's expensive. Users describe scenes in plain TypeScript and the library compiles them down. A DSL would have meant one more language to teach and one more compiler to debug.
|
||||
|
||||
## Held up, didn't hold up
|
||||
|
||||
|
|
|
|||
|
|
@ -31,16 +31,11 @@ links:
|
|||
url: https://vault-link.schmelczer.dev
|
||||
---
|
||||
|
||||
**The two-bullet pitch:**
|
||||
|
||||
- Self-hosted Obsidian sync. One Rust server (axum + sqlx + SQLite), one TypeScript sync engine, three consumers: an Obsidian plugin, a standalone CLI, and two test harnesses. The point is to let me edit notes in Vim, VS Code, Obsidian desktop, and Obsidian mobile without the system caring which one I'm in.
|
||||
- The merge primitive is [reconcile-text](/articles/reconcile-text-3-way-merge/), which I wrote first. VaultLink is the question that made it worth writing, finally asked in earnest.
|
||||
I refuse to give up the editor. Obsidian on the phone, Vim on the laptop, VS Code at work, the occasional headless `sed` across the whole vault. None of them know about each other, none of them are going to learn to, and I'm not switching to whichever sync product picks a favourite. VaultLink is the architecture that falls out of that refusal: one Rust server, one TypeScript sync engine, an Obsidian plugin, a CLI, and two test harnesses. The merge primitive underneath it all is [reconcile-text](/articles/reconcile-text-3-way-merge/), which I wrote first. VaultLink is the question that made it worth writing, finally asked in earnest.
|
||||
|
||||
## The constraint that picks the algorithm
|
||||
|
||||
The whole shape of VaultLink is downstream of one decision: I refuse to give up the editor. Obsidian on the phone, Vim on the laptop, VS Code at work, the occasional headless `sed` across the whole vault. None of those know about each other; none of them are going to learn to.
|
||||
|
||||
The consequence is that the server never sees keystrokes. It sees end states: a file as it stood when sync caught it. That kills CRDTs (which need every operation) and OT-as-it's-usually-implemented (same). It leaves you with one primitive: 3-way merge given a parent, a left, and a right. Which is reconcile-text. Which I'd written exactly because no existing tool took three independently-edited file states and gave one back.
|
||||
The consequence of that refusal is that the server never sees keystrokes. It sees end states: a file as it stood when sync caught it. That kills CRDTs (which need every operation) and OT-as-it's-usually-implemented (same). It leaves you with one primitive: 3-way merge given a parent, a left, and a right. Which is reconcile-text. Which I'd written exactly because no existing tool took three independently-edited file states and gave one back.
|
||||
|
||||
The other consequence is that the _path placement_ is its own problem. Two clients might both move the same file. A file might land on a slot another file already occupies. A rename and a content edit might race. That's the part I underestimated.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: decla.red
|
||||
description: Mobile multiplayer browser game where the client and server linked the same TypeScript game-rules module.
|
||||
description: Browser multiplayer where the client and server linked the same TypeScript rules module. Concurrency bugs you can't have are bugs you don't have.
|
||||
thumbnail:
|
||||
src: ./_assets/declared.jpg
|
||||
alt: The decla.red browser game interface showing a space scene.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Fizika
|
||||
description: A static practice app for the Hungarian advanced-level physics érettségi. 659 past-paper questions, results in localStorage, eight years online.
|
||||
description: 'I needed it for my own physics érettségi: 659 past-paper questions, jQuery, localStorage, no accounts. Eight years on, students still find it.'
|
||||
thumbnail:
|
||||
src: ./_assets/fizika.jpg
|
||||
alt: Screenshot of the Fizika practice app showing topic-selection buttons.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: VaultLink
|
||||
description: Self-hosted Obsidian sync. Two-loop engine (wire and path reconciler), reconcile-text for 3-way merges, ts-rs single-source-of-truth API types.
|
||||
description: 'I refuse to give up the editor: Obsidian, Vim, VS Code, sed. Self-hosted sync that survives all four, built on reconcile-text underneath.'
|
||||
thumbnail:
|
||||
src: ./_assets/vault-link.svg
|
||||
alt: 'The VaultLink logo: a chain-link mark in a soft gradient.'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue