<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Somewhere in his mind</title>
    <description>Code, data, loud thoughts, and whatever else that occupies my mind.
</description>
    <link>https://matooly.github.io/</link>
    <atom:link href="https://matooly.github.io/feed.xml" rel="self" type="application/rss+xml" />
    <pubDate>Mon, 21 Sep 2026 21:09:26 +0000</pubDate>
    <lastBuildDate>Mon, 21 Sep 2026 21:09:26 +0000</lastBuildDate>
    <generator>Jekyll v3.10.0</generator>
    
      <item>
        <title>The Reward Isn&apos;t the Objective — Part 2: Closing the Loop</title>
        <description>&lt;p&gt;&lt;em&gt;This is part 2. Part 1 covered how I ended up here — two surveys, a suspicious sentence about time-inconsistency, and the moment I finally proved the gap I was chasing was real, not just a hunch. If you haven’t read that one, this will still mostly make sense, but the context helps. Same warning as before: this is long. It’s the messy, chronological version, mistakes included.&lt;/em&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;Where part 1 left off: I had two exact reference policies for a multi-period risk-adjusted investing problem, and I’d just shown that the reward people actually train reinforcement learning agents on doesn’t land on either of them — it’s a third policy, and comparing them by the field’s usual metric (Sharpe ratio) gets the ranking backwards more than 90% of the time. That was the proof that the thing was real. What I still didn’t have was the &lt;em&gt;why&lt;/em&gt;, or any sense of how big a deal it actually is once you put a learning algorithm in the loop instead of an exact solver.&lt;/p&gt;

&lt;h2 id=&quot;doing-it-properly-training-an-actual-agent&quot;&gt;Doing it properly: training an actual agent&lt;/h2&gt;

&lt;p&gt;At this point I could no longer avoid writing the RL agent, since the whole thing needed one term I hadn’t measured yet: how much error comes from imperfect learning, versus the reward itself.&lt;/p&gt;

&lt;p&gt;Wrote a simple policy-gradient agent (REINFORCE), deliberately using a policy class that &lt;em&gt;contains&lt;/em&gt; the exact surrogate solution, so any gap I measured was really about optimization and not about the agent being unable to represent the right answer.&lt;/p&gt;

&lt;p&gt;First version was broken in a boring but real way: training got better for a while, then got &lt;em&gt;worse&lt;/em&gt; the longer I trained it. Took me a minute to realize the exploration-noise term was scaling the gradient by one-over-variance-squared, and as I annealed the exploration down, the gradient variance blew up. Fixed by folding that scaling into the learning rate instead. After that, it converged cleanly.&lt;/p&gt;

&lt;p&gt;With a trained agent in hand, I could finally write down the full picture — how much of the gap comes from the philosophical problem (time-inconsistency), how much from the reward shape itself, and how much from imperfect learning:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;J(pre-committed) - J(learned agent)
    = [price of time-inconsistency]
    + [cost of the surrogate reward]
    + [learning error]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;All three terms, measured, summing up exactly to the total gap — no slack, no fudge.&lt;/p&gt;

&lt;p&gt;And here’s the finding I actually like best out of the whole project: &lt;strong&gt;which term dominates depends on how risk-averse you are.&lt;/strong&gt; At low risk aversion, the time-inconsistency price dominates completely and reward design barely matters. Push risk aversion up, and there’s a crossover — somewhere around a risk-aversion parameter of 12 in my test market — past which the &lt;em&gt;reward design&lt;/em&gt; term takes over and grows, while the time-inconsistency term shrinks. So the honest answer to “does reward shaping actually matter” is: it depends where you are on that curve, and two papers disagreeing about it could just be standing on opposite sides of it without knowing.&lt;/p&gt;

&lt;h2 id=&quot;the-part-where-i-checked-my-own-work-and-found-i-was-wrong-again&quot;&gt;The part where I checked my own work and found I was wrong again&lt;/h2&gt;

&lt;p&gt;I wanted to sanity-check the whole setup against something simple: the classic “bet a fixed fraction of your wealth to maximize long-run growth” reward (Kelly). I’d written, somewhat confidently, that this reward should trivially match the equilibrium policy, because it’s additive and therefore consistent by construction.&lt;/p&gt;

&lt;p&gt;Ran it. It doesn’t match at all. Not even close — different &lt;em&gt;shape&lt;/em&gt; entirely. Kelly bets a fraction of wealth; the mean-variance equilibrium bets a constant dollar amount. My reasoning had a hole in it: being &lt;em&gt;internally consistent&lt;/em&gt; only means dynamic programming is valid for whatever objective you picked. It says nothing about whether that objective happens to be the one you actually wanted. I’d conflated “this reward is well-behaved” with “this reward is correct,” and those are different claims.&lt;/p&gt;

&lt;p&gt;Small thing, but it meant a whole limiting case I’d been planning to use as a clean sanity check needed to be un-written and rewritten more carefully. Kind of embarrassing, kind of the whole point of checking your own work before anyone else has to.&lt;/p&gt;

&lt;h2 id=&quot;the-math-actually-clicks&quot;&gt;The math actually clicks&lt;/h2&gt;

&lt;p&gt;Somewhere in the middle of all this, I noticed something I should’ve seen earlier: the surrogate reward, being additive, is &lt;em&gt;itself&lt;/em&gt; time-consistent, which means its optimal policy satisfies its own clean backward recursion — no numerical search required, an exact recipe. Once I had that in closed form, I could ask sharper questions than “run it and see.”&lt;/p&gt;

&lt;p&gt;The best one: is there ever a &lt;em&gt;penalty setting&lt;/em&gt; where the surrogate reward exactly reproduces the true equilibrium policy? Worked through the algebra by hand (with some computer algebra help to check myself), and yes — but only under a specific condition (a risk-free return of exactly 1), and at a very specific penalty:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;kappa* = phi * (1 - nu)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;not &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;phi&lt;/code&gt;, the obvious guess. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nu&lt;/code&gt; here is basically a squared Sharpe ratio, scaled by how often you rebalance. The correction is not small — in my test market it moved the “right” penalty from 1.0 down to about 0.81.&lt;/p&gt;

&lt;p&gt;I dug at why the correction has that particular shape, and it turned out to shrink toward zero the more frequently you rebalance — annual rebalancing gives an 11% correction, weekly gives basically none. That also explains something that had been bugging me: why hadn’t anyone written this down already? Because the classic finance literature on this stuff works in &lt;em&gt;continuous&lt;/em&gt; time, where the correction is exactly zero by construction. It only shows up once you’re forced into discrete steps — which is exactly the situation every RL agent is actually in.&lt;/p&gt;

&lt;p&gt;I also sanity-checked my from-scratch equilibrium recursion against an existing continuous-time formula from the literature, by discretizing time into more and more steps and watching it converge. It did — cleanly, at the rate you’d expect. That was a good feeling; deriving something yourself and then watching it agree with someone else’s known result from a completely different angle is about as close to “this is probably right” as you get without a formal proof.&lt;/p&gt;

&lt;h2 id=&quot;two-more-things-i-hadnt-planned-on&quot;&gt;Two more things I hadn’t planned on&lt;/h2&gt;

&lt;p&gt;Two extensions I only got to because I kept asking “okay but does this actually matter in practice.”&lt;/p&gt;

&lt;p&gt;First: the differential Sharpe ratio, a fancier reward some papers use, involving running estimates of mean and variance. I expected this to need totally new machinery. It didn’t — with some algebra it turns out to just &lt;em&gt;be&lt;/em&gt; a variance-penalized reward again, except the penalty isn’t something you choose, it’s whatever the market happens to produce through those running estimates. Which means a practitioner using this reward thinks they’re setting their own risk preference, but they’re actually inheriting one the market picked for them, and nothing in the setup tells them what it is.&lt;/p&gt;

&lt;p&gt;(First attempt at measuring this gave a nonsensical number, because the running estimates hadn’t had time to converge over a short horizon — a good reminder that “the code ran and gave a number” is not the same as “the number means anything.”)&lt;/p&gt;

&lt;p&gt;Second, and this one mattered more: everything up to this point assumed returns are independent from one period to the next — a real simplification. So I tested what happens if that’s false: fat-tailed returns, clustered volatility, and actual momentum (returns correlated across time). Fat tails and volatility clustering didn’t change anything meaningful — the ranking of policies held up fine. But momentum broke it completely. The ordering flipped, and the pre-committed policy — previously the best — collapsed hardest, because its whole plan was calibrated to a drift assumption that momentum violates, and it has no way to notice and adjust.&lt;/p&gt;

&lt;p&gt;That’s a real limitation, not a footnote, and I said so plainly rather than burying it.&lt;/p&gt;

&lt;p&gt;The other thing I checked in that same pass mattered more than I expected: just &lt;em&gt;how&lt;/em&gt; invisible is this to a normal backtest? I simulated finite numbers of replications and asked how often a Sharpe-ratio comparison would correctly pick the actually-better policy. At 10 replications: about 2% of the time. At 200 or more: essentially never — and it doesn’t improve with more data, it gets &lt;em&gt;more confidently wrong&lt;/em&gt;, because you’re estimating the wrong thing with more and more precision. That’s the sentence I’d point to if someone asked me to summarize the whole project in one line: &lt;strong&gt;more backtest data does not fix a misspecified reward. It just makes you more sure of the wrong answer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2 id=&quot;where-it-stands&quot;&gt;Where it stands&lt;/h2&gt;

&lt;p&gt;I wrote all this up properly — theorems, proofs, an appendix with the derivation, figures, the works. It’s honest about what’s solid and what isn’t. The proof that the surrogate matches the equilibrium exactly at the right penalty: fully proved. The rate at which the mismatch grows away from that special case: proved for the simplest multi-period case, numerically confirmed beyond that. What happens over longer horizons in full generality: still open.&lt;/p&gt;

&lt;p&gt;And I still haven’t done a proper, exhaustive search of the finance literature to make sure the central result isn’t already sitting in some paper I haven’t found — I have a good structural argument for why it might genuinely be new (that continuous-time-versus-discrete-time thing), but an argument for why something &lt;em&gt;should&lt;/em&gt; be undiscovered isn’t the same as confirming it &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you got this far — through both parts — thank you, genuinely, this was long. All the code, the proofs, the write-up, and a running log of every mistake I made along the way (there were more than I expected, and I tried to leave them all visible rather than tidy them away) is here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://github.com/MattTheGreat-root/MVRL-Surrogate-GAP&quot;&gt;github.com/MattTheGreat-root/MVRL-Surrogate-GAP&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything runs on a laptop in a few minutes, except the full training sweep, which takes about ten. If you find a hole in it, I’d genuinely like to hear about it — several of the best parts of this project came from exactly that.&lt;/p&gt;

</description>
        <pubDate>Mon, 21 Sep 2026 02:31:00 +0000</pubDate>
        <link>https://matooly.github.io/thoughts/2026/09/21/econ2.html</link>
        <guid isPermaLink="true">https://matooly.github.io/thoughts/2026/09/21/econ2.html</guid>
        
        
        <category>thoughts</category>
        
      </item>
    
      <item>
        <title>The Reward Isn&apos;t the Objective — Part 1: Finding the Crack</title>
        <description>&lt;p&gt;&lt;em&gt;Heads up before you start: this is long, and it’s part one of two. What began as “let me write a research proposal” turned into weeks of proofs, broken code, and one afternoon where I found out something I’d already written down was flat wrong. If you’re just here for the punchline, there’s a part 2 coming with the actual results and the repo link. If you want the whole path, make some coffee.&lt;/em&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;how-this-started&quot;&gt;How this started&lt;/h2&gt;

&lt;p&gt;I was doing my usual late-night Friday deep dive, casually researching my interests to fill the void in my soul. Once I zeroed in on a specific topic, my friend (an AI, naturally) chimed in: why not make this official and write a research proposal? But “writing a proposal” is a totally different game from “studying AI,” which is my actual strong suit. I’d done the hands-on ML stuff before—scikit-learn, PyTorch, the standard toolkit—but reinforcement learning was brand new to me, and applying it to finance was a completely foreign language.&lt;/p&gt;

&lt;p&gt;The topic was eventually: reinforcement learning for asset allocation. So I did what you’re supposed to do and started reading surveys.&lt;/p&gt;

&lt;p&gt;The first one was Bai et al., a 2024 review of RL in finance. It’s the kind of paper that’s genuinely useful when you’re starting from nothing — it catalogues dozens of papers, groups them by application (market making, portfolio management, optimal execution), and does a meta-analysis of what actually seems to work. I read it end to end, mostly to build vocabulary.&lt;/p&gt;

&lt;p&gt;Buried in there was a table. It classified the &lt;em&gt;rewards&lt;/em&gt; people use to train these agents. Plain return. Profit and loss. Sharpe ratio. Differential Sharpe ratio. Variance penalties. Normal stuff, nothing alarming on the surface.&lt;/p&gt;

&lt;p&gt;Then there was a sentence that stuck with me more than it probably should have: the survey compared studies using plain return against studies using a “shaped” (risk-adjusted) reward, and said the shaped ones perform better. The p-value for that comparison was 0.120.&lt;/p&gt;

&lt;p&gt;That’s not significant. And it’s not even a fair comparison — it’s pooled across papers using different markets, different periods, different algorithms, different everything. I remember thinking: so nobody has actually tested this cleanly. Everyone just… does it, and reports a backtest number.&lt;/p&gt;

&lt;p&gt;I didn’t think much more of it at the time. I filed it away and moved on to writing a normal proposal — pick a reward, sweep some hyperparameters, run PPO, report Sharpe ratios. Standard stuff. I even got pretty far into planning the experiment before something else got in the way.&lt;/p&gt;

&lt;h2 id=&quot;the-second-survey-and-the-sentence-that-changed-the-whole-plan&quot;&gt;The second survey, and the sentence that changed the whole plan&lt;/h2&gt;

&lt;p&gt;Someone told me to look at Hambly, Xu, and Yang’s 2023 survey too — the more mathematical one, published in &lt;em&gt;Mathematical Finance&lt;/em&gt; rather than an ML venue. Different audience, different rigor.&lt;/p&gt;

&lt;p&gt;And there it was, almost in passing: the mean-variance framework — the thing basically everyone means when they say “risk-adjusted” in finance — is &lt;strong&gt;time-inconsistent&lt;/strong&gt;. A strategy that’s optimal today is not optimal tomorrow, even if nothing has changed except that time has passed. And because of that, the Bellman equation doesn’t hold for it.&lt;/p&gt;

&lt;p&gt;I had to sit with that for a minute. Every reinforcement learning algorithm I knew — Q-learning, actor-critic, PPO, all of it — is built on the assumption that you can solve a long problem by solving the next step correctly and then continuing optimally. That’s the whole trick. That’s what makes RL &lt;em&gt;work&lt;/em&gt;. And here was a paper telling me, almost as an aside, that the objective everyone in the first survey was implicitly chasing doesn’t support that trick at all.&lt;/p&gt;

&lt;p&gt;So when someone trains PPO on a Sharpe-ratio reward, sums it over time, and calls the result “risk-adjusted” — what have they actually built? Not the thing they think they built. The algorithm is &lt;em&gt;correct&lt;/em&gt;, in the sense that it correctly optimizes the sum it was given. It’s the sum itself that isn’t the thing.&lt;/p&gt;

&lt;p&gt;That’s when the proposal I’d half-written stopped feeling interesting. Sweeping hyperparameters on a reward nobody had checked the meaning of felt like measuring something without knowing what the ruler was calibrated to.&lt;/p&gt;

&lt;h2 id=&quot;first-proposal-first-rejection-sort-of&quot;&gt;First proposal, first rejection (sort of)&lt;/h2&gt;

&lt;p&gt;I wrote a proposal anyway — a controlled comparison of reward functions, decent statistics, robustness checks. I thought it was solid. It got pushback: no clear method, the gap wasn’t obviously real, and — fair — not much actual math in it. Just prose about what I’d measure.&lt;/p&gt;

&lt;p&gt;That stung a little, but it was the right call. I’d been describing an &lt;em&gt;experiment&lt;/em&gt; and calling it a &lt;em&gt;contribution&lt;/em&gt;. Those aren’t the same thing.&lt;/p&gt;

&lt;h2 id=&quot;finding-the-actual-math&quot;&gt;Finding the actual math&lt;/h2&gt;

&lt;p&gt;I went back to the setting properly this time. Multi-period mean-variance, the Li &amp;amp; Ng (2000) formulation — you get a &lt;em&gt;pre-committed&lt;/em&gt; optimal policy in closed form, solved by embedding the problem in an auxiliary one. It’s affine in wealth: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;u*(x) = alpha*x + beta&lt;/code&gt;. Clean.&lt;/p&gt;

&lt;p&gt;But because of the time-inconsistency thing, there’s a second notion of optimal: the &lt;em&gt;equilibrium&lt;/em&gt; policy — the one where no future version of you wants to deviate from the plan, given everyone else sticks to it too. Basak and Chabakauri worked this one out. It’s a genuinely different policy from the pre-committed one, not just a variant.&lt;/p&gt;

&lt;p&gt;So now I had two reference points. Two different “correct” answers to the same question, depending on what you mean by correct.&lt;/p&gt;

&lt;p&gt;And then the actual question crystallized: when you train RL on a summed-up risk-adjusted reward, which of these — if either — do you land on?&lt;/p&gt;

&lt;p&gt;Nobody had answered that. Not because it’s obscure, but because you need &lt;em&gt;both&lt;/em&gt; closed forms to check against, and most work just runs a backtest and reports whatever number comes out.&lt;/p&gt;

&lt;h2 id=&quot;building-it-and-immediately-breaking-it&quot;&gt;Building it, and immediately breaking it&lt;/h2&gt;

&lt;p&gt;I started coding. First the pre-committed policy — the Li-Ng closed form. Then a verification step, because I didn’t trust myself: I threw a completely independent numerical optimizer at the same problem, with zero knowledge of the closed form, from random starting points. If they agreed, I’d believe it.&lt;/p&gt;

&lt;p&gt;They didn’t, at first. My first check used Nelder-Mead in 24 dimensions, and it just… didn’t converge. It failed silently in a way that made the test &lt;em&gt;look&lt;/em&gt; like it passed, because all I was checking was “did the optimizer beat my closed form,” and a non-convergent optimizer obviously can’t beat anything. That’s a test that passes even when it’s meaningless. I only caught it by looking too closely at the numbers and going “wait, that doesn’t look optimized at all.”&lt;/p&gt;

&lt;p&gt;Swapped in L-BFGS-B with multiple restarts, required agreement in &lt;em&gt;both&lt;/em&gt; directions. Then it worked — agreement to about 2.6e-12. That felt good. First real “okay, I trust this” moment.&lt;/p&gt;

&lt;p&gt;Then I built the equilibrium policy — a genuine backward recursion, trickier than the pre-committed case. Checked it against its own definition directly: at a bunch of wealth levels and time steps, I asked an optimizer to find a better single-period deviation than what my policy prescribed, given everyone else keeps playing the equilibrium strategy. Best improvement found: zero, to machine precision.&lt;/p&gt;

&lt;p&gt;There was a nice surprise buried in there too. The equilibrium policy turned out to hold a &lt;em&gt;constant dollar amount&lt;/em&gt; in risky assets — not a constant fraction of wealth, an actual constant number — while the pre-committed one scales down as wealth grows. Different shapes, not just different numbers. That’s a known result in the literature, but reproducing it from scratch was itself reassuring: if my recursion had a bug, it almost certainly wouldn’t have reproduced a known structural fact by accident.&lt;/p&gt;

&lt;h2 id=&quot;the-part-where-i-almost-shipped-something-false&quot;&gt;The part where I almost shipped something false&lt;/h2&gt;

&lt;p&gt;Somewhere in here I got curious about &lt;em&gt;why&lt;/em&gt; time-inconsistency happens, mechanically, not just abstractly. So I ran an experiment: compute the optimal plan at time zero, let one period pass, then re-solve the exact same problem from scratch at time one. Compare what the original plan says to do against what the fresh solve says to do.&lt;/p&gt;

&lt;p&gt;They disagreed — expected, that’s the whole point. But the disagreement had a very specific shape: it was a straight line in wealth, and it crossed exactly zero at &lt;em&gt;one particular wealth level&lt;/em&gt;. I wrote down, casually, that the disagreement was “present everywhere, especially in the tails.” Then I actually plotted it and found the opposite: it vanishes exactly at the wealth level your past self expected you’d be at, and grows the further you are from that.&lt;/p&gt;

&lt;p&gt;That’s a much better fact than the one I almost wrote down. The mechanism turned out to be really clean too — the whole plan is anchored to a multiplier that’s pinned by your &lt;em&gt;expected&lt;/em&gt; terminal wealth, so if reality tracks the plan exactly, nothing needs to change. It’s only surprise that breaks consistency. I liked that a lot more than the vague thing I’d have shipped if I hadn’t double-checked myself.&lt;/p&gt;

&lt;h2 id=&quot;does-the-actual-gap-even-exist&quot;&gt;Does the actual gap even exist&lt;/h2&gt;

&lt;p&gt;At this point I had two exact reference policies but still hadn’t shown the thing my whole idea depended on: that training RL on a summed reward gets you somewhere &lt;em&gt;else&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Here’s the trick that made it possible to check this without touching reinforcement learning at all: the reward practitioners actually write down — “return minus a penalty on the square of return” — turns out to &lt;em&gt;also&lt;/em&gt; have a closed-form optimum, because summed-up rewards are automatically time-consistent (sums always are). So I could compute the exact maximizer of the &lt;em&gt;practitioner’s reward&lt;/em&gt; and just… compare it directly to the two references. No training, no noise, no excuses.&lt;/p&gt;

&lt;p&gt;First thing I checked: the plain-return reward, no risk penalty at all — the single most common choice in the surveyed literature. It has no maximum. At all. Scale up your bets and the expected reward just climbs forever, 0.88, 8, 80, 800, 8000, 80000. The actual risk-averse problem is perfectly well-behaved and bounded; the reward that’s supposed to represent it isn’t. Anyone training on it isn’t solving a hard version of the right problem. They’re solving a problem with no answer, and what they get back is whatever their leverage limits happened to allow.&lt;/p&gt;

&lt;p&gt;With an actual penalty added in, there &lt;em&gt;is&lt;/em&gt; a finite optimum — and it’s neither of my two reference policies. It’s a third thing. And the gap wasn’t even one-directional: at the best possible penalty, this “surrogate” policy actually slightly &lt;em&gt;beat&lt;/em&gt; the equilibrium one on its own terms. Not a worse version of the equilibrium policy — a genuinely different object.&lt;/p&gt;

&lt;p&gt;And then the part that made me want to write this whole post: I compared these policies by Sharpe ratio, the thing everyone reports in backtests. The naive choice — set your reward’s risk penalty equal to your actual risk aversion, which is the obviously reasonable thing to do — had a &lt;em&gt;higher&lt;/em&gt; Sharpe ratio than the true equilibrium policy, while having a &lt;em&gt;lower&lt;/em&gt; value on the actual objective. Backwards. If you evaluated these by the field’s normal method, you’d conclude the wrong one is better, on precisely the dimension where it’s worse.&lt;/p&gt;

&lt;p&gt;I checked that wasn’t a fluke by running it across about 500 random markets and risk-aversion levels. The reversal shows up over 90% of the time.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;That’s where part 1 ends — the point where I finally had proof, not just a hunch, that the thing I’d suspected two surveys ago was real. Part 2 picks up with actually training an agent, a decomposition that splits the error into three honest pieces, a mistake I nearly shipped involving a textbook betting strategy, and a result about backtests that I think is the sharpest thing to come out of the whole project. Repo link is at the bottom of part 2.&lt;/p&gt;
</description>
        <pubDate>Wed, 16 Sep 2026 02:31:00 +0000</pubDate>
        <link>https://matooly.github.io/thoughts/2026/09/16/econ.html</link>
        <guid isPermaLink="true">https://matooly.github.io/thoughts/2026/09/16/econ.html</guid>
        
        
        <category>thoughts</category>
        
      </item>
    
      <item>
        <title>Invictus</title>
        <description>&lt;p&gt;Out of the night that covers me,&lt;br /&gt;
Black as the pit from pole to pole,&lt;br /&gt;
I thank whatever gods may be&lt;br /&gt;
For my unconquerable soul.&lt;/p&gt;

&lt;p&gt;In the fell clutch of circumstance&lt;br /&gt;
I have not winced nor cried aloud.&lt;br /&gt;
Under the bludgeonings of chance&lt;br /&gt;
My head is bloody, but unbowed.&lt;/p&gt;

&lt;p&gt;Beyond this place of wrath and tears&lt;br /&gt;
Looms but the Horror of the shade,&lt;br /&gt;
And yet the menace of the years&lt;br /&gt;
Finds and shall find me unafraid.&lt;/p&gt;

&lt;p&gt;It matters not how strait the gate,&lt;br /&gt;
How charged with punishments the scroll,&lt;br /&gt;
I am the master of my fate,&lt;br /&gt;
I am the captain of my soul.&lt;/p&gt;
</description>
        <pubDate>Sun, 30 Aug 2026 02:31:00 +0000</pubDate>
        <link>https://matooly.github.io/thoughts/2026/08/30/invictus.html</link>
        <guid isPermaLink="true">https://matooly.github.io/thoughts/2026/08/30/invictus.html</guid>
        
        
        <category>thoughts</category>
        
      </item>
    
      <item>
        <title>Project Purple</title>
        <description>&lt;p&gt;In 2005, the smartphone industry was completely obsessed with plastic. If you look back at a BlackBerry or a Palm Treo from that era, the screen was almost an afterthought. The entire bottom half of the device was dominated by a fixed, cramped grid of little plastic chiclets. To the rest of the industry, this was just how “serious” business phones were built. To Steve Jobs, it was a massive design flaw.&lt;/p&gt;

&lt;p&gt;Jobs hated the idea of static hardware dictating the software. His vision for the iPhonecodenamed “Project Purple”was ruthless in its simplicity. Why should a phone carry around a plastic keyboard when you’re trying to watch a video? In that context, those buttons are just dead space.&lt;/p&gt;

&lt;p&gt;Jobs wanted a device that was nothing but a screena blank slate where the interface could instantly morph to fit whatever app you were using. You get a calculator pad when you need numbers, a full QWERTY layout when you need to type, and absolutely nothing when you want to watch a movie. The hardware had to get out of the way.&lt;/p&gt;

&lt;p&gt;It was a brilliant product vision. But there was just one massive problem: typing on a flat sheet of glass was terrible.&lt;/p&gt;

&lt;h2 id=&quot;so-many-things-wrong-with-glass&quot;&gt;So many things wrong with glass&lt;/h2&gt;

&lt;p&gt;Early prototypes of the iPhone were failing at the most basic hurdle. Without the tactile “click” of a physical button, users were totally lost. Human thumbs are simply too wide and blunt to accurately hit tiny, 40-pixel targets on a smooth surface. Inside Apple, testers were trying to tap out simple emails and ending up with strings of pure gibberish. The error rates were off the charts.&lt;/p&gt;

&lt;p&gt;For Jobs, giving in and bolting a plastic keyboard onto the bottom of the iPhone wasn’t an option. It would ruin the purity of the device. But if his team couldn’t figure out how to make typing on glass actually workif they couldn’t somehow make it accurate and intuitivethe whole concept of a screen-only smartphone was completely dead in the water.&lt;/p&gt;

&lt;h2 id=&quot;the-keyboard-derby&quot;&gt;The “Keyboard Derby”&lt;/h2&gt;

&lt;p&gt;Recognizing the existential threat to the project, Apple software executive Scott Forstall hit the panic button. He paused nearly all other user interface work on the iPhone and initiated a brutal internal competition.&lt;/p&gt;

&lt;p&gt;For three weeks, every software engineer on the UI team was tasked with doing just one thing: build a touchscreen keyboard that actually worked.&lt;/p&gt;

&lt;p&gt;The engineers pitched wildly different concepts. Some threw out the standard QWERTY layout entirely, arguing it wasn’t made for thumbs anyway. Others tried grouping multiple letters onto single, larger keys to compensate for inaccuracy.&lt;/p&gt;

&lt;p&gt;But the winning design came from a principal engineer named Ken Kocienda. Kocienda realized a fundamental truth of product design: &lt;strong&gt;you don’t force users to learn a completely new layout while simultaneously forcing them to learn a completely new input method (glass).&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The visual layout had to remain standard QWERTY. The innovation couldn’t be in the visible design. It had to be invisible.&lt;/p&gt;

&lt;p&gt;Kocienda’s breakthrough was recognizing that the keys didn’t need to change visuallythey needed to change &lt;em&gt;behaviorally&lt;/em&gt; in the background, outsmarting the user’s fat fingers in real-time.&lt;/p&gt;

&lt;h2 id=&quot;the-phantom-hitboxes-an-algorithm-in-disguise&quot;&gt;The Phantom Hitboxes: An Algorithm in Disguise&lt;/h2&gt;

&lt;p&gt;To understand the genius of the original iPhone keyboard, you have to separate the UI from the underlying architecture. What the user saw was a static, unmoving grid of QWERTY keys. But what the iPhone’s processor saw was a highly dynamic, probabilistic data pipeline that was constantly shifting in real-time.&lt;/p&gt;

&lt;p&gt;When you tapped a piece of glass, you weren’t actually pressing a buttonyou were simply feeding an (x, y) coordinate on a 2D plane into an algorithm. Ken Kocienda’s job was to figure out what you actually &lt;em&gt;meant&lt;/em&gt; to type based on those coordinates, even when they were objectively wrong.&lt;/p&gt;

&lt;p&gt;He solved this by building a localized predictive model that relied on three distinct layers of logic:&lt;/p&gt;

&lt;h3 id=&quot;1-the-probabilistic-dictionary&quot;&gt;1. The Probabilistic Dictionary&lt;/h3&gt;

&lt;p&gt;At its core, the keyboard functioned as a character-level $n$-gram model, constantly calculating the conditional probability of the next character in a sequence. Rather than treating each keystroke as an isolated independent event, the software relied on a Markov assumption—&lt;strong&gt;specifically that the probability of the next character $c_i$ depends on the preceding $n-1$ characters.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Formally, the probability distribution over the vocabulary for the next character is evaluated as:&lt;/p&gt;

\[P(c_i \vert{} c_1, c_2, \dots, c_{i-1}) \approx P(c_i \vert{} c_{i-(n-1)}, \dots, c_{i-1})\]

&lt;p&gt;In practice, if you tapped the letter “T” ($c_1$) and then “H” ($c_2$), the system instantly queried its local lexicon to calculate the Maximum Likelihood Estimation (MLE) for every possible subsequent character. Using a trigram context, the probability of the next letter being “E” is determined by the historical frequency counts within the English language corpus:&lt;/p&gt;

\[P(c_3 = \text{&apos;E&apos;} \mid c_1 = \text{&apos;T&apos;}, c_2 = \text{&apos;H&apos;}) = \frac{\text{Count}(\text{&apos;T&apos;}, \text{&apos;H&apos;}, \text{&apos;E&apos;})}{\text{Count}(\text{&apos;T&apos;}, \text{&apos;H&apos;})}\]

&lt;p&gt;Because the sequence “THE” is statistically dominant, this equation yields an extremely high probability scalar. Conversely, evaluating the likelihood of an impossible sequence—&lt;strong&gt;such as $P(c_3 = \text{‘Q’} \mid \text{‘T’}, \text{‘H’})$&lt;/strong&gt;—evaluates to near zero. These calculated probability weights were then used as the direct mathematical inputs to reshape the actual touch zones on the screen.&lt;/p&gt;

&lt;h3 id=&quot;2-the-dynamic-hit-targets&quot;&gt;2. The Dynamic Hit Targets&lt;/h3&gt;

&lt;p&gt;This is where the magic happened. Based on those calculated probabilities, the software instantly redrew the invisible touch-sensitive regions (the hitboxes) for every key on the screen.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;High-Probability Keys:&lt;/strong&gt; The hitboxes for “E”, “A”, and “I” would massively expand, silently cannibalizing the pixels of the surrounding keys.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Low-Probability Keys:&lt;/strong&gt; The hitboxes for keys like “Q” and “Z” would shrink down to a fraction of their visual size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you were trying to type “THE”, but you were moving fast and your clumsy thumb physically landed on the left edge of the “R” key, the phone wouldn’t register an “R”. Because the “E” hitbox had expanded to overlap the physical drawing of the “R” key, the algorithm confidently registered an “E”.&lt;/p&gt;

&lt;h3 id=&quot;3-geometric-distance--keyboard-constellations&quot;&gt;3. Geometric Distance &amp;amp; “Keyboard Constellations”&lt;/h3&gt;

&lt;p&gt;Simply resizing hitboxes wasn’t enough to handle truly terrible typing. To fix mangled words, Kocienda engineered a geometric pattern-matching system.&lt;/p&gt;

&lt;p&gt;Every time you typed a word, your sequence of taps created a specific path of (x, y) coordinates across the screenwhat Kocienda called a “keyboard constellation.” If you typed a word so poorly that the dynamic hitboxes couldn’t save it, the system would look at your messy constellation of coordinates and calculate the Euclidean distance between your raw taps and the “ideal” coordinates for actual dictionary words.&lt;/p&gt;

&lt;p&gt;The software would generate a list of candidate words that matched your geometric shape, and then apply Zipf’s Law (a statistical rule about how frequently certain words are used in a language) to weight the results. The most statistically probable word with the closest geometric distance won the tiebreaker.&lt;/p&gt;

&lt;p&gt;The user was completely blind to this entire pipeline. To them, it just felt like the glass keyboard was somehow reading their mind.&lt;/p&gt;

&lt;h2 id=&quot;trusting-the-math&quot;&gt;Trusting the Math&lt;/h2&gt;

&lt;p&gt;All of this invisible maththe probabilistic dictionaries, the shifting hitboxes, the geometric constellation mappingwas an engineering triumph. But it created a massive psychological problem.&lt;/p&gt;

&lt;p&gt;How do you get a user to trust a piece of glass that is secretly changing the rules of the keyboard while they type?&lt;/p&gt;

&lt;p&gt;The answer became the most famous (and sometimes infamous) user interface element of the modern smartphone era: the autocorrect suggestion bubble.&lt;/p&gt;

&lt;h2 id=&quot;breaking-the-context-switch&quot;&gt;Breaking the “Context Switch”&lt;/h2&gt;

&lt;p&gt;During internal testing, Ken Kocienda noticed a fatal flaw in how people interacted with the touchscreen. Because the glass lacked tactile feedback, users didn’t trust it. They would peck a letter, look up at the text field to make sure the right letter appeared, and then look back down at the keyboard.&lt;/p&gt;

&lt;p&gt;If they made a mistakesay, hitting the “R” key instead of the “E”they immediately hit the backspace key to fix it.&lt;/p&gt;

&lt;p&gt;Kocienda called this the “context switch,” and it absolutely broke his algorithm. The geometric constellation mapping &lt;em&gt;needed&lt;/em&gt; the user to finish typing the entire word. If you only typed two letters, realized you messed up, and deleted one, the software didn’t have enough geometric data points to guess what word you were actually aiming for.&lt;/p&gt;

&lt;p&gt;Apple realized they didn’t just have to design new software; they had to reprogram human behavior.&lt;/p&gt;

&lt;p&gt;The autocorrect UI was designed to encourage momentum. As you typed your mangled, fat-fingered gibberish, the algorithm’s best guess would hover in a little bubble just below your text. The genius was in the execution: you didn’t have to tap the bubble to accept the correction. You just hit the spacebar.&lt;/p&gt;

&lt;p&gt;Apple’s marketing and instructional videos for the original iPhone all hammered home the same core message: &lt;em&gt;Just type. Don’t look down. Don’t stop to fix mistakes. Trust the keyboard.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you just kept your thumbs moving, the algorithm would gather enough coordinate data to map your constellation, apply Zipf’s Law, and seamlessly swap your gibberish for the correct word the moment you hit the spacebar.&lt;/p&gt;

&lt;h2 id=&quot;the-blackberry-killer&quot;&gt;The BlackBerry Killer&lt;/h2&gt;

&lt;p&gt;When Steve Jobs walked onto the Macworld stage in January 2007, the executives at companies like Research In Motion (the makers of BlackBerry) were watching closely. Many of them believed typing on glass was a physics problem that couldn’t be solved. They assumed the iPhone would be a neat toy, but useless for serious enterprise email.&lt;/p&gt;

&lt;p&gt;Then Jobs demonstrated the keyboard. He didn’t use a stylus. He used his bare fingers, typing quickly and fluidly. The autocorrect engine caught his errors silently and perfectly.&lt;/p&gt;

&lt;p&gt;In that single presentation, the fixed physical keyboard became obsolete.&lt;/p&gt;

&lt;h2 id=&quot;the-end-of-plastic&quot;&gt;The End of Plastic&lt;/h2&gt;

&lt;p&gt;It’s easy to laugh at autocorrect today, usually when it aggressively changes “fucking” to “ducking” in a text message. But in 2007, that invisible safety net wasn’t a jokethat was the only thing keeping the iPhone from being a very expensive paperweight.&lt;/p&gt;

&lt;p&gt;What Kocienda really figured out during that Keyboard Derby was that typing on a touchscreen isn’t actually typing at all. There are no buttons. There is no physical input. You are simply throwing raw spatial data at a statistical engine and trusting the math to figure out what you meant.&lt;/p&gt;

&lt;p&gt;By letting an algorithm do the heavy lifting that plastic keys used to do, Apple proved that good software could completely mask the physical flaws of a piece of hardware. It’s the reason the physical smartphone keyboard died almost overnight, and it’s the reason you aren’t carrying a BlackBerry right now.&lt;/p&gt;
</description>
        <pubDate>Wed, 19 Aug 2026 14:17:00 +0000</pubDate>
        <link>https://matooly.github.io/science/2026/08/19/apple.html</link>
        <guid isPermaLink="true">https://matooly.github.io/science/2026/08/19/apple.html</guid>
        
        
        <category>science</category>
        
      </item>
    
      <item>
        <title>Stalker: A Public Scoreboard for People</title>
        <description>&lt;p&gt;You just met someone.&lt;/p&gt;

&lt;p&gt;Could be a girl you’re thinking about dating, a guy you’re about to do a group project with, or some new coworker who already talks too much. They seem fine. Maybe even good.&lt;/p&gt;

&lt;p&gt;But you have no idea if they’re actually full of shit.&lt;/p&gt;

&lt;p&gt;You don’t know if the last three people who dated them walked away thinking “never again.” You don’t know if their previous teammates quietly prayed they’d drop the class. You don’t know if they’re the type who shows up or the type who ghosts the second work gets real.&lt;/p&gt;

&lt;p&gt;That information exists. It’s just locked in group chats, private rants, and the faces of people who already got burned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stalker&lt;/strong&gt; is the public version of that information.
→ &lt;a href=&quot;https://rate.pythonanywhere.com/&quot;&gt;Try it here&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A big, ugly database of numbers slapped onto real people — looks, personality, style, intelligence, money, body. Crowdsourced scores from the people who actually know them. No comments. No essays. Just the numbers.&lt;/p&gt;

&lt;p&gt;It’s the digital equivalent of leaning over to the person next to you and asking:&lt;/p&gt;

&lt;p&gt;“Be honest. Is this one worth it or should I run?”&lt;/p&gt;

&lt;p&gt;Some people will call it toxic.&lt;br /&gt;
Some will get their feelings hurt when they see their score.&lt;/p&gt;

&lt;p&gt;I don’t really care.&lt;br /&gt;
I built it anyway.&lt;/p&gt;

&lt;h2 id=&quot;the-concept-a-shadow-transcript&quot;&gt;The Concept: A Shadow Transcript&lt;/h2&gt;

&lt;p&gt;Your official transcript says someone got an A in Data Structures.&lt;br /&gt;
It doesn’t say whether they carried the group project, ghosted until 3 a.m. the night before, or submitted code that looked like it was written by a raccoon.&lt;/p&gt;

&lt;p&gt;Stalker fills that gap. Crowdsourced reputation. A living, breathing ranking of the people you actually have to work with.&lt;/p&gt;

&lt;p&gt;Is it fair? Probably not.&lt;br /&gt;
Is it interesting? Extremely.&lt;/p&gt;

&lt;h2 id=&quot;the-math-seed-scores-and-the-clamp-lag-problem&quot;&gt;The Math: Seed Scores and the “Clamp Lag” Problem&lt;/h2&gt;

&lt;p&gt;Raw averages are trash for this kind of system. New profiles get destroyed by the first three angry votes, and the whole thing becomes a popularity contest that swings wildly.&lt;/p&gt;

&lt;p&gt;So every profile starts with a seed score (I went with 8).&lt;br /&gt;
Users then vote +1 or –1 on each metric.&lt;br /&gt;
The backend just does:&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Seed + total upvotes – total downvotes&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Simple. Honest. Trackable.&lt;/p&gt;

&lt;p&gt;But the UI has a hard clamp: scores never show above 10 or below 0.&lt;/p&gt;

&lt;p&gt;Here’s where it gets spicy.&lt;/p&gt;

&lt;p&gt;Imagine someone has a seed of 8 and has already collected five upvotes. True score = 13. The UI shows a clean 10.&lt;br /&gt;
Someone comes along and downvotes them. True score drops to 12.&lt;br /&gt;
UI still shows 10.&lt;/p&gt;

&lt;p&gt;To the person who just voted, the number didn’t move. It feels broken. That’s &lt;strong&gt;Clamp Lag&lt;/strong&gt; — the price you pay for bounding a cumulative sum while still tracking every individual vote.&lt;/p&gt;

&lt;p&gt;You could switch to a shared 0–10 slider that updates instantly, but then you lose the ability to know &lt;em&gt;who&lt;/em&gt; voted and how the score actually evolved. In a system built on judgment, accountability matters more than instant gratification.&lt;/p&gt;

&lt;h2 id=&quot;the-stack-and-the-production-nightmares&quot;&gt;The Stack and the Production Nightmares&lt;/h2&gt;

&lt;p&gt;Django backend. Vanilla JavaScript frontend. SQLite database. Hosted on a persistent PythonAnywhere server.&lt;/p&gt;

&lt;p&gt;This is where the real education happened.&lt;/p&gt;

&lt;h3 id=&quot;lesson-1-the-git-database-wipe&quot;&gt;Lesson 1: The Git Database Wipe&lt;/h3&gt;

&lt;p&gt;Early on I committed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;db.sqlite3&lt;/code&gt; like an absolute rookie.&lt;br /&gt;
One &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git pull --hard&lt;/code&gt; later and every user, every rating, every profile vanished. Git happily overwrote the live database with the empty one from the repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;db.sqlite3&lt;/code&gt; goes in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gitignore&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git rm --cached db.sqlite3&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Daily physical backups of both the database and the media folder&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GitHub is for code. It is not your backup strategy.&lt;/p&gt;

&lt;h3 id=&quot;lesson-2-ghost-sessions&quot;&gt;Lesson 2: Ghost Sessions&lt;/h3&gt;

&lt;p&gt;After the wipe, the frontend still had old user IDs sitting in Local Storage.&lt;br /&gt;
It would successfully create a new profile, then try to attach ratings using a user that no longer existed. Django returned 400s. The JavaScript ignored them and redirected anyway. Result: profiles stuck at zero with no way to recover.&lt;/p&gt;

&lt;p&gt;Now the frontend actually checks the response before it does anything. Revolutionary, I know.&lt;/p&gt;

&lt;h3 id=&quot;lesson-3-the-golden-backup-ritual&quot;&gt;Lesson 3: The Golden Backup Ritual&lt;/h3&gt;

&lt;p&gt;Any migration that touches the schema is high-stakes.&lt;br /&gt;
My deployment ritual is now religious:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Branch the changes&lt;/li&gt;
  &lt;li&gt;SSH in and run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cp db.sqlite3 db_safety_snapshot.sqlite3&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Pull the new code&lt;/li&gt;
  &lt;li&gt;Migrate and collectstatic&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If it explodes, the abort button is instant: delete the broken DB, rename the snapshot, checkout main. Sleep well.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;At the end of the day, this whole thing is just numbers on people.&lt;/p&gt;

&lt;p&gt;Some will say it’s toxic. Some will say it’s unfair. Some will get their feelings hurt when they see a 4.2 next to their name and realize the room doesn’t think as highly of them as they do.&lt;/p&gt;

&lt;p&gt;Good.&lt;/p&gt;

&lt;p&gt;The world already judges you. It just does it quietly, behind your back, in group chats you’ll never see. Stalker just makes the judgment visible and permanent.&lt;/p&gt;

&lt;p&gt;Is that dystopian? Probably.&lt;br /&gt;
Is it useful? Sometimes.&lt;br /&gt;
Is it going to make some people uncomfortable? Definitely.&lt;/p&gt;

&lt;p&gt;I didn’t build this to make friends or fix society.&lt;br /&gt;
I built it because I wanted to see what happens when you give students a public scoreboard for each other and let the numbers do the talking.&lt;/p&gt;

&lt;p&gt;The database is live.&lt;br /&gt;
The scores are moving.&lt;br /&gt;
And if yours is low, well… now you know.&lt;/p&gt;

&lt;p&gt;→ &lt;a href=&quot;https://rate.pythonanywhere.com/&quot;&gt;Try Stalker here&lt;/a&gt;&lt;/p&gt;
</description>
        <pubDate>Fri, 31 Jul 2026 14:17:00 +0000</pubDate>
        <link>https://matooly.github.io/code/2026/07/31/stalker.html</link>
        <guid isPermaLink="true">https://matooly.github.io/code/2026/07/31/stalker.html</guid>
        
        
        <category>code</category>
        
      </item>
    
      <item>
        <title>Dev-Log: Breaking the Rubika Web SPA (A Descent into Madness)</title>
        <description>&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;03:20 AM, deep flow state.&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;Espresso number three is keeping the lights on. Selenium has trust issues, Angular has declared war on me, and I’m becoming increasingly convinced the DOM tree knows exactly what it’s doing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1 id=&quot;lets-build-a-headless-rubika-broadcaster&quot;&gt;Let’s build a headless Rubika broadcaster.&lt;/h1&gt;

&lt;p&gt;First, we thought it would be easy. Boot up Selenium, log in, search for the user, click the “Message” button, and hit send. It’s just basic web automation, right? Find element by XPath, send keys, click enter. Five minutes, in and out.&lt;/p&gt;

&lt;p&gt;Oh, how naive. I am documenting this descent into madness so I never forget the absolute chaos of trying to automate a modern, aggressively sandboxed Single Page Application (SPA). Here is the exact anatomy of how this app fought back at every single step, and the increasingly unhinged lengths we had to go to in order to break it. You can see in the text that I keep using the word “We” which is refereeing my team (me, Claude and gemini).&lt;/p&gt;

&lt;h1 id=&quot;phase-1-the-frankenstein-spa--the-offline-mode-curse&quot;&gt;Phase 1: The Frankenstein SPA &amp;amp; The Offline Mode Curse&lt;/h1&gt;

&lt;p&gt;Our first goal was persisting the login session. Nobody wants to scan a QR code or type an SMS OTP every time they run a script. I tried the classic route: manually ripping out the authentication cookies and injecting LocalStorage into the driver using a saved JSON file.&lt;/p&gt;

&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;

&lt;p&gt;The bot would launch, bypass the login screen perfectly, and immediately crash into a terrifying “half-loaded” state. The green background rendered, but the UI was shattered. The console was screaming about missing assets. Why? Because Web 3.0 SPAs don’t just use cookies anymore. They rely heavily on IndexedDB for chat histories and service workers for state routing. By injecting LocalStorage without the accompanying local databases, we created a Frankenstein browser state. The framework woke up, realized half its brain was missing, assumed we had no internet connection, and threw itself into a fatal offline loop.&lt;/p&gt;

&lt;h2 id=&quot;the-fix&quot;&gt;The Fix&lt;/h2&gt;

&lt;p&gt;We threw away the manual injection entirely and stopped trying to outsmart the browser. We switched to a Native Chrome Profile (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--user-data-dir&lt;/code&gt;). We literally just told Chrome, “Hey, save your own databases to this folder.” The next time the bot spun up, it was natively logged in with perfectly synced, uncorrupted databases. The UI loaded flawlessly. One crisis averted.&lt;/p&gt;

&lt;h1 id=&quot;phase-2-the-virtualized-scrolling-nightmare-the-disappearing-dom&quot;&gt;Phase 2: The Virtualized Scrolling Nightmare (The Disappearing DOM)&lt;/h1&gt;

&lt;p&gt;We survived the login. Next up: building the Data Scraper to extract post prices and engagement metrics from the shop’s profile grid. We just needed to scroll down the user’s feed, click each post, grab the data, and go back. A standard &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;body.scrollHeight&lt;/code&gt; loop, right? Absolutely not.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-1&quot;&gt;The Problem&lt;/h2&gt;

&lt;p&gt;Rubika uses a highly aggressive virtualized grid to save memory. As you scroll down, the app actively deletes older posts from the raw HTML. If we told Selenium to scroll to the bottom, the posts vanished from RAM before the bot could even click them. If we tried to iterate through a pre-counted list of 50 posts, the script crashed with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;StaleElementReferenceException&lt;/code&gt; by post #12 because the target no longer existed.&lt;/p&gt;

&lt;p&gt;To make matters worse, the UI renders the grid twice during CSS transitions. This leaves invisible “ghost” tiles sitting in the code with stale X/Y coordinates. When Selenium blindly clicked the first &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag it found, it clicked the ghost, triggering a fatal &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ElementClickInterceptedException&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-fix-1&quot;&gt;The Fix&lt;/h2&gt;

&lt;p&gt;After burning far too many hours trying every conventional Selenium trick in the book, I handed the problem to Claude. It spent quite a while iterating through different approaches before finally landing on a solution that actually understood how Rubika’s virtualized grid behaved.&lt;/p&gt;

&lt;p&gt;We had to completely abandon traditional scraping and build a custom spatial-awareness engine.&lt;/p&gt;

&lt;p&gt;First, we stopped scrolling globally. We wrote a custom JavaScript injector to target the exact hidden scrollable container (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.rtl-l3f8vc&lt;/code&gt;) and scroll by exactly 250 pixels at a time—just enough to load the next row without un-mounting the current one.&lt;/p&gt;

&lt;p&gt;Second, we stopped trusting the HTML. We used JavaScript &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getBoundingClientRect()&lt;/code&gt; to map out the physical X/Y coordinates of every tile on the screen, deduplicating them by their actual location on the monitor rather than their DOM tags. Finally, we used &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;document.elementFromPoint(cx, cy)&lt;/code&gt; to shoot a virtual raycast at the center of the tile. If the ray hit an invisible overlay instead of the post, the script knew it was a ghost, ignored it, and moved on.&lt;/p&gt;

&lt;p&gt;We beat the grid.&lt;/p&gt;

&lt;h1 id=&quot;phase-3-angulars-state-manager-the-invisible-wall&quot;&gt;Phase 3: Angular’s State Manager (The Invisible Wall)&lt;/h1&gt;

&lt;p&gt;Okay, so we are logged in. We successfully navigated to a profile via the Vitrin search and used a hardware-level click to trigger the &lt;strong&gt;Message&lt;/strong&gt; button. The chat UI slides open. We told Selenium to locate the text box and drop the payload using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.send_keys()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing happened. The box stayed empty. The send button stayed greyed out.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-2&quot;&gt;The Problem&lt;/h2&gt;

&lt;p&gt;Inspecting the DOM revealed an absolute nightmare of frontend engineering.&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;draftMessage&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;ng-trim=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;false&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;ng-untouched ng-pristine&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;style=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;display: none;&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Rubika’s web client is heavily driven by Angular. The chat input wasn’t a standard HTML &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;input&amp;gt;&lt;/code&gt; tag; it was a layered &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;div&amp;gt;&lt;/code&gt; with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;contenteditable=&quot;true&quot;&lt;/code&gt;, sitting under an invisible placeholder &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;span&lt;/code&gt;, sitting next to a hidden &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;textarea&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When Selenium forces text into a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;div&lt;/code&gt; via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;send_keys()&lt;/code&gt;, it bypasses the operating system and violently injects the text straight into the DOM. Angular’s state manager (the thing tracking the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ng-pristine&lt;/code&gt; class) completely ignores this because it demands a literal OS-level hardware keystroke to register a change. We tried &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ActionChains&lt;/code&gt;. We tried native clipboard pasting (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pyperclip&lt;/code&gt;). The framework swallowed all of it.&lt;/p&gt;

&lt;h2 id=&quot;the-fix-2&quot;&gt;The Fix&lt;/h2&gt;

&lt;p&gt;A pure JavaScript bypass. We had to stop asking Selenium to act like a user and instead hijack the browser’s JavaScript engine directly. We injected this exact payload:&lt;/p&gt;

&lt;div class=&quot;language-javascript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;box&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;arguments&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;box&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;innerText&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;arguments&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Inject the raw string directly into the node&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;box&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;dispatchEvent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;bubbles&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}));&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Force Angular to wake up&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dispatchEvent&lt;/code&gt; was the silver bullet. We sent a fake, bubbling input event up the DOM tree. Angular intercepted it, updated its internal tracking, flipped the class to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ng-dirty&lt;/code&gt;, and finally turned the Send button blue.&lt;/p&gt;

&lt;h1 id=&quot;phase-4-chasing-ghost-elements&quot;&gt;Phase 4: Chasing Ghost Elements&lt;/h1&gt;

&lt;p&gt;So the text injection works. Great. We run the script again. It opens the chat, executes the JavaScript, and… the text doesn’t appear. No errors. No crashes. Just silence.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-3&quot;&gt;The Problem&lt;/h2&gt;

&lt;p&gt;SPAs are incredibly lazy. When you navigate away from a chat page, the framework doesn’t delete the old HTML from your RAM; it just hides it by setting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;display: none;&lt;/code&gt;. Selenium was blindly searching the document for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;//div[contains(@class, &apos;composer_rich_textarea&apos;)]&lt;/code&gt;. It was finding an inactive, hidden chat box from a completely different screen that was still buried in the DOM, and silently typing our message into the void while the real, visible chat box sat entirely empty.&lt;/p&gt;

&lt;h2 id=&quot;the-fix-3&quot;&gt;The Fix&lt;/h2&gt;

&lt;p&gt;We built a DOM filter. Instead of targeting one element, we scraped every single text box in the raw HTML and ran a hard &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.is_displayed()&lt;/code&gt; boolean check on each one. The script iterated through the ghosts until it locked onto the active UI element physically visible on the monitor.&lt;/p&gt;

&lt;h1 id=&quot;phase-5-the-iframe-sandbox-the-final-boss&quot;&gt;Phase 5: The Iframe Sandbox (The Final Boss)&lt;/h1&gt;

&lt;p&gt;We are in the home stretch. Mode 1 (Targeted User) works perfectly. I moved on to Mode 2 (Broadcast to All Contacts). The bot opens the contact list, scrapes the names, searches them up, clicks their profile, the chat opens… and Selenium throws a fatal 15-second &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimeoutException&lt;/code&gt; looking for the text box.&lt;/p&gt;

&lt;p&gt;I was losing my mind. I could physically see the text box on my screen. Why was Selenium suddenly blind?&lt;/p&gt;

&lt;p&gt;I wrote a massive diagnostic block to pause the script, dump the raw HTML to a text file, and take a screenshot of what Selenium’s engine was seeing. The screenshot showed the chat UI perfectly. But when I searched the 1800-line HTML dump for the text box class, I got 0 results.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-4&quot;&gt;The Problem&lt;/h2&gt;

&lt;p&gt;I scrolled to the very bottom of the raw HTML dump and found this monstrosity:&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;iframe&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;title=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;test&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;src=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/messenger/index.html?cache=2.5.8...&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;sandbox=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;allow-same-origin allow-scripts...&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;They put the entire chat interface inside an isolated, sandboxed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt;. The profile page is the parent document, but the actual messaging engine renders inside a portal. Selenium was scanning the main parent document, staring blankly at the profile page, completely unaware that the text box was inside the sandbox.&lt;/p&gt;

&lt;h2 id=&quot;the-fix-4&quot;&gt;The Fix&lt;/h2&gt;

&lt;p&gt;We had to breach the sandbox. Before looking for the text box, we commanded Selenium’s internal context to step inside the iframe:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;iframe_xpath&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;//iframe[contains(@src, &apos;openchat&apos;)]&quot;&lt;/span&gt;
&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;wait&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;until&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;EC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;frame_to_be_available_and_switch_to_it&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;By&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;XPATH&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;iframe_xpath&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Once inside, the entire chat DOM became visible. We dropped the Angular payload, hit ENTER, and then—crucially—executed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;self.driver.switch_to.default_content()&lt;/code&gt; to step back out into the main document so the script wouldn’t crash when it tried to search for the next user.&lt;/p&gt;

&lt;p&gt;By the time it was over, the bot could launch a persistent Chrome profile, navigate the SPA, bypass Angular’s input validation, distinguish visible elements from hidden ghosts, step into sandboxed iframes, deliver the message, and continue to the next user without losing its place.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/MattTheGreat-root/Rubino&quot;&gt;Check out the full project here&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of these problems were difficult because the code was complicated. They were difficult because every layer of the application assumed a human would always be sitting behind a browser. Every  step was less about writing automation and more about understanding how the application actually behaved beneath the UI. Goodnight!&lt;/p&gt;
</description>
        <pubDate>Mon, 06 Jul 2026 03:20:00 +0000</pubDate>
        <link>https://matooly.github.io/code/2026/07/06/spa.html</link>
        <guid isPermaLink="true">https://matooly.github.io/code/2026/07/06/spa.html</guid>
        
        
        <category>code</category>
        
      </item>
    
      <item>
        <title>Do not go gentle into that good night</title>
        <description>&lt;p&gt;Do not go gentle into that good night,&lt;br /&gt;
Old age should burn and rave at close of day;&lt;br /&gt;
Rage, rage against the dying of the light.&lt;/p&gt;

&lt;p&gt;Though wise men at their end know dark is right,&lt;br /&gt;
Because their words had forked no lightning they&lt;br /&gt;
Do not go gentle into that good night.&lt;/p&gt;

&lt;p&gt;Good men, the last wave by, crying how bright&lt;br /&gt;
Their frail deeds might have danced in a green bay,&lt;br /&gt;
Rage, rage against the dying of the light.&lt;/p&gt;

&lt;p&gt;Wild men who caught and sang the sun in flight,&lt;br /&gt;
And learn, too late, they grieved it on its way,&lt;br /&gt;
Do not go gentle into that good night.&lt;/p&gt;

&lt;p&gt;Grave men, near death, who see with blinding sight&lt;br /&gt;
Blind eyes could blaze like meteors and be gay,&lt;br /&gt;
Rage, rage against the dying of the light.&lt;/p&gt;

&lt;p&gt;And you, my father, there on the sad height,&lt;br /&gt;
Curse, bless, me now with your fierce tears, I pray.&lt;br /&gt;
Do not go gentle into that good night.&lt;br /&gt;
Rage, rage against the dying of the light.&lt;/p&gt;
</description>
        <pubDate>Fri, 19 Jun 2026 02:31:00 +0000</pubDate>
        <link>https://matooly.github.io/thoughts/2026/06/19/rage.html</link>
        <guid isPermaLink="true">https://matooly.github.io/thoughts/2026/06/19/rage.html</guid>
        
        
        <category>thoughts</category>
        
      </item>
    
      <item>
        <title>I wish there was a war.</title>
        <description>&lt;p&gt;&lt;strong&gt;Dear Edward,&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This just serves to acknowledge receipt of yours per Cap Lowndes which was delivered me yesterday. The truth of Cap Lightbowen &amp;amp; Lowndes information is now verifyd by the presence of your Father and Sister for whose safe arrival I Pray, and that they may convey that satisfaction to your soul that must naturally flow from the sight of absent Friends in health, and shall for news this way refer you to them.&lt;/p&gt;

&lt;p&gt;As to what you say respecting your having soon the happiness of seeing us all, I wish, for an accomplishment of your hopes provided they are Concomitant with your welfare, otherwise not, tho doubt whether I shall be Present or not.&lt;/p&gt;

&lt;p&gt;For to confess my weakness, Ned, my Ambition is prevalent. That I contemn the grov’ling condition of a Clerk or the like, to which my Fortune &amp;amp;c condemns me and would willingly risk my life though not my Character to exalt my station. I’m confident, Ned that my Youth excludes me from any hopes of immediate Perferment, nor do I desire it, but I mean to prepare the way for futurity.&lt;/p&gt;

&lt;p&gt;I’m no Philosopher you see and may be justly said to Build Castles in the Air. My Folly makes me ashamed and beg youll conceal it, yet Neddy we have seen such schemes successful when the Projector is Constant.&lt;/p&gt;

&lt;p&gt;I shall Conclude saying I wish there was a war.&lt;/p&gt;

&lt;p&gt;I am,&lt;br /&gt;
Dr Edward,&lt;br /&gt;
Yours,&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alex Hamilton&lt;/strong&gt;&lt;/p&gt;
</description>
        <pubDate>Mon, 15 Jun 2026 12:00:00 +0000</pubDate>
        <link>https://matooly.github.io/thoughts/2026/06/15/war.html</link>
        <guid isPermaLink="true">https://matooly.github.io/thoughts/2026/06/15/war.html</guid>
        
        <category>featured</category>
        
        
        <category>thoughts</category>
        
      </item>
    
      <item>
        <title>The Oldest Fear: K2-18b, The Great Filter, and the Silence of the Stars</title>
        <description>&lt;p&gt;It was yesterday morning when I first heard the news that the James Webb telescope had found signs of life on a planet outside our solar system. Ngl, I was fascinated. Here it goes:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;“K2-18b, located 124 light-years from Earth, could be a Hycean world: a potentially habitable planet entirely covered in liquid water with a hydrogen-rich atmosphere,” said lead study author Nikku Madhusudhan, professor of astrophysics and exoplanetary science at the University of Cambridge’s Institute of Astronomy. “Earlier theoretical work had predicted that high levels of sulfur-based gases like DMS and DMDS are possible on Hycean worlds. And now we’ve observed it, in line with what was predicted. Given everything we know about this planet, a Hycean world with an ocean that is teeming with life is the scenario that best fits the data we have.” 
— &lt;strong&gt;CNN&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;blockquote&gt;
  &lt;p&gt;“The team sidestepped this challenge by analyzing light from K2-18 b’s parent star as it passed through the exoplanet’s atmosphere. K2-18 b is a transiting exoplanet, meaning that we can detect a drop in brightness as it passes across the face of its host star. This is how the exoplanet was first discovered in 2015 with NASA’s K2 mission. This means that during transits a tiny fraction of starlight will pass through the exoplanet’s atmosphere before reaching telescopes like Webb. The starlight’s passage through the exoplanet atmosphere leaves traces that astronomers can piece together to determine the gases of the exoplanet’s atmosphere.” 
— &lt;strong&gt;NASA&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Read the full articles here:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://edition.cnn.com/2025/04/17/science/k218b-potential-biosignature-webb/index.html&quot;&gt;Scientists detect signature of life on a distant planet, study suggests&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://science.nasa.gov/missions/webb/webb-discovers-methane-carbon-dioxide-in-atmosphere-of-k2-18-b/&quot;&gt;Webb Discovers Methane, Carbon Dioxide in Atmosphere of K2-18 b&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As said in the articles, this is the strongest evidence humans have ever had for life on other planets. If this is true, it completely shifts our perspective. It means life in our galaxy is not as rare as we thought it would be—it might even be inevitable.&lt;/p&gt;

&lt;p&gt;I’ve seen Floppy Disks, Smart Phones, Quantum Computers, Artificial Intelligence and now Aliens. What an era to live in. What a time to be alive.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;em&gt;“The oldest and strongest emotion of mankind is fear. And the oldest and strongest kind of fear is the fear of the unknown.”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;First things first, let’s ask this question: &lt;strong&gt;What are we actually looking for?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When we stare into the void, we need some kind of framework to make us search better. We need to identify the universal rules that intelligent species follow. To do that, we compare them to ourselves, looking at the energy we consume and the work that we have done.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-kardashev-scale&quot;&gt;The Kardashev Scale&lt;/h2&gt;
&lt;p&gt;This framework ranks hypothetical civilizations based on their energy consumption and technological reach. It scales into different categories:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Type 1:&lt;/strong&gt; Is able to harness and use the available energy of their entire home planet.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Type 2:&lt;/strong&gt; Is able to harness and use the available energy of their star and planetary system (like building a Dyson sphere to capture starlight).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Type 3:&lt;/strong&gt; Is able to use and control the available energy of their entire galaxy.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Type 4:&lt;/strong&gt; Is able to use the available energy of multiple galaxies, mastering the visible universe.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the catch: If there were any Type 3 or higher civilizations, we would have noticed their massive energy signatures by now. After all, a civilization that spans an entire galaxy couldn’t hide, and they certainly couldn’t have missed us. So this means any type of life outside our solar system, including whatever might be in the oceans of K2-18b, can only be Type 1 or Type 2.&lt;/p&gt;

&lt;p&gt;But if the universe is this old, and life is this common, where are they?&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;em&gt;“Two possibilities exist: Either we are alone in the universe or we are not. Both are equally terrifying.”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-fermi-paradox&quot;&gt;The Fermi Paradox&lt;/h2&gt;
&lt;p&gt;Enrico Fermi grasped a deeply unsettling mathematical reality: any civilization with a modest amount of rocket technology and an immodest amount of imperial incentive could rapidly colonize the entire galaxy within a few million years. On a cosmic timescale, that is practically overnight.&lt;/p&gt;

&lt;p&gt;We look up at a sky filled with billions of stars, many much older than our own sun. Yet, we hear absolutely nothing. Just radio static. I mean, if we’re not alone, why is it so deafeningly quiet out there?&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-great-filter&quot;&gt;The Great Filter&lt;/h2&gt;
&lt;p&gt;One of the most well-known, and most dread-inducing, explanations for this paradox is that there is a “Great Filter.”&lt;/p&gt;

&lt;p&gt;The theory suggests there might be a hard step in the evolution of life that’s extremely rare—either before intelligent life arises or still ahead of us. In other words: Some step between the start of life and becoming a galaxy-spanning civilization acts as an impossible wall. It is so hard to get past that almost no one makes it.&lt;/p&gt;

&lt;p&gt;In our own civilization, this leads to two scenarios:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;1. The Filter is Behind Us:&lt;/strong&gt; If the hardest step is already behind us—like the initial leap from simple to complex life—then we might be incredibly lucky. We are the survivors. Maybe we’re one of the only ones to make it this far.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;2. The Filter is Ahead of Us:&lt;/strong&gt; If the hardest step is still ahead—like surviving our own technology, artificial intelligence, nuclear war, or climate collapse—then finding life on K2-18b is bad news. It would mean life starts easily but most civilizations self-destruct before reaching the stars, and we might too.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;the-ultimate-unknown&quot;&gt;The Ultimate Unknown&lt;/h2&gt;
&lt;p&gt;So, what does this all mean for us? Finding signs of an ocean world 124 light-years away doesn’t just change science; it fundamentally changes how we view our place in the universe.&lt;/p&gt;

&lt;p&gt;When I look up at the night sky now, I don’t just see the quiet mechanics of stars and distant planets. I see a massive, silent test. If something is out there on K2-18b, breathing in their hydrogen-rich atmosphere, I wonder if they look at their own sky and ask the exact same questions. Until we know the answer, we just have to keep training our models, building our future, and hoping the Great Filter is a hurdle humanity has already cleared.&lt;/p&gt;
</description>
        <pubDate>Sat, 19 Apr 2025 23:00:00 +0000</pubDate>
        <link>https://matooly.github.io/space/2025/04/19/life.html</link>
        <guid isPermaLink="true">https://matooly.github.io/space/2025/04/19/life.html</guid>
        
        
        <category>space</category>
        
      </item>
    
  </channel>
</rss>
