<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  
  <title>Rahul&#39;s Journal</title>
  <subtitle>Building things, breaking things, and everything in between.</subtitle>
  <link href="https://blog.vrp2206.com/feed.xml" rel="self" />
  <link href="https://blog.vrp2206.com/" />
  <updated>2026-10-29T00:00:00Z</updated>
  <id>https://blog.vrp2206.com/</id>
  <author>
    <name>Rahul Vedant Pemsing</name>
  </author>
  <entry>
    <title>My pre-first act as AWS club president was a disaster (and I learned a lot)</title>
    <link href="https://blog.vrp2206.com/posts/pre-first-act-aws-club-president/" />
    <updated>2026-10-29T00:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/pre-first-act-aws-club-president/</id>
    <content type="html">&lt;p&gt;Technically, I&#39;m not president of the AWS Student Builder Group until 2027. So tonight was my pre-first act: the first time I stood up in front of a room and spoke for the club.&lt;/p&gt;
&lt;p&gt;It did not go well.&lt;/p&gt;
&lt;p&gt;I&#39;m writing this while it still stings, because I&#39;d rather learn from it properly than pretend it went fine.&lt;/p&gt;
&lt;h2 id=&quot;the-setup&quot;&gt;The setup&lt;/h2&gt;
&lt;p&gt;Tonight was Monash University Malaysia&#39;s first-ever Tech Club Award Night: the campus tech clubs in one badminton court, with food, a lucky draw and awards. Every club had to send at least one representative (no one there meant your award nominations were forfeited), and each president had to present a one-slide recap of their year.&lt;/p&gt;
&lt;p&gt;Mohamad, who started the AWS Student Builder Group, couldn&#39;t make it. As the incoming president, I said I&#39;d go.&lt;/p&gt;
&lt;h2 id=&quot;what-i-prepared&quot;&gt;What I prepared&lt;/h2&gt;
&lt;p&gt;On paper, I was ready.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The slide:&lt;/strong&gt; a timeline of our year, from our Cloud Fundamentals crash course in March to the AWS × Axrail AI site visit in October, with our numbers across the top: &lt;strong&gt;10 events, 310 sign-ups, 291 attendances and 181 students&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The script:&lt;/strong&gt; about two minutes, with every number highlighted so I could find my place.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The outfit:&lt;/strong&gt; a charcoal suit with a knit polo. I genuinely asked whether a suit was too much.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here&#39;s the catch. The slide was still changing on the afternoon of the event, and the script was finished less than two hours before I had to be there. That left under two hours to learn it, get changed and get to campus.&lt;/p&gt;
&lt;p&gt;It also landed in one of the busiest stretches of my semester. I was preparing for a massive exam while finishing a big group assignment and an individual one. As well as preping for my internship starting a little bit after the exam period.  That&#39;s not an excuse, since everyone in that room was juggling a semester too. It&#39;s just the context: the presentation was one more thing on the list, and it got whatever time was left over.&lt;/p&gt;
&lt;p&gt;You can probably see where this is going.&lt;/p&gt;
&lt;h2 id=&quot;what-actually-happened&quot;&gt;What actually happened&lt;/h2&gt;
&lt;p&gt;I&#39;m not going to dress it up: I messed up the presentation.&lt;/p&gt;
&lt;p&gt;I was the last club to present, and by the time my turn came, the weight of it had fully set in. I was underprepared, and nothing I&#39;d done that afternoon had got me ready for the real thing: standing up and doing it live.&lt;/p&gt;
&lt;p&gt;So I rushed. I wasn&#39;t even reading off my notes. I went completely off script, tripped over my words, and spent most of it pointing at the monitor instead of looking at the audience.&lt;/p&gt;
&lt;p&gt;I felt terrible walking off. The club had a genuinely great first year, and my two minutes didn&#39;t do it justice. Mohamad trusted me to represent the club he built, and it felt like I&#39;d let him down.&lt;/p&gt;
&lt;p&gt;Should I have prepared more? Could I have? Honestly, yes to both.&lt;/p&gt;
&lt;h2 id=&quot;everything-i-need-to-improve&quot;&gt;Everything I need to improve&lt;/h2&gt;
&lt;p&gt;This is the useful bit. Here&#39;s my list:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Finish early, then rehearse.&lt;/strong&gt; Done two hours before isn&#39;t the same as ready. Next time the slide and script get locked a few days out, and the time before the event goes to practice.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rehearse out loud.&lt;/strong&gt; Reading a script in your head isn&#39;t practice. Standing up, saying it out loud and timing it is.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Learn the structure, not every word.&lt;/strong&gt; Opening, numbers, timeline, thank you. If I know the four beats, one missed line doesn&#39;t sink the whole thing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep a 30-second version ready.&lt;/strong&gt; Events run late and slots get squeezed. I should be able to give the short version without panicking.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check the setup before going up.&lt;/strong&gt; How long do I get? Who changes the slide? Is there a mic? Where do I stand?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Practise in front of real people.&lt;/strong&gt; My committee, friends, anyone. A room full of tech club presidents shouldn&#39;t be my first audience.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Have a recovery plan.&lt;/strong&gt; If I lose my place: pause, look at the slide, and pick up from the next point. Nobody remembers a pause. They remember a spiral.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;the-plan&quot;&gt;The plan&lt;/h2&gt;
&lt;p&gt;Presenting is now officially at the top of my list for 2027. Next year I&#39;ll be speaking at our own events, and every one of them is a rep. By the time I&#39;m actually president, standing up in front of a room should feel normal.&lt;/p&gt;
&lt;p&gt;The handover with Mohamad is coming up soon, and I&#39;ve got the semester break to plan. ROUND TWO WILL BE BETTER.&lt;/p&gt;
&lt;h2 id=&quot;the-silver-lining&quot;&gt;The silver lining&lt;/h2&gt;
&lt;p&gt;The slide was right, even if my delivery wasn&#39;t. Ten events, 291 attendances, 181 students and a six-week Cloud Practitioner series in the club&#39;s first year is something to be proud of. That&#39;s on Mohamad and the committee, and two shaky minutes from me don&#39;t change it.&lt;/p&gt;
&lt;p&gt;Pre-first act: disaster.&lt;/p&gt;
&lt;p&gt;First act: still compiling.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>FairSight revisited: the bias in my bias detector was twice what I claimed</title>
    <link href="https://blog.vrp2206.com/posts/fairsight-revisited/" />
    <updated>2026-10-23T10:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/fairsight-revisited/</id>
    <content type="html">&lt;p&gt;&lt;strong&gt;FairSight&lt;/strong&gt; was our group project for FIT1055 (IT Professional Practice and Ethics): a platform that audits AI hiring systems for bias and explains every decision. The project site is &lt;a href=&quot;https://fairsight.org&quot;&gt;fairsight.org&lt;/a&gt;. I built the Streamlit prototype, which is now back online at &lt;a href=&quot;https://prototype.fairsight.org&quot;&gt;prototype.fairsight.org&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Putting it back online was a good excuse to check its numbers properly, and they weren&#39;t what the code said they&#39;d be.&lt;/p&gt;
&lt;h2 id=&quot;what-the-prototype-does&quot;&gt;What the prototype does&lt;/h2&gt;
&lt;p&gt;It&#39;s a dashboard with five pages:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Overview and bias metrics:&lt;/strong&gt; hire rates by gender, score distributions, and the effect of gender-coded keywords.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Explainability:&lt;/strong&gt; which features drive the model&#39;s scores, overall and for any one applicant.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mitigation:&lt;/strong&gt; apply a fix and compare the metrics before and after.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Applicant view:&lt;/strong&gt; &amp;quot;why was this decision made?&amp;quot; in plain language, plus a human review queue.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audit and compliance report:&lt;/strong&gt; a downloadable summary of all of the above.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;the-data-a-deliberately-biased-model&quot;&gt;The data: a deliberately biased model&lt;/h2&gt;
&lt;p&gt;There&#39;s no real hiring data to work with, so the prototype generates &lt;strong&gt;1,200 synthetic applicants&lt;/strong&gt;, modelled on the widely reported case of Amazon&#39;s experimental recruiting tool, scrapped in 2018 for penalising women&#39;s CVs.&lt;/p&gt;
&lt;p&gt;Each applicant gets merit features (technical score, interview score, years of experience). Then the &amp;quot;model&amp;quot; scores them with &lt;strong&gt;three discriminatory penalties&lt;/strong&gt; built in on purpose:&lt;/p&gt;
&lt;pre class=&quot;language-python&quot;&gt;&lt;code class=&quot;language-python&quot;&gt;gender_penalty  &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.18&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt; is_female
keyword_penalty &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.04&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;gender_coded_keywords&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
college_penalty &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.10&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;*&lt;/span&gt; womens_college
final_score &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; base_score &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; gender_penalty &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; keyword_penalty &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; college_penalty&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The top 30% of scores are hired. The generator&#39;s docstring says this produces a hire rate for women &lt;strong&gt;about 25 percentage points lower&lt;/strong&gt; than for men.&lt;/p&gt;
&lt;h2 id=&quot;what-it-actually-produces&quot;&gt;What it actually produces&lt;/h2&gt;
&lt;p&gt;Running the project&#39;s own metrics on its own data:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Male hire rate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;54.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;353 of 653 men&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Female hire rate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.3%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7 of 547 women&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Demographic parity difference&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.528&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A 52.8-point gap, not 25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disparate impact ratio&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.024&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Women are hired at 2.4% of men&#39;s rate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Composite bias score&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.99&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On a 0–1 scale&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A worked example of the two headline metrics, using these numbers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Demographic parity difference&lt;/strong&gt; = male hire rate − female hire rate = 0.541 − 0.013 = &lt;strong&gt;0.528&lt;/strong&gt;. Zero would mean equal hire rates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disparate impact ratio&lt;/strong&gt; = female hire rate ÷ male hire rate = 0.013 ÷ 0.541 ≈ &lt;strong&gt;0.024&lt;/strong&gt;. The usual threshold is the &lt;strong&gt;four-fifths rule&lt;/strong&gt;: below 0.8 is evidence of adverse impact. This one is more than 30 times below it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;why-the-gap-doubled&quot;&gt;Why the gap doubled&lt;/h2&gt;
&lt;p&gt;The penalties are large compared with the spread of scores:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The hiring cut-off (the 70th-percentile score) is about &lt;strong&gt;0.44&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;average&lt;/strong&gt; total penalty for a woman is &lt;strong&gt;0.32&lt;/strong&gt;: 0.18 for gender, plus roughly 0.12 for gender-coded keywords (women average about three), plus the college penalty for one in four.&lt;/li&gt;
&lt;li&gt;The average penalty for a man is &lt;strong&gt;0.02&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A penalty of 0.32 against a cut-off of 0.44 means almost no woman can be hired, however strong her CV. The penalties &lt;em&gt;stack&lt;/em&gt;, and the docstring described one of them, not all three together.&lt;/p&gt;
&lt;p&gt;For a teaching demo, an extreme bias still makes the point. But a tool whose whole purpose is &lt;strong&gt;honest measurement&lt;/strong&gt; shouldn&#39;t misstate its own numbers. The fix is to tune the penalties until the measured gap matches the documented one, and to print the measured value in the docs rather than an intended one.&lt;/p&gt;
&lt;h2 id=&quot;the-mitigation-only-works-because-we-wrote-the-bias&quot;&gt;The &amp;quot;mitigation&amp;quot; only works because we wrote the bias&lt;/h2&gt;
&lt;p&gt;The mitigation page says it applies &lt;strong&gt;fairness-aware sample reweighting&lt;/strong&gt;. The code does something simpler: it recomputes every score from the merit features alone, without the three penalty terms. The result looks great:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Female hire rate&lt;/td&gt;
&lt;td&gt;1.3%&lt;/td&gt;
&lt;td&gt;29.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Male hire rate&lt;/td&gt;
&lt;td&gt;54.1%&lt;/td&gt;
&lt;td&gt;30.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disparate impact ratio&lt;/td&gt;
&lt;td&gt;0.024&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.988&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;But this only works because &lt;strong&gt;we know exactly where the bias is&lt;/strong&gt;: we put it there. In a real system you don&#39;t get a line saying &lt;code&gt;gender_penalty = 0.18&lt;/code&gt;. Bias comes in through &lt;strong&gt;proxies&lt;/strong&gt;: a women&#39;s college, a gap in employment, a hobby, wording in a CV. Deleting the obvious column (&amp;quot;fairness through unawareness&amp;quot;) leaves the proxies doing the same job. That&#39;s how Amazon&#39;s model penalised women without ever being given gender.&lt;/p&gt;
&lt;p&gt;So the honest label for that page is &amp;quot;&lt;strong&gt;what the hire rates would be without the planted bias&lt;/strong&gt;&amp;quot;, which is still a useful comparison. Real reweighting would weight training examples so that each group&#39;s outcomes are balanced, without assuming you know the cause.&lt;/p&gt;
&lt;h2 id=&quot;the-ethics-part&quot;&gt;The ethics part&lt;/h2&gt;
&lt;p&gt;FIT1055 was an ethics unit, so the prototype came with &lt;strong&gt;ethical pseudocode&lt;/strong&gt; mapping every module to an ethical framework. We chose &lt;strong&gt;deontology&lt;/strong&gt; as the main framework, because it turns into hard rules code can enforce:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;RULE 1: GENDER_PENALTY          = FORBIDDEN
RULE 2: COLLEGE_TYPE_PENALTY    = FORBIDDEN
RULE 3: BIAS_DISCLOSURE         = MANDATORY
RULE 4: HUMAN_REVIEW_RIGHT      = PROTECTED
RULE 5: MERIT_FEATURES_ONLY     = REQUIRED&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Virtue ethics&lt;/strong&gt; came second, for the system&#39;s character: honesty in explanations, justice in scoring, responsibility for logging and flagging. We rejected pure &lt;strong&gt;utilitarianism&lt;/strong&gt;, because maximising the overall hire rate could still justify penalising one group.&lt;/p&gt;
&lt;p&gt;Rereading it now, my own findings above are RULE 3 in action. A bias tool that understates bias, or labels a fix as something it isn&#39;t, breaks the disclosure rule it was built to enforce.&lt;/p&gt;
&lt;h2 id=&quot;what-i-took-away&quot;&gt;What I took away&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Measure, don&#39;t assume.&lt;/strong&gt; The docstring described the intended bias. One run of the metrics would have shown the real one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Name things for what they do.&lt;/strong&gt; &amp;quot;Reweighting&amp;quot; and &amp;quot;remove the terms we added&amp;quot; are different techniques, and the difference is exactly what matters in the real world.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Synthetic data proves the plumbing, not the method.&lt;/strong&gt; The dashboard, metrics and explanations work. Whether the mitigation works can only be tested on bias you didn&#39;t write yourself.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <title>Rereading my pathfinding visualiser: A*, Dijkstra, and an RRT that never arrives</title>
    <link href="https://blog.vrp2206.com/posts/pathfinding-revisited/" />
    <updated>2026-10-23T08:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/pathfinding-revisited/</id>
    <content type="html">&lt;p&gt;One of my first projects at Monash (for FIT1045) was a &lt;strong&gt;2D pathfinding visualiser&lt;/strong&gt;: draw obstacles on a grid, pick start and end points, and watch an algorithm find a path. It&#39;s back online at &lt;a href=&quot;https://pathfinding.vrp2206.com&quot;&gt;pathfinding.vrp2206.com&lt;/a&gt; as part of &lt;a href=&quot;https://blog.vrp2206.com/posts/old-projects-back-online/&quot;&gt;putting old projects back online&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Before linking it from my portfolio, I reread the code and, for the first time, benchmarked it properly.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;/h2&gt;
&lt;p&gt;Built with &lt;strong&gt;Streamlit&lt;/strong&gt; and &lt;strong&gt;Plotly&lt;/strong&gt;, it offers three algorithms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dijkstra:&lt;/strong&gt; expands outward in order of distance from the start. Guaranteed shortest path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A*:&lt;/strong&gt; Dijkstra plus a heuristic that estimates the remaining distance to the goal, so it explores towards the goal first. Also guaranteed shortest, if the heuristic never overestimates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RRT (Rapidly-exploring Random Tree):&lt;/strong&gt; a sampling-based planner from robotics. It grows a tree towards random points until a branch happens to get near the goal. No guarantee of the shortest path, or of any path.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The grid is &lt;strong&gt;8-connected&lt;/strong&gt;: you can move diagonally, and a diagonal step costs √2 ≈ 1.414 instead of 1.&lt;/p&gt;
&lt;h2 id=&quot;the-readme-describes-the-wrong-heuristic&quot;&gt;The README describes the wrong heuristic&lt;/h2&gt;
&lt;p&gt;The README says A* uses &lt;strong&gt;Manhattan distance&lt;/strong&gt;. The code uses &lt;strong&gt;Euclidean distance&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-python&quot;&gt;&lt;code class=&quot;language-python&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;distance&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;self&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; pos1&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; pos2&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; np&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;sqrt&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token builtin&quot;&gt;sum&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;a &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; b&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;**&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; a&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; b &lt;span class=&quot;token keyword&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;zip&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;pos1&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; pos2&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&#39;s a lucky mismatch, because the code is the right one. A* is only guaranteed to find the shortest path if its heuristic is &lt;strong&gt;admissible&lt;/strong&gt;: it must never &lt;em&gt;overestimate&lt;/em&gt; the real remaining cost.&lt;/p&gt;
&lt;p&gt;A worked example. Going from (0, 0) to (3, 3) on this grid:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Estimate or cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real cheapest path: 3 diagonal steps&lt;/td&gt;
&lt;td&gt;3 × √2 ≈ &lt;strong&gt;4.24&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Euclidean heuristic: √(3² + 3²)&lt;/td&gt;
&lt;td&gt;≈ &lt;strong&gt;4.24&lt;/strong&gt;, never more than the real cost ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manhattan heuristic: 3 + 3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;, an overestimate ✗&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;With diagonal moves, Manhattan distance overestimates, and A* could return a path that isn&#39;t the shortest. (The textbook choice for 8-connected grids is the &lt;em&gt;octile&lt;/em&gt; distance, which is exact on an empty grid. Euclidean is admissible too, just a little less sharp.) So the code is correct and the README needed fixing.&lt;/p&gt;
&lt;h2 id=&quot;the-benchmark&quot;&gt;The benchmark&lt;/h2&gt;
&lt;p&gt;The app has a comparison mode, but it times a single grid once. I wrote a small script that runs the project&#39;s own &lt;code&gt;AStar&lt;/code&gt;, &lt;code&gt;Dijkstra&lt;/code&gt; and &lt;code&gt;RRT&lt;/code&gt; classes on &lt;strong&gt;40 random grids per setting&lt;/strong&gt;, from the top-left corner to the bottom-right, skipping grids with no possible path:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Grid&lt;/th&gt;
&lt;th&gt;Algorithm&lt;/th&gt;
&lt;th&gt;Found a path&lt;/th&gt;
&lt;th&gt;Cells explored&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Path cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;15×15, 20% walls&lt;/td&gt;
&lt;td&gt;A*&lt;/td&gt;
&lt;td&gt;40 / 40&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;td&gt;1.1 ms&lt;/td&gt;
&lt;td&gt;20.71&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Dijkstra&lt;/td&gt;
&lt;td&gt;40 / 40&lt;/td&gt;
&lt;td&gt;184&lt;/td&gt;
&lt;td&gt;1.8 ms&lt;/td&gt;
&lt;td&gt;20.71&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;RRT&lt;/td&gt;
&lt;td&gt;39 / 40&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;36 ms&lt;/td&gt;
&lt;td&gt;24.62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50×50, 20% walls&lt;/td&gt;
&lt;td&gt;A*&lt;/td&gt;
&lt;td&gt;40 / 40&lt;/td&gt;
&lt;td&gt;357&lt;/td&gt;
&lt;td&gt;9.1 ms&lt;/td&gt;
&lt;td&gt;71.51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Dijkstra&lt;/td&gt;
&lt;td&gt;40 / 40&lt;/td&gt;
&lt;td&gt;2,048&lt;/td&gt;
&lt;td&gt;21.9 ms&lt;/td&gt;
&lt;td&gt;71.51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;RRT&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 40&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;304&lt;/td&gt;
&lt;td&gt;163 ms&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50×50, 30% walls&lt;/td&gt;
&lt;td&gt;A*&lt;/td&gt;
&lt;td&gt;36 / 36&lt;/td&gt;
&lt;td&gt;462&lt;/td&gt;
&lt;td&gt;11.7 ms&lt;/td&gt;
&lt;td&gt;72.91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Dijkstra&lt;/td&gt;
&lt;td&gt;36 / 36&lt;/td&gt;
&lt;td&gt;1,852&lt;/td&gt;
&lt;td&gt;19.1 ms&lt;/td&gt;
&lt;td&gt;72.91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;RRT&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 36&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;225&lt;/td&gt;
&lt;td&gt;128 ms&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;em&gt;Averages per grid. Path cost counts a diagonal step as √2. Times are from one Python run and only comparable within the table.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;what-the-numbers-say&quot;&gt;What the numbers say&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;&lt;em&gt;A&lt;/em&gt; and Dijkstra always agree on the path cost.&lt;/em&gt;* That&#39;s the check that matters most: both are supposed to be optimal, and on 116 grids they never disagreed.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;em&gt;A&lt;/em&gt; does the same job with far less searching.&lt;/em&gt;* On 50×50 grids it explored about &lt;strong&gt;5.7× fewer cells&lt;/strong&gt; than Dijkstra (357 vs 2,048). That gap is the whole point of a heuristic: Dijkstra spreads out in a circle, while A* leans towards the goal.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The time gap is smaller than the cell gap.&lt;/strong&gt; A* explores 5.7× fewer cells but is only about 2.4× faster, because each A* step also computes the heuristic. &amp;quot;Cells explored&amp;quot; is the fairer measure. Wall-clock time in Python measures the interpreter as much as the algorithm.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RRT never reaches the goal on a 50×50 grid.&lt;/strong&gt; Three reasons, all visible in the code:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;It stops after 500 iterations.&lt;/strong&gt; On a 15×15 grid that&#39;s plenty. On 50×50 the tree needs to grow about 70 cells&#39; worth of distance through obstacles, while most random samples pull it in other directions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It only grows one cell at a time&lt;/strong&gt; towards each random point, so progress is slow.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Finding the nearest tree node is a linear scan&lt;/strong&gt; over the whole tree every iteration, so it gets slower as the tree grows. That&#39;s why RRT is the slowest of the three, despite exploring fewer cells than Dijkstra.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Even when it succeeds on small grids, its paths cost about &lt;strong&gt;19% more&lt;/strong&gt; than the optimum (24.62 vs 20.71). RRT shines in continuous, high-dimensional spaces such as robot arms, where you can&#39;t list every cell. On a small 2D grid, A* is better in every way.&lt;/p&gt;
&lt;h2 id=&quot;what-i-d-change-now&quot;&gt;What I&#39;d change now&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fix the README&lt;/strong&gt; to say Euclidean, and explain why Manhattan would be wrong here.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Report path cost, not path length.&lt;/strong&gt; The app&#39;s &amp;quot;Path Length&amp;quot; counts cells, so ten diagonal steps and ten straight steps look the same, even though the diagonal path costs about 41% more.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skip stale queue entries in A*.&lt;/strong&gt; It pushes a cell again when it finds a better route, and later pops the old entry and expands it again. That&#39;s harmless for correctness here, but wasted work. One &lt;code&gt;if current in closed: continue&lt;/code&gt; fixes it, the same way the Dijkstra code already does.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give RRT a fair chance:&lt;/strong&gt; scale the iteration limit with the grid size, take longer steps, and add &lt;em&gt;goal bias&lt;/em&gt; (aim straight at the goal some fraction of the time).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Benchmark many grids, not one.&lt;/strong&gt; A single run tells you very little. Forty runs per setting made every pattern above obvious.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The code I wrote in first year was mostly right. The parts that were wrong were the claims around it: the README, the metric names and the comparison. That&#39;s what rereading old code is good for.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Putting three old projects back online with one Dockerfile</title>
    <link href="https://blog.vrp2206.com/posts/old-projects-back-online/" />
    <updated>2026-10-23T06:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/old-projects-back-online/</id>
    <content type="html">&lt;p&gt;Before the &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-origin/&quot;&gt;homelab&lt;/a&gt; had to survive three months without me, I wanted my older projects back on the web, linked from my portfolio, instead of sitting in repos nobody runs. Three of them were &lt;strong&gt;Streamlit&lt;/strong&gt; apps:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;From&lt;/th&gt;
&lt;th&gt;Now at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A turn-based roguelike&lt;/td&gt;
&lt;td&gt;FIT1045&lt;/td&gt;
&lt;td&gt;&lt;code&gt;roguelike.vrp2206.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A &lt;a href=&quot;https://blog.vrp2206.com/posts/pathfinding-revisited/&quot;&gt;2D pathfinding visualiser&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;FIT1045&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pathfinding.vrp2206.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://blog.vrp2206.com/posts/fairsight-revisited/&quot;&gt;FairSight&lt;/a&gt;, a hiring-bias dashboard&lt;/td&gt;
&lt;td&gt;FIT1055 group project&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prototype.fairsight.org&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Three similar apps is the point where copy-pasting a setup three times starts to hurt. So they share one pattern.&lt;/p&gt;
&lt;h2 id=&quot;one-folder-per-app&quot;&gt;One folder per app&lt;/h2&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;~/Projects/
├── proxy/          Caddy + the main tunnel, one block per hostname
├── roguelike/
│   ├── src/        the app&#39;s git checkout (branch main)
│   ├── Dockerfile
│   ├── docker-compose.yml
│   └── .env        this app&#39;s tunnel token, readable only by me
├── pathfinding/    same layout
└── fairsight/      same layout&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The app&#39;s own repo lives untouched in &lt;code&gt;src/&lt;/code&gt;. Everything about &lt;em&gt;running&lt;/em&gt; it lives one level up. That keeps deployment files out of coursework repos, and redeploying after a push is one line:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;git&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-C&lt;/span&gt; src pull &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; compose up &lt;span class=&quot;token parameter variable&quot;&gt;-d&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--build&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;the-shared-dockerfile&quot;&gt;The shared Dockerfile&lt;/h2&gt;
&lt;p&gt;All three use the same Dockerfile. Only two things change per app: which packages to install and which file to run.&lt;/p&gt;
&lt;pre class=&quot;language-dockerfile&quot;&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;&lt;span class=&quot;token instruction&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;FROM&lt;/span&gt; python:3.13-slim&lt;/span&gt;
&lt;span class=&quot;token instruction&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;ARG&lt;/span&gt; PIP_INSTALL&lt;/span&gt;
&lt;span class=&quot;token instruction&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;WORKDIR&lt;/span&gt; /app&lt;/span&gt;
&lt;span class=&quot;token instruction&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;COPY&lt;/span&gt; . .&lt;/span&gt;
&lt;span class=&quot;token instruction&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;RUN&lt;/span&gt; pip install --no-cache-dir &lt;span class=&quot;token variable&quot;&gt;$PIP_INSTALL&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&#92;&lt;/span&gt;
 &amp;amp;&amp;amp; useradd --create-home app &amp;amp;&amp;amp; chown -R app:app /app&lt;/span&gt;
&lt;span class=&quot;token instruction&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;USER&lt;/span&gt; app&lt;/span&gt;
&lt;span class=&quot;token instruction&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;ENV&lt;/span&gt; APP_FILE=app.py&lt;/span&gt;
&lt;span class=&quot;token instruction&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;EXPOSE&lt;/span&gt; 8501&lt;/span&gt;
&lt;span class=&quot;token instruction&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;HEALTHCHECK&lt;/span&gt; &lt;span class=&quot;token options&quot;&gt;&lt;span class=&quot;token property&quot;&gt;--interval&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;30s&lt;/span&gt; &lt;span class=&quot;token property&quot;&gt;--timeout&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;5s&lt;/span&gt; &lt;span class=&quot;token property&quot;&gt;--start-period&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;20s&lt;/span&gt; &lt;span class=&quot;token property&quot;&gt;--retries&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;3&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&#92;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;CMD&lt;/span&gt; python -c &lt;span class=&quot;token string&quot;&gt;&quot;import urllib.request; urllib.request.urlopen(&#39;http://127.0.0.1:8501/_stcore/health&#39;)&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;token instruction&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;CMD&lt;/span&gt; [&lt;span class=&quot;token string&quot;&gt;&quot;sh&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;-c&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;exec streamlit run &#92;&quot;$APP_FILE&#92;&quot; --server.headless=true &#92;
     --server.address=0.0.0.0 --server.port=8501 --server.fileWatcherType=none &#92;
     --browser.gatherUsageStats=false --client.toolbarMode=viewer&quot;&lt;/span&gt;]&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The choices that matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A non-root user.&lt;/strong&gt; If someone finds a way to run code through the app, they get an unprivileged account inside a container, not root.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A health check against Streamlit&#39;s own &lt;code&gt;/_stcore/health&lt;/code&gt; endpoint&lt;/strong&gt;, using Python&#39;s standard library, so the slim image doesn&#39;t need &lt;code&gt;curl&lt;/code&gt;. Docker now knows the difference between &amp;quot;running&amp;quot; and &amp;quot;working&amp;quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pinned versions&lt;/strong&gt; where the repo had no &lt;code&gt;requirements.txt&lt;/code&gt;, passed in as a build argument (&lt;code&gt;streamlit==1.40.2&lt;/code&gt;, and &lt;code&gt;plotly&lt;/code&gt;/&lt;code&gt;numpy&lt;/code&gt; for the pathfinder). An old app shouldn&#39;t break because a library released a new major version.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Production flags for Streamlit:&lt;/strong&gt; no file watcher (nothing is being edited in the container), no usage statistics, and a viewer-only toolbar so visitors don&#39;t get developer options.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each &lt;code&gt;docker-compose.yml&lt;/code&gt; then adds a &lt;strong&gt;512 MB memory limit&lt;/strong&gt;, so one misbehaving app can&#39;t starve everything else on the box, and publishes the app on &lt;strong&gt;loopback only&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;ports&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;127.0.0.1:8501:8501&quot;&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;# 8502 and 8503 for the other two&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;routing-caddy-on-loopback-only&quot;&gt;Routing: Caddy, on loopback only&lt;/h2&gt;
&lt;p&gt;Public traffic arrives the same way as for &lt;a href=&quot;https://blog.vrp2206.com/posts/shipdoc-homelab/&quot;&gt;ShipDoc&lt;/a&gt;: Cloudflare Tunnel → Caddy → app. The new apps are three more blocks:&lt;/p&gt;
&lt;pre class=&quot;language-caddy&quot;&gt;&lt;code class=&quot;language-caddy&quot;&gt;http://roguelike.vrp2206.com {
	reverse_proxy localhost:8501
}
http://pathfinding.vrp2206.com {
	reverse_proxy localhost:8502
}
http://prototype.fairsight.org {
	reverse_proxy localhost:8503
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Caddy got one more change at the same time. It runs with &lt;code&gt;network_mode: host&lt;/code&gt;, and by default it listened on &lt;strong&gt;port 80 on every interface&lt;/strong&gt;, so anything on my LAN could reach every site directly, around the tunnel. One global option fixes that:&lt;/p&gt;
&lt;pre class=&quot;language-caddy&quot;&gt;&lt;code class=&quot;language-caddy&quot;&gt;{
	auto_https off
	# Loopback only: cloudflared (host network) is the sole way in
	default_bind 127.0.0.1 [::1]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the tunnel is the only way in. Both the old Caddyfile and compose file were copied to dated &lt;code&gt;.bak-2026-10-23&lt;/code&gt; files before any edit, so rolling back is a copy.&lt;/p&gt;
&lt;p&gt;Each app&#39;s compose file also runs its own &lt;code&gt;cloudflared&lt;/code&gt; connector, with its own token and its own metrics port (&lt;code&gt;20242&lt;/code&gt;–&lt;code&gt;20244&lt;/code&gt;). An app and its tunnel live in the same folder, so they&#39;re started, stopped and deleted together.&lt;/p&gt;
&lt;h2 id=&quot;a-bug-that-only-exists-once-its-public&quot;&gt;A bug that only exists once it&#39;s public&lt;/h2&gt;
&lt;p&gt;The roguelike was written to be played by one person on one laptop. It has a &lt;strong&gt;persistent save&lt;/strong&gt;: after every room, the whole game state is written to &lt;code&gt;advanced_game_data.json&lt;/code&gt;, and a new game session loads that file on startup.&lt;/p&gt;
&lt;p&gt;On a laptop, that&#39;s a save game. On a public server, &lt;strong&gt;everyone shares one save file&lt;/strong&gt;. A new visitor doesn&#39;t start a new run: they pick up wherever the last visitor left off. The repo even ships with a save in it, a Tank in room 5 with exactly this much health:&lt;/p&gt;
&lt;pre class=&quot;language-json&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&lt;span class=&quot;token property&quot;&gt;&quot;health&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;9.900000000000002&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(Damage is scaled by armour, &lt;code&gt;attack * (1 - defense)&lt;/code&gt;, so a Tank takes hits like &lt;code&gt;0.85&lt;/code&gt;. Subtract enough of those and floating-point rounding shows up, because numbers like &lt;code&gt;0.85&lt;/code&gt; and &lt;code&gt;9.9&lt;/code&gt; can&#39;t be stored exactly in binary. The fix there is just rounding for display.)&lt;/p&gt;
&lt;p&gt;The proper fix is to keep game state in Streamlit&#39;s per-browser &lt;code&gt;session_state&lt;/code&gt; and either drop the file save or give each visitor their own save. Until then, the game works, but it&#39;s multiplayer in a way I never designed.&lt;/p&gt;
&lt;h2 id=&quot;why-bother-with-old-projects&quot;&gt;Why bother with old projects?&lt;/h2&gt;
&lt;p&gt;These were coursework, and they&#39;re not what I&#39;d write today. But a link on a portfolio that actually opens is worth more than a screenshot. And as the next two posts show, rereading old code with fresh eyes turns up things I couldn&#39;t see when I wrote it: a &lt;a href=&quot;https://blog.vrp2206.com/posts/pathfinding-revisited/&quot;&gt;pathfinder that isn&#39;t quite what its README says&lt;/a&gt;, and a &lt;a href=&quot;https://blog.vrp2206.com/posts/fairsight-revisited/&quot;&gt;bias dashboard whose bias was bigger than I&#39;d claimed&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>My &quot;LAN speed test&quot; was actually an internet speed test</title>
    <link href="https://blog.vrp2206.com/posts/lan-speed-test/" />
    <updated>2026-10-22T06:01:00Z</updated>
    <id>https://blog.vrp2206.com/posts/lan-speed-test/</id>
    <content type="html">&lt;p&gt;A few days ago, while updating my &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-origin/&quot;&gt;homelab server&lt;/a&gt; to the latest Arch Linux packages and installing CUDA for &lt;a href=&quot;https://blog.vrp2206.com/posts/examx-part-1/&quot;&gt;ExamX&lt;/a&gt;, I found that its LAN cable was faulty. The link had fallen back to 10 Mbit/s. I swapped in a Cat6 cable and the link came back up at 1000 Mbit/s.&lt;/p&gt;
&lt;p&gt;A link speed is only what the two ends agreed on, though, not what actually gets through. I wanted to know how fast the network really is, so I tested it with the standard tool is &lt;code&gt;iperf3&lt;/code&gt;: run a server on one end and a client on the other, and it measures raw throughput with the disks out of the picture.&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;iperf3 &lt;span class=&quot;token parameter variable&quot;&gt;-s&lt;/span&gt;                    &lt;span class=&quot;token comment&quot;&gt;# on the server, listens on port 5201&lt;/span&gt;
iperf3 &lt;span class=&quot;token parameter variable&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;server-ip&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt;        &lt;span class=&quot;token comment&quot;&gt;# on the client&lt;/span&gt;
iperf3 &lt;span class=&quot;token parameter variable&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;server-ip&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-R&lt;/span&gt;     &lt;span class=&quot;token comment&quot;&gt;# reverse direction: server sends to the client&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;attempt-1-timed-out&quot;&gt;Attempt 1: timed out&lt;/h2&gt;
&lt;p&gt;I pointed my Windows PC at the server&#39;s wired LAN address and got:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;iperf3: error - unable to connect to server - server may have stopped running
or use a different port, firewall issue, etc.: Connection timed out&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My first thought was the firewall. But &lt;code&gt;ping&lt;/code&gt; to the same address timed out too, so this wasn&#39;t about iperf3 or a port at all. The PC couldn&#39;t reach that address.&lt;/p&gt;
&lt;h2 id=&quot;the-clue-i-d-left-out&quot;&gt;The clue I&#39;d left out&lt;/h2&gt;
&lt;p&gt;Then I mentioned something I hadn&#39;t thought to say: I was talking to Claude &lt;strong&gt;over SSH from that same PC&lt;/strong&gt;. If SSH works but the LAN address doesn&#39;t, the SSH session must be taking a different path. Claude checked where the session was coming from: a Tailscale address. The PC wasn&#39;t on my home LAN at all. It was reaching the server through the Tailscale tunnel.&lt;/p&gt;
&lt;h2 id=&quot;attempt-2-about-30-mbit-s&quot;&gt;Attempt 2: about 30 Mbit/s&lt;/h2&gt;
&lt;p&gt;Pointed at the server&#39;s Tailscale address, iperf3 connected and reported about &lt;strong&gt;30 Mbit/s&lt;/strong&gt;. That&#39;s terrible for gigabit Ethernet, and with the old cable fresh in my mind, I assumed the new one was bad too.&lt;/p&gt;
&lt;p&gt;It wasn&#39;t. &lt;code&gt;tailscale status&lt;/code&gt; showed a &lt;strong&gt;direct&lt;/strong&gt; connection (not relayed through a DERP server), with the PC at a public IP on a different network about 10 ms away. So the test measured:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;the slower of the PC&#39;s download speed and the server&#39;s home &lt;strong&gt;upload&lt;/strong&gt; speed, minus a little WireGuard overhead.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;30 Mbit/s is a normal home upload speed. The number was right. I was just measuring something other than what I thought.&lt;/p&gt;
&lt;h2 id=&quot;checking-the-actual-lan-link&quot;&gt;Checking the actual LAN link&lt;/h2&gt;
&lt;p&gt;That still left the question I&#39;d started with: was the new cable any good? Claude ran a few checks from the server towards the router:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Link speed:&lt;/strong&gt; &lt;code&gt;ethtool&lt;/code&gt; reported &lt;code&gt;1000Mb/s&lt;/code&gt;, so the Cat6 cable and router port negotiated gigabit. A damaged cable usually falls back to 100 Mbit/s, or all the way to 10 like my old one did.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Latency to the router:&lt;/strong&gt; about 0.8 ms average, with 0% packet loss.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The new cable is fine. Measuring the link&#39;s real throughput needs a second wired device on the same LAN running &lt;code&gt;iperf3 -s&lt;/code&gt;, since most routers can&#39;t act as an iperf3 endpoint.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A timeout on both &lt;code&gt;ping&lt;/code&gt; and the app means a reachability problem&lt;/strong&gt;, not a port or firewall problem in the app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Know which path your traffic takes.&lt;/strong&gt; With Tailscale running, &amp;quot;the server&#39;s address&amp;quot; can mean a LAN IP, a tailnet IP or something routed in between. Check with &lt;code&gt;tailscale status&lt;/code&gt;, and &lt;code&gt;echo $SSH_CLIENT&lt;/code&gt; shows where an SSH session is coming from.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Over a VPN, iperf3 measures the internet.&lt;/strong&gt; Over the internet, home upload speed is almost always the bottleneck.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ethtool &amp;lt;iface&amp;gt; | grep Speed&lt;/code&gt; is the 5-second cable check.&lt;/strong&gt; A faulty cable usually shows up as a link stuck at 100 or 10 Mbit/s. Check it before you blame the cable.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <title>My new monitoring paged me twice on day one. Good.</title>
    <link href="https://blog.vrp2206.com/posts/homelab-false-alarms/" />
    <updated>2026-10-22T05:45:00Z</updated>
    <id>https://blog.vrp2206.com/posts/homelab-false-alarms/</id>
    <content type="html">&lt;p&gt;The &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-hardening/&quot;&gt;hardening work&lt;/a&gt; added two new scheduled jobs: a 5-minute heartbeat to healthchecks.io and a nightly restic backup. Both had passed their tests. Within 24 hours, &lt;strong&gt;both triggered a Discord alert&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;alert-1-the-heartbeat-failed&quot;&gt;Alert 1: the heartbeat &amp;quot;failed&amp;quot;&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;archlinux: Failed systemd units: server-heartbeat.service&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The server was fine. The journal showed two failed pings to &lt;code&gt;hc-ping.com&lt;/code&gt;: a timeout, and then an &lt;strong&gt;HTTP/2 error&lt;/strong&gt; on the next try. My own connection looked healthy (0% loss to 1.1.1.1 at about 2.5 ms), but the route to the ping service was about 240 ms away and dropping a lot of ICMP.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The real bug was the design.&lt;/strong&gt; The heartbeat script exited non-zero when a ping failed, so systemd marked the unit failed, and my existing health monitor (which reports failed units to Discord) raised the alarm.&lt;/p&gt;
&lt;p&gt;But a missed ping is &lt;em&gt;already&lt;/em&gt; the signal. healthchecks.io alerts when pings stop arriving, so making the unit fail too just creates a second, noisier alert. The fix:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ping over &lt;strong&gt;HTTP/1.1&lt;/strong&gt;, which avoids the HTTP/2 failure;&lt;/li&gt;
&lt;li&gt;retry up to 5 times, 3 seconds apart;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;never fail the unit because a ping failed.&lt;/strong&gt; If the pings really stop, healthchecks.io alerts after the 10-minute grace period.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The backup job&#39;s pings got the same treatment.&lt;/p&gt;
&lt;p&gt;The good news: the alert chain worked end to end. A problem was detected and reached my phone within minutes.&lt;/p&gt;
&lt;h2 id=&quot;alert-2-the-backup-failed-at-03-36&quot;&gt;Alert 2: the backup failed at 03:36&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ archlinux: Failed systemd units: server-backup.service&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This one was a genuine failure, and nothing had been backed up that night. restic stopped within a second because it couldn&#39;t find a cache directory. &lt;strong&gt;When systemd runs a service, &lt;code&gt;HOME&lt;/code&gt; isn&#39;t set&lt;/strong&gt;, and restic puts its cache under &lt;code&gt;$HOME/.cache&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The first run had worked because it was started by hand from a shell that &lt;em&gt;has&lt;/em&gt; a home directory. The fix was one line in the unit and one in the script:&lt;/p&gt;
&lt;pre class=&quot;language-ini&quot;&gt;&lt;code class=&quot;language-ini&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# /etc/systemd/system/server-backup.service&lt;/span&gt;
&lt;span class=&quot;token section&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token section-name selector&quot;&gt;Service&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;token key attr-name&quot;&gt;Environment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token value attr-value&quot;&gt;HOME=/root&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# /usr/local/bin/server-backup&lt;/span&gt;
&lt;span class=&quot;token builtin class-name&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;RESTIC_CACHE_DIR&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;/var/cache/restic&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Running it exactly the way the timer does took about 12 seconds, the repository check came back clean, and the backup check on healthchecks.io went green again.&lt;/p&gt;
&lt;h2 id=&quot;the-lesson-test-the-way-it-will-actually-run&quot;&gt;The lesson: test the way it will actually run&lt;/h2&gt;
&lt;p&gt;Both jobs passed their tests. Both tests ran in a different environment from the real one:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;How it was tested&lt;/th&gt;
&lt;th&gt;How it really runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Heartbeat&lt;/td&gt;
&lt;td&gt;One manual ping, on a good connection&lt;/td&gt;
&lt;td&gt;Every 5 minutes, all day, over a flaky route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backup&lt;/td&gt;
&lt;td&gt;Started from an interactive shell&lt;/td&gt;
&lt;td&gt;Started by systemd at 03:30, with no &lt;code&gt;HOME&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If I&#39;d found the backup bug in month two instead of on day one, I&#39;d have had weeks of &amp;quot;backup failed&amp;quot; alerts to deal with from abroad, or worse, I&#39;d have missed them. Before I leave:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Run every unit with &lt;code&gt;systemctl start&lt;/code&gt;&lt;/strong&gt;, not by running the script by hand. That&#39;s the environment the timer gives it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do a full reboot test&lt;/strong&gt;, so everything starts from cold the way it will after a power cut.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make alerts mean one thing.&lt;/strong&gt; One problem should raise one alert, from the system best placed to notice it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two pages on day one is annoying. Two pages in month two, from another country, would have been much worse.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Getting a homelab ready to run for three months without me</title>
    <link href="https://blog.vrp2206.com/posts/homelab-hardening/" />
    <updated>2026-10-21T08:33:00Z</updated>
    <id>https://blog.vrp2206.com/posts/homelab-hardening/</id>
    <content type="html">&lt;p&gt;I&#39;ll be out of the country for three months with no physical access to the server, and I had about a month to prepare. After the &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-audit/&quot;&gt;audit&lt;/a&gt;, the question became concrete: &lt;strong&gt;what could take this machine down or lock me out while I&#39;m away, and how would I even know?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I started a new Claude Code session on the server and asked it to find every failure point and fix what it could.&lt;/p&gt;
&lt;h2 id=&quot;the-failure-points&quot;&gt;The failure points&lt;/h2&gt;
&lt;p&gt;The first survey sorted the risks into &amp;quot;critical before you leave&amp;quot; and the rest:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The machine could fall asleep.&lt;/strong&gt; It&#39;s a laptop, and closing the lid suspends it by default. A suspended server is a dead server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Power cuts.&lt;/strong&gt; The battery is at about 74% of its design capacity after 1,490 cycles, and there&#39;s no UPS. After an outage, the machine only comes back if the BIOS powers it on when AC returns.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One way in.&lt;/strong&gt; One SSH key, and one other device on Tailscale. Lose that laptop and I&#39;m locked out for three months.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No backups.&lt;/strong&gt; The only copy of everything, including &lt;code&gt;.env&lt;/code&gt; files and tunnel credentials, was on one SSD.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SSH accepted passwords&lt;/strong&gt; on every interface.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;never-sleep-and-reboot-if-frozen&quot;&gt;Never sleep, and reboot if frozen&lt;/h2&gt;
&lt;p&gt;The easy wins went in first:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;logind.conf.d&lt;/code&gt; drop-in so the lid switch, idle and the power key are ignored;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sleep.target&lt;/code&gt;, &lt;code&gt;suspend.target&lt;/code&gt; and &lt;code&gt;hibernate.target&lt;/code&gt; masked, so nothing can suspend it by accident;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;hardware watchdog&lt;/strong&gt; that reboots the machine if it freezes for 30 seconds;&lt;/li&gt;
&lt;li&gt;a full system upgrade (including Tailscale), the journal capped at 500 MB and 3 months, and 2 GB of Docker build cache cleared;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/boot&lt;/code&gt; permissions tightened, so the random seed is no longer world-readable.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;ssh-keys-only-verified-before-switching&quot;&gt;SSH: keys only, verified before switching&lt;/h2&gt;
&lt;p&gt;My past logins had used a password, so turning passwords off blind could have locked me out. The order mattered:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;I tested a key-only login from my laptop: &lt;code&gt;ssh -o PasswordAuthentication=no ...&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Only after that worked did &lt;code&gt;PasswordAuthentication no&lt;/code&gt; go in, along with no root login, &lt;code&gt;AllowUsers&lt;/code&gt;, 3 attempts maximum and a short login grace time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fail2ban&lt;/strong&gt; watches sshd, with my own networks on the ignore list so I can&#39;t ban myself.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;a-second-way-in&quot;&gt;A second way in&lt;/h2&gt;
&lt;p&gt;A second device on Tailscale and a second SSH key, on different hardware:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I added my phone to the tailnet.&lt;/li&gt;
&lt;li&gt;I generated an ed25519 key in Termux. My first try failed because I typed &lt;code&gt;ssh keygen&lt;/code&gt; instead of &lt;code&gt;ssh-keygen&lt;/code&gt;, and ssh tried to connect to a host called &amp;quot;keygen&amp;quot;.&lt;/li&gt;
&lt;li&gt;Claude added the public key, I logged in from the phone &lt;strong&gt;on mobile data with Wi-Fi off&lt;/strong&gt;, and the SSH log confirmed it was the phone&#39;s key that got in.&lt;/li&gt;
&lt;li&gt;I turned off &lt;strong&gt;Tailscale key expiry&lt;/strong&gt; for the server, which otherwise drops off the tailnet after about 180 days.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;wiping-the-hdd-storage-and-backups&quot;&gt;Wiping the HDD: storage and backups&lt;/h2&gt;
&lt;p&gt;The 1 TB HDD only held old Windows data, so I asked for it to be wiped. Before touching it, Claude checked the drive&#39;s &lt;strong&gt;serial number&lt;/strong&gt; matched the HDD, not the NVMe. That&#39;s a cheap check against the most expensive possible mistake.&lt;/p&gt;
&lt;p&gt;It&#39;s now two ext4 partitions, both mounted with &lt;code&gt;nofail&lt;/code&gt; so a dead drive can&#39;t stop the boot:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mount&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mnt/storage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;462 GB&lt;/td&gt;
&lt;td&gt;A network storage project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mnt/backup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;454 GB&lt;/td&gt;
&lt;td&gt;Backups, root-only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&quot;backups-that-are-tested-not-assumed&quot;&gt;Backups that are tested, not assumed&lt;/h2&gt;
&lt;p&gt;The backups use &lt;strong&gt;restic&lt;/strong&gt;: a nightly systemd timer backs up &lt;code&gt;/home&lt;/code&gt; and &lt;code&gt;/etc&lt;/code&gt; to &lt;code&gt;/mnt/backup&lt;/code&gt;, keeps 7 daily, 4 weekly and 6 monthly snapshots, and runs &lt;code&gt;restic check&lt;/code&gt; afterwards.&lt;/p&gt;
&lt;p&gt;The first run used 35 GB. The same job then &lt;strong&gt;restored a file from the snapshot and compared it with the original&lt;/strong&gt;, and they matched. A backup you&#39;ve never restored from is only a hope.&lt;/p&gt;
&lt;p&gt;The weak spot is clear: backups on the same machine as the data don&#39;t survive theft, fire or a power surge. An offsite copy is still on the list.&lt;/p&gt;
&lt;h2 id=&quot;docker-and-the-demo-sites&quot;&gt;Docker and the demo sites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The two app ports that were published on every interface are now bound to &lt;code&gt;127.0.0.1&lt;/code&gt;. The tunnel and Caddy reach them locally.&lt;/li&gt;
&lt;li&gt;Container logs rotate at 10 MB × 3 files.&lt;/li&gt;
&lt;li&gt;Fresh Caddy and cloudflared images, all containers recreated, and all three sites returned 200 afterwards.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;trying-network-storage-then-turning-it-off&quot;&gt;Trying network storage, then turning it off&lt;/h2&gt;
&lt;p&gt;I tried a Samba share for &lt;code&gt;/mnt/storage&lt;/code&gt;. It was set up as tightly as I could think of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SMB3 with &lt;strong&gt;encryption required&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;no guest access;&lt;/li&gt;
&lt;li&gt;bound to the Tailscale address only, with an nftables rule dropping SMB from anything else;&lt;/li&gt;
&lt;li&gt;a dedicated Samba password.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Windows rejected the first password (the fix was a simpler password and the username written as &lt;code&gt;server&#92;user&lt;/code&gt;), but after that it worked. Then I asked for it to be turned off again. I want to understand the trade-offs before leaving a file share running for three months. It&#39;s disabled with the config kept, so turning it back on is one command.&lt;/p&gt;
&lt;h2 id=&quot;an-alarm-that-works-when-the-server-doesnt&quot;&gt;An alarm that works when the server doesn&#39;t&lt;/h2&gt;
&lt;p&gt;The server already sends health reports to Discord, but &lt;strong&gt;if it dies, it goes quiet&lt;/strong&gt;, and silence looks exactly like &amp;quot;everything&#39;s fine&amp;quot;. So we added a dead-man&#39;s switch with &lt;a href=&quot;https://healthchecks.io&quot;&gt;healthchecks.io&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Heartbeat:&lt;/strong&gt; every 5 minutes, a script checks that all mounts are present, all containers are running, the sites respond on localhost, there are no failed units, the disk is under 90% and Tailscale is up. If all of that holds it pings healthchecks.io. If not, it pings the &lt;code&gt;/fail&lt;/code&gt; URL with the list of problems. If the server is dead, the pings stop, and that&#39;s the alert.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backups:&lt;/strong&gt; the nightly job reports start, success and failure to a second check.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alerts&lt;/strong&gt; go to email, WhatsApp and Discord.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We tested the failure path for real: stopping a container produced &amp;quot;only 3/4 containers running&amp;quot; within one run.&lt;/p&gt;
&lt;h2 id=&quot;still-on-the-list&quot;&gt;Still on the list&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Checking the BIOS for a &amp;quot;power on after AC loss&amp;quot; option. Lenovo consumer laptops rarely have one, so the fallback is a trusted person who can press the power button.&lt;/li&gt;
&lt;li&gt;A low-battery clean shutdown, to stop adding to that unsafe-shutdown count.&lt;/li&gt;
&lt;li&gt;A full &lt;strong&gt;reboot test&lt;/strong&gt;, so every timer and service gets exercised from a cold start.&lt;/li&gt;
&lt;li&gt;An offsite backup copy.&lt;/li&gt;
&lt;li&gt;Rotating the credentials that passed through chat while we were moving fast.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The reboot test turned out to matter more than I thought. Within a day, two of the new jobs failed for real. That&#39;s &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-false-alarms/&quot;&gt;part 3&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>ExamX, part 2: cloud models, a public site, and pressing pause</title>
    <link href="https://blog.vrp2206.com/posts/examx-part-2/" />
    <updated>2026-10-21T00:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/examx-part-2/</id>
    <content type="html">&lt;p&gt;&lt;a href=&quot;https://blog.vrp2206.com/posts/examx-part-1/&quot;&gt;Part 1&lt;/a&gt; ended with a working generator and grader, held back by a local model that was too small to write good questions. Around midnight I asked the obvious question: &lt;strong&gt;would a free cloud model fix it?&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;would-gemini-solve-the-problem&quot;&gt;Would Gemini solve the problem?&lt;/h2&gt;
&lt;p&gt;Mostly, yes, with a few catches that were worth spelling out before building anything:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It&#39;s no longer local.&lt;/strong&gt; That was fine: I dropped the &amp;quot;runs locally&amp;quot; requirement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The free tier may use your prompts for training.&lt;/strong&gt; That would include past-paper text and my answers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It needs an API key,&lt;/strong&gt; which has to live in the environment and never in the repo.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The exact checkers stay in charge&lt;/strong&gt; of anything they can check. A better model makes fewer mistakes, but not zero.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;three-back-ends-and-a-model-switcher&quot;&gt;Three back ends and a model switcher&lt;/h2&gt;
&lt;p&gt;The LLM client got three interchangeable back ends, chosen with a &lt;code&gt;provider:model&lt;/code&gt; spec:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;examx generate &lt;span class=&quot;token parameter variable&quot;&gt;--model&lt;/span&gt; qwen3:8b                  &lt;span class=&quot;token comment&quot;&gt;# local, via Ollama&lt;/span&gt;
examx generate &lt;span class=&quot;token parameter variable&quot;&gt;--model&lt;/span&gt; gemini                    &lt;span class=&quot;token comment&quot;&gt;# Google&#39;s default model&lt;/span&gt;
examx generate &lt;span class=&quot;token parameter variable&quot;&gt;--model&lt;/span&gt; claude:claude-sonnet-5-5  &lt;span class=&quot;token comment&quot;&gt;# Anthropic&lt;/span&gt;
examx models                                     &lt;span class=&quot;token comment&quot;&gt;# which back ends are ready&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The web UI got a matching &lt;strong&gt;AI model&lt;/strong&gt; page. It shows whether each provider is ready and how to set it up if not, and a chip in the header always shows the current model. The UI also gained dark mode, a phone layout, past-paper diagrams inside questions, and answers saved in the browser as you type.&lt;/p&gt;
&lt;h2 id=&quot;the-free-tier-is-20-requests-a-day&quot;&gt;The free tier is 20 requests a day&lt;/h2&gt;
&lt;p&gt;I set up a Gemini key (in a file only my user can read, loaded by &lt;code&gt;~/.bashrc&lt;/code&gt;, nowhere near the repo). The first surprise: &lt;code&gt;gemini-2.5-flash&lt;/code&gt; was no longer offered to new keys, so the default moved to &lt;code&gt;gemini-3.5-flash&lt;/code&gt;. It answered a test question about decidability correctly in under 4 seconds.&lt;/p&gt;
&lt;p&gt;Then a full paper ran for 22 minutes and produced almost nothing from Gemini:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the free tier allows &lt;strong&gt;20 requests per day&lt;/strong&gt; for that model, and one AI-written paper needs &lt;strong&gt;15–25&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;several calls failed earlier anyway with &amp;quot;model experiencing high demand&amp;quot;;&lt;/li&gt;
&lt;li&gt;my retry logic waited and retried for minutes, even after the daily quota was gone.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The fix was to treat &amp;quot;quota used up for hours&amp;quot; differently from &amp;quot;slow down for a minute&amp;quot;. ExamX now stops at once, skips that model for the rest of the run, and says when the quota resets. In practice the free tier gives you about &lt;strong&gt;one AI-written paper or one marked attempt a day&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;putting-it-online&quot;&gt;Putting it online&lt;/h2&gt;
&lt;p&gt;I decided to try Claude Sonnet instead and asked for the app at &lt;code&gt;exam-x.vrp2206.com&lt;/code&gt;. The server already ran Caddy and a Cloudflare Tunnel for my hackathon demo sites, so ExamX went in as a Docker container behind its own tunnel.&lt;/p&gt;
&lt;p&gt;Two design decisions from this part are worth keeping:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;API keys live in the visitor&#39;s browser, not on the server.&lt;/strong&gt; You paste your key into the AI model page. It&#39;s stored as an HttpOnly cookie, checked once against the provider&#39;s free model-list endpoint, used only while your job runs, and never written to disk or logged. By default it&#39;s forgotten when you close the browser.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fail closed.&lt;/strong&gt; The app first refused every request that hadn&#39;t passed Cloudflare Access.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Cloudflare Access then asked for a credit card, so I asked to drop it. Before switching the guard off, Claude added the protections a public site needs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;visitors can only use &lt;strong&gt;their own&lt;/strong&gt; Claude or Gemini key, never my local GPU models or a server-side key;&lt;/li&gt;
&lt;li&gt;each browser gets an anonymous ID and only sees &lt;strong&gt;its own&lt;/strong&gt; papers and reports;&lt;/li&gt;
&lt;li&gt;the model choice is per browser, and each visitor has daily job limits;&lt;/li&gt;
&lt;li&gt;the owner still has full access from the server itself.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;pressing-pause&quot;&gt;Pressing pause&lt;/h2&gt;
&lt;p&gt;That&#39;s where I stopped. The Claude API is billed separately from a Claude Pro subscription. The free Gemini tier is too small to rely on. The local models are too weak to write new questions. And the real goal was passing the exam, not shipping a product the night before.&lt;/p&gt;
&lt;p&gt;So ExamX was shut down cleanly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;both containers (the app and its tunnel) removed, and the Caddy route removed and reloaded, with the other sites checked afterwards;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;&amp;quot;Project status: paused&amp;quot;&lt;/strong&gt; section at the top of the README, covering what&#39;s done, what I learned and the next steps;&lt;/li&gt;
&lt;li&gt;the code tagged &lt;code&gt;v0.1-paused&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I kept the Ollama models (they&#39;re useful for other ML work) and left the GitHub repo public rather than archived, in case someone else wants to pick it up.&lt;/p&gt;
&lt;h2 id=&quot;what-i-took-away&quot;&gt;What I took away&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Let code mark what code can mark.&lt;/strong&gt; The exact checkers were the most reliable part of the whole project, and they need no model at all.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Small local models are good at following a template, not at inventing correct maths.&lt;/strong&gt; Using real past questions with official solutions beat every LLM-written alternative I tried.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Free tiers have shapes.&lt;/strong&gt; &amp;quot;20 requests a day&amp;quot; changes the whole design of a tool that needs 25 requests per run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&#39;t paste secrets into a chat.&lt;/strong&gt; I pasted a tunnel token into the conversation to save time, which meant treating it as leaked and replacing it. The final version of the app asks for keys through a form instead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stopping is a valid ending.&lt;/strong&gt; The tool still works offline for practice: &lt;code&gt;examx generate --no-llm&lt;/code&gt; and exact marking, no AI needed.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <title>Auditing a gaming laptop that thinks it&#39;s a server</title>
    <link href="https://blog.vrp2206.com/posts/homelab-audit/" />
    <updated>2026-10-20T14:21:00Z</updated>
    <id>https://blog.vrp2206.com/posts/homelab-audit/</id>
    <content type="html">&lt;p&gt;My homelab server is a &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-origin/&quot;&gt;Lenovo Legion gaming laptop&lt;/a&gt; running Arch Linux. It hosts my hackathon demo sites (kept online for my CV, &lt;a href=&quot;https://blog.vrp2206.com/posts/shipdoc-homelab/&quot;&gt;ShipDoc&lt;/a&gt; among them), Docker, Tailscale and a Cloudflare Tunnel. It had been running for a while without anyone looking at it critically, and now real projects depended on it, so I asked Claude Code to &lt;strong&gt;audit the quality of the system and how it was set up&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-ground-rules&quot;&gt;The ground rules&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Read-only first.&lt;/strong&gt; The first pass ran without root.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&#39;t touch the other Claude.&lt;/strong&gt; Another Claude Code session was busy building &lt;a href=&quot;https://blog.vrp2206.com/posts/examx-part-1/&quot;&gt;ExamX&lt;/a&gt; in a tmux window on the same machine. The audit stayed away from that directory and that session.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Root without sharing the password.&lt;/strong&gt; A &lt;code&gt;!&lt;/code&gt; command in Claude Code has no terminal, so &lt;code&gt;sudo&lt;/code&gt; can&#39;t prompt there. Instead of passing my password into the session, Claude wrote a &lt;strong&gt;read-only audit script&lt;/strong&gt;. I read it, ran it with &lt;code&gt;sudo&lt;/code&gt; in my own terminal, and Claude read the output file.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last pattern is worth reusing: the AI writes the script, you review and run it, and it reads the results. No credentials ever enter the chat.&lt;/p&gt;
&lt;h2 id=&quot;what-was-already-fine&quot;&gt;What was already fine&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;No failed systemd units, and the system was up to date.&lt;/li&gt;
&lt;li&gt;Only 59 explicitly installed packages. Package signature checking was on.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sudo&lt;/code&gt; requires a password (no &lt;code&gt;NOPASSWD&lt;/code&gt; anywhere).&lt;/li&gt;
&lt;li&gt;Every SSH login in the journal was key-based, from my own laptop over Tailscale.&lt;/li&gt;
&lt;li&gt;Both drives passed SMART. The HDD had 0 reallocated or pending sectors, and the NVMe had 0 media errors.&lt;/li&gt;
&lt;li&gt;No port forwarding: remote access goes through Tailscale and a Cloudflare Tunnel.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-wasn-t-worst-first&quot;&gt;What wasn&#39;t, worst first&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;No host firewall at all.&lt;/strong&gt; The &lt;code&gt;INPUT&lt;/code&gt; policy was &lt;code&gt;ACCEPT&lt;/code&gt; for IPv4 and IPv6. SSH, HTTP, the tunnel&#39;s metrics port and two Docker-published app ports were listening on every interface. On a machine that has been moved between networks, that matters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SSH accepted passwords.&lt;/strong&gt; &lt;code&gt;sshd -T&lt;/code&gt; confirmed &lt;code&gt;PasswordAuthentication yes&lt;/code&gt;. I only ever use keys, so turning passwords off costs nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/boot&lt;/code&gt; was world-readable&lt;/strong&gt;, including the random seed file, which systemd flags as a security hole.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;86 unsafe shutdowns on the NVMe&lt;/strong&gt;, and 17% wear after only about 3,000 power-on hours. The last boot had ended with &lt;code&gt;watchdog did not stop!&lt;/code&gt;. That&#39;s what a laptop that sometimes gets hard-powered-off looks like.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No backups.&lt;/strong&gt; Nothing was configured, &lt;code&gt;/home&lt;/code&gt; lived on a single drive, and the 1 TB HDD held old Windows data, mounted read-only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Smaller items:&lt;/strong&gt; membership of the &lt;code&gt;docker&lt;/code&gt; group (root-equivalent), unpinned &lt;code&gt;:latest&lt;/code&gt; images, 589 MB of reclaimable build cache, orphaned packages and a BIOS from 2022.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;There was also one thing the audit couldn&#39;t fully explain: &lt;code&gt;/&lt;/code&gt; was 20 GB used of 32 GB, but Docker only accounted for 0.4 GB of it.&lt;/p&gt;
&lt;h2 id=&quot;the-output-a-plan-not-changes&quot;&gt;The output: a plan, not changes&lt;/h2&gt;
&lt;p&gt;The result wasn&#39;t a pile of edits. It was a single Markdown file:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a snapshot of the system and what&#39;s already good;&lt;/li&gt;
&lt;li&gt;an 11-item TODO list in priority order;&lt;/li&gt;
&lt;li&gt;copy-pasteable fixes, each with a rollback note;&lt;/li&gt;
&lt;li&gt;a suggested order of work and the open questions for me to decide.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I sent it to my Windows laptop with Taildrop (&lt;code&gt;tailscale file cp&lt;/code&gt;). Taildrop needs root, or a one-time &lt;code&gt;tailscale set --operator=&amp;lt;user&amp;gt;&lt;/code&gt;. Claude wouldn&#39;t change my Tailscale settings without asking, so I ran the send myself.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Nothing was changed in this session. The point of an audit is to know what you&#39;re dealing with before you touch it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The next day this audit turned into a much bigger job. I&#39;ll be out of the country for three months with no physical access to the box. &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-hardening/&quot;&gt;Part 2&lt;/a&gt; is about getting it ready for that.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>ExamX, part 1: a practice-exam generator that marks its own papers</title>
    <link href="https://blog.vrp2206.com/posts/examx-part-1/" />
    <updated>2026-10-20T13:35:00Z</updated>
    <id>https://blog.vrp2206.com/posts/examx-part-1/</id>
    <content type="html">&lt;p&gt;My FIT2014 exam (Theory of Computation at Monash) was coming up, and I had five past papers but only two sets of solutions. I wanted more practice than that, so I wrote a brief for a system and handed it to &lt;a href=&quot;https://claude.com/claude-code&quot;&gt;Claude Code&lt;/a&gt;, running on my &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-origin/&quot;&gt;homelab server&lt;/a&gt;: build a tool that &lt;strong&gt;generates new practice papers with worked solutions and marks my attempts&lt;/strong&gt;, using only free tools and a local model.&lt;/p&gt;
&lt;p&gt;This post covers that first evening. &lt;a href=&quot;https://blog.vrp2206.com/posts/examx-part-2/&quot;&gt;Part 2&lt;/a&gt; covers cloud models, putting it online, and why I paused it.&lt;/p&gt;
&lt;h2 id=&quot;the-brief&quot;&gt;The brief&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Data:&lt;/strong&gt; the 2013 sample exam plus the 2013, 2014, 2015 and 2017 papers. Only two of them came with solutions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hardware:&lt;/strong&gt; the server has 8 CPU cores, 31 GB of RAM and a GTX 1650 with 4 GB of VRAM. That&#39;s a small budget for running an LLM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rules:&lt;/strong&gt; free tools only, a Python virtual environment inside the repo, small commits pushed often, and no credentials stored anywhere.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;step-1-getting-the-papers-in&quot;&gt;Step 1: getting the papers in&lt;/h2&gt;
&lt;p&gt;All the PDFs had real text in them, so no OCR was needed. Ingestion used PyMuPDF to split each paper into questions and parts, tag each one with a topic, and match it to its official solution where one existed.&lt;/p&gt;
&lt;p&gt;One check made me trust the parser: &lt;strong&gt;every paper&#39;s marks added up to exactly 120&lt;/strong&gt;. That caught several splitting bugs early.&lt;/p&gt;
&lt;p&gt;Two details mattered more than I expected:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Superscripts.&lt;/strong&gt; Expressions like a&lt;sup&gt;m&lt;/sup&gt;b&lt;sup&gt;n&lt;/sup&gt; lose their meaning if the exponent becomes plain text. The parser reads font sizes and positions so they survive as &lt;code&gt;a^{m}b^{n}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Colour.&lt;/strong&gt; In the solution PDFs, the answers and the marker&#39;s comments are in different colours, so the parser separates them by colour.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result was 101 structured questions in JSON, with the diagrams saved as images.&lt;/p&gt;
&lt;h2 id=&quot;step-2-let-code-mark-what-code-can-mark&quot;&gt;Step 2: let code mark what code can mark&lt;/h2&gt;
&lt;p&gt;Much of FIT2014 has answers you can check exactly. Two regular expressions either describe the same language or they don&#39;t, and a Turing machine either computes the right output or it doesn&#39;t. So before any LLM was involved, the project got a set of &lt;strong&gt;exact checkers&lt;/strong&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Checker&lt;/th&gt;
&lt;th&gt;What it decides&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regex / automata&lt;/td&gt;
&lt;td&gt;Language equivalence, with the shortest string where two answers differ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subset construction and minimisation&lt;/td&gt;
&lt;td&gt;Whether an NFA→DFA conversion or a minimised DFA is correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context-free grammars&lt;/td&gt;
&lt;td&gt;Membership (Earley parsing), derivations, bounded language comparison&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turing machines and PDAs&lt;/td&gt;
&lt;td&gt;Simulation on test inputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Propositional logic&lt;/td&gt;
&lt;td&gt;Truth tables and CNF&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Because the checkers name a concrete counterexample, the feedback is specific: &amp;quot;your regex accepts &lt;code&gt;abba&lt;/code&gt;, the language doesn&#39;t&amp;quot;.&lt;/p&gt;
&lt;p&gt;One bug was a good lesson. Some reference Turing machines kept crashing off the left end of the tape. FIT2014 uses one-way infinite tapes, so a machine can&#39;t detect the left end unless it marks it first. The fix belonged in the machines, not in the simulator.&lt;/p&gt;
&lt;h2 id=&quot;step-3-generating-new-papers&quot;&gt;Step 3: generating new papers&lt;/h2&gt;
&lt;p&gt;The generator samples a blueprint from the past papers (which topics, how many marks) and fills each slot in one of two ways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Procedural questions.&lt;/strong&gt; Code generates the automata, grammar, logic and decidability questions, so their solutions are correct by construction. Each model solution is fed back through the grader and must score full marks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LLM questions.&lt;/strong&gt; For proofs and explanations, a local model writes new questions using past-paper questions as examples.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Papers come out as Markdown, PDF and JSON. The PDF path is Markdown → HTML → PyMuPDF, so no LaTeX install was needed.&lt;/p&gt;
&lt;h2 id=&quot;where-the-local-model-fell-over&quot;&gt;Where the local model fell over&lt;/h2&gt;
&lt;p&gt;I benchmarked the Qwen3 models through Ollama. The 8B model was the largest that ran at a usable speed (about 8 tokens/s, split between the 4 GB GPU and system RAM), and the only one that wrote a correct proof in the benchmark.&lt;/p&gt;
&lt;p&gt;A full paper took &lt;strong&gt;19 minutes, and about half of the LLM-written questions were poor&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;near-duplicate questions;&lt;/li&gt;
&lt;li&gt;a &amp;quot;prove that…&amp;quot; of a statement that is &lt;strong&gt;false&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;wrong answers on decidability;&lt;/li&gt;
&lt;li&gt;an NP-completeness question that didn&#39;t make sense.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So we added quality controls: near-duplicate drafts got rejected, a self-review pass checked each question, and LLM-written questions got flagged in the solutions. The second run took 39 minutes and was &lt;strong&gt;worse&lt;/strong&gt;. The self-review changed a correct answer (&amp;quot;is this grammar property decidable? No&amp;quot;) into a wrong one. Five questions fixated on the same grammar, and two of them asked students to prove something it doesn&#39;t generate.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;An 8B model on a 4 GB card can&#39;t be trusted to invent exam questions and their solutions. The checkers can&#39;t save a question that&#39;s wrong to begin with.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;The fix was to give the LLM less to do.&lt;/strong&gt; Decidability tables and language-class questions moved to code. Proof and explanation slots now use &lt;strong&gt;real past questions with their official solutions&lt;/strong&gt; by default. LLM-written questions became an opt-in &lt;code&gt;--prose llm&lt;/code&gt; experiment.&lt;/p&gt;
&lt;h2 id=&quot;marking-proofs&quot;&gt;Marking proofs&lt;/h2&gt;
&lt;p&gt;Proofs can&#39;t be checked by code, so an LLM marks them against a rubric. The 8B model gave a correct proof 1–3 out of 7 and claimed a true step was false. Moving proof marking to &lt;strong&gt;qwen3:14b&lt;/strong&gt; fixed most of that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the correct proof got 5/7, with accurate feedback;&lt;/li&gt;
&lt;li&gt;a deliberately bogus proof got 0/7, so the 14B model wasn&#39;t just being lenient;&lt;/li&gt;
&lt;li&gt;each proof took about 2 minutes to mark.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Proof marks are still labelled advisory in the report.&lt;/p&gt;
&lt;h2 id=&quot;where-it-ended-up-after-one-evening&quot;&gt;Where it ended up after one evening&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A CLI (&lt;code&gt;ingest&lt;/code&gt;, &lt;code&gt;generate&lt;/code&gt;, &lt;code&gt;grade&lt;/code&gt;, &lt;code&gt;check&lt;/code&gt;, &lt;code&gt;serve&lt;/code&gt; and more) and a small web UI built on Python&#39;s standard-library HTTP server.&lt;/li&gt;
&lt;li&gt;108 passing tests, many built from real past-paper answers.&lt;/li&gt;
&lt;li&gt;An example paper, a deliberately mixed sample attempt, and its marking report in the repo.&lt;/li&gt;
&lt;li&gt;A README that records both LLM runs and their failures as well as the successes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;examx generate --no-llm&lt;/code&gt; makes a fresh paper in about a second, and the exact checkers mark it instantly. That alone was worth building. The open question was whether a better model would fix the question writing, which is where &lt;a href=&quot;https://blog.vrp2206.com/posts/examx-part-2/&quot;&gt;part 2&lt;/a&gt; picks up.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>ShipDoc, part 2: moving off AWS before the account expired</title>
    <link href="https://blog.vrp2206.com/posts/shipdoc-homelab/" />
    <updated>2026-10-18T15:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/shipdoc-homelab/</id>
    <content type="html">&lt;p&gt;In &lt;a href=&quot;https://blog.vrp2206.com/posts/shipdoc-aws/&quot;&gt;part 1&lt;/a&gt;, ShipDoc went live on AWS: Lambda for the API, DynamoDB for results, Amplify for the website. The catch was that the AWS account came with the hackathon, and &lt;strong&gt;it expired on 18 October&lt;/strong&gt;. When it did, everything on it would go with it.&lt;/p&gt;
&lt;p&gt;A hackathon project listed on a CV should still work when someone clicks the link months later. So I moved it to my &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-origin/&quot;&gt;homelab server&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;move-first-switch-later&quot;&gt;Move first, switch later&lt;/h2&gt;
&lt;p&gt;I didn&#39;t shut AWS down and then rebuild. Instead:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Run a second copy of ShipDoc on the homelab, on &lt;strong&gt;new subdomains&lt;/strong&gt;: &lt;code&gt;lab.shipdoc.org&lt;/code&gt; for the website and &lt;code&gt;lab-api.shipdoc.org&lt;/code&gt; for the API.&lt;/li&gt;
&lt;li&gt;Test it there while &lt;code&gt;shipdoc.org&lt;/code&gt; was still served from AWS.&lt;/li&gt;
&lt;li&gt;When the account expired, point &lt;code&gt;shipdoc.org&lt;/code&gt; at the homelab.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For a few days both versions were live, and nothing a visitor saw depended on the new one working.&lt;/p&gt;
&lt;h2 id=&quot;from-lambda-back-to-plain-docker&quot;&gt;From Lambda back to plain Docker&lt;/h2&gt;
&lt;p&gt;The cloud version was shaped around Lambda: a Mangum wrapper and DynamoDB, because Lambda runs short-lived copies side by side. The homelab doesn&#39;t need any of that. The repo already had a &lt;code&gt;docker-compose.yml&lt;/code&gt; with two services:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;api&lt;/code&gt;&lt;/strong&gt;: the same FastAPI app, keeping results in a JSON file on a mounted volume, so they survive restarts;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;web&lt;/code&gt;&lt;/strong&gt;: the React site, built into static files and served by nginx.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both are published on &lt;code&gt;127.0.0.1&lt;/code&gt; only, never on the LAN:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;ports&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;127.0.0.1:8000:8000&quot;&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;# api&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;127.0.0.1:5173:80&quot;&lt;/span&gt;     &lt;span class=&quot;token comment&quot;&gt;# web&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One detail matters when moving a Vite site. Vite &lt;strong&gt;bakes the API address into the JavaScript at build time&lt;/strong&gt;, so changing an environment variable on the running container does nothing. The address has to be passed as a build argument (&lt;code&gt;VITE_API_URL=https://lab-api.shipdoc.org&lt;/code&gt;) and the image rebuilt. The Android app is different: it has a setting for the API address, so it could be repointed without a rebuild.&lt;/p&gt;
&lt;h2 id=&quot;caddy-and-the-tunnel&quot;&gt;Caddy and the tunnel&lt;/h2&gt;
&lt;p&gt;Every public site on the homelab follows the same path:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;visitor → Cloudflare → Cloudflare Tunnel → Caddy on :80 → app on 127.0.0.1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The tunnel connects outwards from the server, so there are no open ports on my home router. Cloudflare handles HTTPS, so Caddy only speaks plain HTTP on loopback, with one block per hostname:&lt;/p&gt;
&lt;pre class=&quot;language-caddy&quot;&gt;&lt;code class=&quot;language-caddy&quot;&gt;{
	auto_https off
}
http://lab.shipdoc.org {
	reverse_proxy localhost:5173
}
http://lab-api.shipdoc.org {
	reverse_proxy localhost:8000
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;what-happens-to-shipdoc-org&quot;&gt;What happens to shipdoc.org&lt;/h2&gt;
&lt;p&gt;When the account expired, the Amplify site and the Lambda API went with it. Rather than letting &lt;code&gt;shipdoc.org&lt;/code&gt; show an error, I pointed it at the homelab too and gave it a third Caddy block that serves a single static page:&lt;/p&gt;
&lt;pre class=&quot;language-caddy&quot;&gt;&lt;code class=&quot;language-caddy&quot;&gt;http://shipdoc.org, http://www.shipdoc.org {
	root * /srv/notice
	file_server
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The page says plainly that the AWS deployment was taken down because the account expired, and links to the new website, the new API and the docs (which never depended on AWS: they&#39;re on Cloudflare). Old links in the README, the slides and the demo video still lead somewhere useful.&lt;/p&gt;
&lt;h2 id=&quot;trade-offs&quot;&gt;Trade-offs&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AWS&lt;/th&gt;
&lt;th&gt;Homelab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Hackathon credits, with an end date&lt;/td&gt;
&lt;td&gt;Electricity I already pay for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uptime&lt;/td&gt;
&lt;td&gt;AWS&#39;s problem&lt;/td&gt;
&lt;td&gt;Mine, including power cuts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;Lambda scales out on its own&lt;/td&gt;
&lt;td&gt;One laptop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning&lt;/td&gt;
&lt;td&gt;The AWS console&lt;/td&gt;
&lt;td&gt;Everything, end to end&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For a demo with light traffic, the homelab wins. For a product with real users, it wouldn&#39;t.&lt;/p&gt;
&lt;h2 id=&quot;what-came-next&quot;&gt;What came next&lt;/h2&gt;
&lt;p&gt;Real projects depending on one laptop made me look at that laptop more critically. Two days later I asked for a full &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-audit/&quot;&gt;audit of the homelab&lt;/a&gt;, which turned into getting it ready to run for three months without me.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>How an old gaming laptop became my homelab</title>
    <link href="https://blog.vrp2206.com/posts/homelab-origin/" />
    <updated>2026-09-27T00:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/homelab-origin/</id>
    <content type="html">&lt;p&gt;Windows is my everyday OS. But I wanted to actually &lt;em&gt;learn&lt;/em&gt; Linux, not just use WSL, so I put &lt;strong&gt;Arch Linux&lt;/strong&gt; on my old gaming laptop, a &lt;strong&gt;Lenovo Legion Y540&lt;/strong&gt;. It didn&#39;t stay a learning machine for long.&lt;/p&gt;
&lt;h2 id=&quot;the-hardware&quot;&gt;The hardware&lt;/h2&gt;
&lt;p&gt;It&#39;s not what you&#39;d buy as a server, but it&#39;s more than enough for one:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Spec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;9th-gen Intel Core i5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM&lt;/td&gt;
&lt;td&gt;32 GB at 2666 MHz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;GTX 1650, 4 GB VRAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;NVMe SSD for the system, plus a 1 TB HDD&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The GPU turned out to matter later: it&#39;s enough to run small local language models, which is how &lt;a href=&quot;https://blog.vrp2206.com/posts/examx-part-1/&quot;&gt;ExamX&lt;/a&gt; started.&lt;/p&gt;
&lt;h2 id=&quot;phase-1-a-linux-desktop-to-learn-on&quot;&gt;Phase 1: a Linux desktop to learn on&lt;/h2&gt;
&lt;p&gt;The first setup was a normal desktop: &lt;strong&gt;KDE Plasma&lt;/strong&gt;, the &lt;strong&gt;multilib&lt;/strong&gt; repository enabled (needed for 32-bit libraries), &lt;strong&gt;Steam&lt;/strong&gt;, and &lt;strong&gt;Chrome from the AUR&lt;/strong&gt;. Doing all of that on Arch, by hand, is a good crash course: package management, the AUR, systemd, and reading the wiki until things make sense.&lt;/p&gt;
&lt;h2 id=&quot;phase-2-reachable-from-anywhere&quot;&gt;Phase 2: reachable from anywhere&lt;/h2&gt;
&lt;p&gt;The step that changed what the machine was for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;OpenSSH&lt;/strong&gt;, so I could work on it from my main laptop.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailscale&lt;/strong&gt;, so I could reach it from anywhere without opening any ports on my home router.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once I could SSH in from my Windows laptop on any network, the Legion didn&#39;t need a screen or a keyboard. It became a box I log into.&lt;/p&gt;
&lt;h2 id=&quot;phase-3-hosting-things&quot;&gt;Phase 3: hosting things&lt;/h2&gt;
&lt;p&gt;From there it was a short step to serving websites. Instead of port forwarding, public traffic arrives through a &lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt;, and &lt;strong&gt;Caddy&lt;/strong&gt; routes each domain to the right app running in &lt;strong&gt;Docker&lt;/strong&gt;. My first site went live from it, and the plan is for it to host my hackathon demo sites, so they stay online for my CV long after the event credits run out.&lt;/p&gt;
&lt;p&gt;The longer-term plan is a general-purpose server: hosting, VMs, storage and experiments.&lt;/p&gt;
&lt;h2 id=&quot;what-i-didnt-do-yet&quot;&gt;What I didn&#39;t do (yet)&lt;/h2&gt;
&lt;p&gt;I set it up to &lt;em&gt;work&lt;/em&gt;, not to be &lt;em&gt;robust&lt;/em&gt;. No firewall, password SSH still allowed, no backups.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Update:&lt;/em&gt; a few weeks later &lt;a href=&quot;https://blog.vrp2206.com/posts/shipdoc-homelab/&quot;&gt;ShipDoc&lt;/a&gt; moved onto it, and I asked for a proper &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-audit/&quot;&gt;audit&lt;/a&gt;. It found out exactly how much I&#39;d skipped.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>ShipDoc, part 1: a perfect score locally, 0.9869 on Lambda</title>
    <link href="https://blog.vrp2206.com/posts/shipdoc-aws/" />
    <updated>2026-09-22T06:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/shipdoc-aws/</id>
    <content type="html">&lt;p&gt;At the &lt;strong&gt;Averis × Monash Hackathon 2026&lt;/strong&gt; I was the registered team lead of &lt;strong&gt;Claude&#39;s Plan&lt;/strong&gt;, a five-person team. Our entry is &lt;strong&gt;ShipDoc&lt;/strong&gt; (&lt;a href=&quot;https://shipdoc.org&quot;&gt;shipdoc.org&lt;/a&gt;, docs at &lt;a href=&quot;https://docs.shipdoc.org&quot;&gt;docs.shipdoc.org&lt;/a&gt;). My part was the cloud deployment, the Android build, the rename to ShipDoc and the documentation site. Most of the pipeline itself was written by my teammates.&lt;/p&gt;
&lt;h2 id=&quot;what-shipdoc-does&quot;&gt;What ShipDoc does&lt;/h2&gt;
&lt;p&gt;A shipping-documentation team works from a mixed inbox: requests for Shipping Instructions, invoice queries, automated notices, spam, and the emails that matter most. In those, a carrier sends a draft &lt;strong&gt;Bill of Lading (BL)&lt;/strong&gt; that someone has to check, field by field, against the customer&#39;s &lt;strong&gt;Shipping Instruction (SI)&lt;/strong&gt;. A wrong port or container count that survives into the issued BL is expensive to fix.&lt;/p&gt;
&lt;p&gt;ShipDoc reads that inbox and, for every email:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;sorts it&lt;/strong&gt; into one of five categories: BL comparison, SI request, invoice query, general or spam;&lt;/li&gt;
&lt;li&gt;for BL checks, &lt;strong&gt;reads the SI and BL attachments&lt;/strong&gt; (&lt;code&gt;.txt&lt;/code&gt;, &lt;code&gt;.pdf&lt;/code&gt;, &lt;code&gt;.docx&lt;/code&gt; or &lt;code&gt;.xlsx&lt;/code&gt;) and finds seven fields, whatever they&#39;re labelled (&lt;em&gt;POD&lt;/em&gt; and &lt;em&gt;Discharge Port&lt;/em&gt; are the same field);&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;compares them&lt;/strong&gt; and names the exact fields that differ;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;escalates instead of guessing&lt;/strong&gt; when an attachment is missing, unreadable, the wrong document or has a blank value.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The rule the whole design follows is &lt;strong&gt;&amp;quot;AI reads, code decides.&amp;quot;&lt;/strong&gt; Rules handle classification and extraction first, and an AI model is only asked when they&#39;re unsure. The comparison itself is plain, unit-tested code, so a mismatch can always be re-derived from the two values on screen.&lt;/p&gt;
&lt;p&gt;On the organisers&#39; synthetic 520-email dataset, the rules alone scored &lt;strong&gt;1.000&lt;/strong&gt; on their formula: 46 of 46 defective BLs caught with the exact wrong fields, no false alarms, and all 20 escalations with the right reason.&lt;/p&gt;
&lt;h2 id=&quot;getting-it-onto-aws&quot;&gt;Getting it onto AWS&lt;/h2&gt;
&lt;p&gt;I&#39;d never deployed anything on AWS. The setup we ended up with, all in &lt;code&gt;ap-southeast-1&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Runs on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API (FastAPI)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Lambda&lt;/strong&gt;, from a container image in &lt;strong&gt;ECR&lt;/strong&gt;, wrapped by Mangum, with a public &lt;strong&gt;Function URL&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Results&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DynamoDB&lt;/strong&gt;, pay per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website (React)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Amplify Hosting&lt;/strong&gt;, rebuilt from GitHub on every push&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android app&lt;/td&gt;
&lt;td&gt;The same React code wrapped by Capacitor, as an APK pointed at the Function URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;Gemini on the live deployment; the same interface also supports Bedrock and the Claude API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The DynamoDB part exists because of how Lambda works. The local version kept results in memory or in a JSON file. Lambda runs several short-lived copies of the function side by side, so a result written by one copy is invisible to the next. The storage layer already sat behind an interface, so the cloud got its own &lt;code&gt;DynamoDBStore&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Some first-timer lessons from the deploy, all of which went into the guide on the docs site:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Lambda&#39;s default timeout is 3 seconds.&lt;/strong&gt; Ours runs at 2 minutes with 1024 MB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Container images need &lt;code&gt;--provenance=false&lt;/code&gt;&lt;/strong&gt; when built with a recent Docker, or Lambda rejects them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CORS configured in two places&lt;/strong&gt; (the Function URL and the app) sends duplicate headers, and browsers reject that.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The AWS console quietly switches region&lt;/strong&gt; between pages, which makes resources you just created look missing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;why-the-cloud-scored-0-9869&quot;&gt;Why the cloud scored 0.9869&lt;/h2&gt;
&lt;p&gt;With everything deployed, we ran the full dataset against the live API and compared it with a local run. The scores came out as:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.9869&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Same code, same data. Comparing the two runs email by email showed exactly one PDF that read fine locally but failed on Lambda.&lt;/p&gt;
&lt;p&gt;The cause was &lt;strong&gt;git, not AWS&lt;/strong&gt;. Some of us develop on Windows, and git was set up to convert line endings between Windows (CRLF) and Linux (LF). It treated some PDFs as text and rewrote the line-ending bytes &lt;strong&gt;inside them&lt;/strong&gt;. A PDF records the byte position of every object, so changing a few bytes is enough to corrupt it. The copy on the laptop was fine. The copy that went through git into the container wasn&#39;t.&lt;/p&gt;
&lt;p&gt;The fix, in &lt;code&gt;.gitattributes&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;*.pdf  binary
*.docx binary
*.xlsx binary
*.png  binary
*.apk  binary&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;binary&lt;/code&gt; tells git never to touch those files. We also pinned the PDF library versions so Lambda and the laptops parse PDFs the same way, and made the reader retry a damaged file with its line endings restored. After that, Lambda scored &lt;strong&gt;1.000&lt;/strong&gt; as well.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;If the cloud and your laptop disagree, diff the results per item before suspecting the cloud.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;renaming-the-apk-and-the-docs-site&quot;&gt;Renaming, the APK and the docs site&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The name came from the domain.&lt;/strong&gt; We picked ShipDoc partly because &lt;code&gt;shipdoc.org&lt;/code&gt; was available. I bought it through Cloudflare, pointed it at Amplify, and renamed the website and the Android app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The APK&lt;/strong&gt; was rebuilt against the live API. It also has a setting for changing the API address, so one build works against a laptop, a phone on the same network or the cloud.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The docs site&lt;/strong&gt; is a VitePress site deployed with Cloudflare Workers Builds, separate from the app, so a broken docs build can&#39;t take the product down. It publishes from an &lt;strong&gt;allow-list&lt;/strong&gt; of files: internal notes like the build plan, the frontend/backend handoff and the compliance review can never end up public by accident.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-i-took-away&quot;&gt;What I took away&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Deploy early.&lt;/strong&gt; The corrupted PDF was invisible until the code ran somewhere that wasn&#39;t a laptop.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mark binary files as binary.&lt;/strong&gt; One line of &lt;code&gt;.gitattributes&lt;/code&gt; would have saved a lot of confusion.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Let code decide what code can decide.&lt;/strong&gt; Keeping AI out of the comparison meant every result could be explained and reproduced.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The AWS account came with the hackathon and had an end date. &lt;a href=&quot;https://blog.vrp2206.com/posts/shipdoc-homelab/&quot;&gt;Part 2&lt;/a&gt; is about moving ShipDoc to my homelab before it expired.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Anxin: two AI models, and what to do when only one answers</title>
    <link href="https://blog.vrp2206.com/posts/anxin-muba-hackathon/" />
    <updated>2026-09-11T00:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/anxin-muba-hackathon/</id>
    <content type="html">&lt;p&gt;For the &lt;strong&gt;MUBA Blockchain Hackathon 2026&lt;/strong&gt; I led a team called &lt;strong&gt;Tenners&lt;/strong&gt;. We looked at all three sponsor tracks before committing, and picked Gonka Router&#39;s &lt;strong&gt;AI for Society&lt;/strong&gt; track. Our project, &lt;strong&gt;Anxin (安心)&lt;/strong&gt;, made the &lt;strong&gt;top 21&lt;/strong&gt; of that track. It&#39;s live at &lt;a href=&quot;https://anxin-protect.com&quot;&gt;anxin-protect.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;安心 (ān xīn)&lt;/em&gt; means &amp;quot;peace of mind&amp;quot;.&lt;/p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The problem&lt;/h2&gt;
&lt;p&gt;Scams and misleading claims spread fastest through channels people already trust: forwarded messages, screenshots, family group chats. The people most exposed, especially older adults and people who aren&#39;t comfortable in English, are also the least likely to get a clear explanation of &lt;em&gt;why&lt;/em&gt; something is a scam.&lt;/p&gt;
&lt;p&gt;Anxin lets you paste a message or a link, or upload a screenshot, and returns a report in &lt;strong&gt;English and Simplified Chinese&lt;/strong&gt; with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;Truth Score&lt;/strong&gt; from 0 to 100: how well the evidence supports the claim;&lt;/li&gt;
&lt;li&gt;a separate &lt;strong&gt;fraud-risk&lt;/strong&gt; level: how much the message looks like a scam;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;confidence&lt;/strong&gt; figure that&#39;s honest about how much the models agreed;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;evidence&lt;/strong&gt; used, with links and timestamps;&lt;/li&gt;
&lt;li&gt;and, for every AI call, which model answered and a link to a &lt;strong&gt;public receipt&lt;/strong&gt; from the Gonka network.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Truth Score and fraud risk are deliberately separate. A phishing text (&amp;quot;your account is suspended, verify now&amp;quot;) makes no checkable claim at all, but it&#39;s still dangerous.&lt;/p&gt;
&lt;h2 id=&quot;what-gonka-is&quot;&gt;What Gonka is&lt;/h2&gt;
&lt;p&gt;Gonka is a &lt;strong&gt;decentralised AI inference network&lt;/strong&gt;: models run on machines across the network instead of one company&#39;s servers. Gonka Router is its OpenAI-style gateway. The track&#39;s rule was that &lt;strong&gt;every judgement at runtime must go through Gonka&lt;/strong&gt;. Assistants like Claude could help us write the code, but they couldn&#39;t secretly do the product&#39;s thinking.&lt;/p&gt;
&lt;h2 id=&quot;two-models-never-shown-each-others-answer&quot;&gt;Two models, never shown each other&#39;s answer&lt;/h2&gt;
&lt;p&gt;Anxin sends the same claim and the same evidence to &lt;strong&gt;two different models&lt;/strong&gt;, DeepSeek and MiniMax, at the same time, and neither sees the other&#39;s answer. Then plain, unit-tested code (not another AI) decides what the pair means, based on how far apart their scores are:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gap between the two Truth Scores&lt;/th&gt;
&lt;th&gt;What Anxin does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0–20 points&lt;/td&gt;
&lt;td&gt;They broadly agree: the scores are averaged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;21–40 points&lt;/td&gt;
&lt;td&gt;Partial disagreement: confidence drops by 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;More than 40&lt;/td&gt;
&lt;td&gt;Strong disagreement: confidence drops by 45 and the verdict becomes &lt;em&gt;Insufficient evidence&lt;/em&gt;, not an averaged score&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Averaging a 10 and a 90 into &amp;quot;50&amp;quot; would look precise and mean nothing. When two models strongly disagree, the honest answer is that we don&#39;t know.&lt;/p&gt;
&lt;p&gt;Fraud risk works the other way: it&#39;s &lt;strong&gt;never averaged&lt;/strong&gt;. Anxin takes the higher of the two, because a scam one model missed is still a scam.&lt;/p&gt;
&lt;h2 id=&quot;scam-risk-is-checked-first&quot;&gt;Scam risk is checked first&lt;/h2&gt;
&lt;p&gt;The order of the rules matters. Anxin also has an &lt;strong&gt;evidence gate&lt;/strong&gt;: if neither model found real evidence, the verdict is &amp;quot;insufficient evidence&amp;quot;.&lt;/p&gt;
&lt;p&gt;But a phishing SMS has, by its nature, no evidence on the web to check against. With the evidence gate first, the most dangerous messages we&#39;d ever see would come back as merely &amp;quot;insufficient evidence&amp;quot;. So the fraud check runs &lt;strong&gt;before&lt;/strong&gt; everything else: if the message itself shows strong scam patterns (urgency, impersonation, payment requests), it&#39;s flagged &lt;strong&gt;High risk&lt;/strong&gt;, whatever the evidence or the disagreement.&lt;/p&gt;
&lt;h2 id=&quot;the-hard-part-refusing-a-silent-substitute&quot;&gt;The hard part: refusing a silent substitute&lt;/h2&gt;
&lt;p&gt;This was the most interesting problem in the project.&lt;/p&gt;
&lt;p&gt;Gonka confirmed during the event that DeepSeek was &lt;strong&gt;constantly saturated&lt;/strong&gt;, and that the router&#39;s default failover sends the overflow to... MiniMax. That would quietly turn our &amp;quot;two independent opinions&amp;quot; into &lt;strong&gt;the same model asked twice&lt;/strong&gt;, with the report still claiming cross-verification.&lt;/p&gt;
&lt;p&gt;So every call sends &lt;code&gt;X-Gonka-No-Fallback: true&lt;/code&gt;: give us the model we asked for, or fail. That means we deliberately accept real errors on the DeepSeek side, and the product has to handle them honestly. When only one model answers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the verdict drops to &lt;strong&gt;insufficient&lt;/strong&gt;, unless that one model raised a scam warning (we never mute a warning);&lt;/li&gt;
&lt;li&gt;confidence is &lt;strong&gt;capped at 60&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;the report shows a &lt;strong&gt;&amp;quot;Not cross-verified&amp;quot;&lt;/strong&gt; badge and says in plain language that only one model answered.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;A single opinion should never be dressed up as a consensus.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;receipts-not-trust-us&quot;&gt;Receipts, not &amp;quot;trust us&amp;quot;&lt;/h2&gt;
&lt;p&gt;Every model call returns a request ID, and Gonka publishes a &lt;strong&gt;public receipt&lt;/strong&gt; for each one: which model served it, when, and how many tokens it used. Anxin links to it directly, so anyone can check that a call really ran on Gonka, without going through our server.&lt;/p&gt;
&lt;p&gt;We were careful about what that proves. The receipt comes from GonkaRouter&#39;s own database, so it&#39;s &lt;em&gt;their&lt;/em&gt; attestation, not cryptographic proof, and it says nothing about whether the claim is true. The UI says exactly that: &lt;em&gt;independently verifiable against GonkaRouter, not something we could fabricate on our own server.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;built-to-be-checked&quot;&gt;Built to be checked&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Over 100 backend tests&lt;/strong&gt;, runnable with no API key thanks to a clearly labelled &lt;strong&gt;mock mode&lt;/strong&gt;. A mock report shows a yellow banner, so it can&#39;t be mistaken for a real one in a demo.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Screenshot OCR runs locally&lt;/strong&gt; with Tesseract, and only extracts text. It never makes a judgement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The evidence fetcher is guarded against SSRF&lt;/strong&gt;, so a pasted link can&#39;t be used to make our server request internal addresses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A security script&lt;/strong&gt; runs dependency and code scanners before every release: deterministic tools, not an AI&#39;s opinion.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No smart contract.&lt;/strong&gt; Gonka&#39;s request IDs and receipts were the verification mechanism for this track, so we didn&#39;t deploy a contract just to fill a form field.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-i-took-away&quot;&gt;What I took away&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Design for failure before accuracy.&lt;/strong&gt; The most important code in Anxin decides what to show when the AI is unavailable, disagrees or is wrong.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Watch for silent substitutions.&lt;/strong&gt; A fallback that keeps your app &amp;quot;working&amp;quot; can quietly break the guarantee your product is built on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Say exactly what your proof proves.&lt;/strong&gt; &amp;quot;Verifiable against GonkaRouter&amp;quot; is less exciting than &amp;quot;trustless&amp;quot;, but it&#39;s true.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Top 21 wasn&#39;t a podium finish. But Anxin is still online, and it does something genuinely useful.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Planning errands is the travelling salesman problem in disguise</title>
    <link href="https://blog.vrp2206.com/posts/errand-route-planner/" />
    <updated>2026-09-04T00:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/errand-route-planner/</id>
    <content type="html">&lt;p&gt;For the &lt;strong&gt;Monash × Shortcut Asia Internship Challenge&lt;/strong&gt; I built an &lt;strong&gt;Errand Route Planner&lt;/strong&gt;: drop your stops on a map, and it tells you which order to visit them in so the whole trip is as short as possible.&lt;/p&gt;
&lt;p&gt;It sounds like a small app. Underneath, it&#39;s one of the most famous hard problems in computer science.&lt;/p&gt;
&lt;h2 id=&quot;the-problem-underneath&quot;&gt;The problem underneath&lt;/h2&gt;
&lt;p&gt;&amp;quot;Visit every stop once and come back home, as cheaply as possible&amp;quot; is the &lt;strong&gt;travelling salesman problem (TSP)&lt;/strong&gt;. It&#39;s NP-hard: no known algorithm solves every instance quickly, and the obvious approach (try every order) explodes.&lt;/p&gt;
&lt;p&gt;With &lt;em&gt;n&lt;/em&gt; stops after the starting point, there are &lt;em&gt;n!&lt;/em&gt; orders to try:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stops&lt;/th&gt;
&lt;th&gt;Orders to try&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;3,628,800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;about 1.3 trillion&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Nobody runs 15 errands in one afternoon, but the table shows why &amp;quot;just try them all&amp;quot; stops being an option very quickly.&lt;/p&gt;
&lt;p&gt;So the planner uses &lt;strong&gt;two strategies&lt;/strong&gt;, and picks one based on how many stops there are.&lt;/p&gt;
&lt;h2 id=&quot;small-trips-held-karp-the-exact-answer&quot;&gt;Small trips: Held-Karp, the exact answer&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Held-Karp&lt;/strong&gt; is a dynamic-programming algorithm that finds the &lt;em&gt;optimal&lt;/em&gt; route without trying every order. The key idea is that the best way to finish a route only depends on two things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;which stops you&#39;ve already visited&lt;/strong&gt; (a set, stored as a bitmask), and&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;where you are right now.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It doesn&#39;t matter what order you visited the earlier stops in. So instead of &lt;em&gt;n!&lt;/em&gt; orders, there are only about &lt;em&gt;2ⁿ · n&lt;/em&gt; states to fill in:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;best[visited][last] = shortest path that starts at home,
                      visits exactly the stops in `visited`,
                      and ends at `last`

best[{j}][j]        = dist(home, j)
best[S][j]          = min over k in S, k ≠ j, of
                      best[S − {j}][k] + dist(k, j)

answer              = min over j of best[all][j] + dist(j, home)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A simplified TypeScript version of the core loop:&lt;/p&gt;
&lt;pre class=&quot;language-ts&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;heldKarp&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;dist&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;number&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; n &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; dist&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;length &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;             &lt;span class=&quot;token comment&quot;&gt;// stop 0 is home&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;FULL&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt; n&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; best &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;Array&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; length&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;FULL&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;&lt;span class=&quot;token builtin&quot;&gt;Array&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;n&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;fill&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;Infinity&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; j &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; j &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt; n&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; j&lt;span class=&quot;token operator&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; best&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt; j&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;j&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; dist&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;j &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; mask &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; mask &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;FULL&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; mask&lt;span class=&quot;token operator&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; j &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; j &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt; n&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; j&lt;span class=&quot;token operator&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;mask &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt; j&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;||&lt;/span&gt; best&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;mask&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;j&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;Infinity&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;continue&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; k &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; k &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt; n&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; k&lt;span class=&quot;token operator&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;mask &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt; k&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;continue&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;// already visited&lt;/span&gt;
        &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; next &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; mask &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt; k&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; cost &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; best&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;mask&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;j&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; dist&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;j &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;k &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;cost &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt; best&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;next&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;k&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; best&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;next&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;k&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; cost&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; answer &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;Infinity&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; j &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; j &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt; n&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; j&lt;span class=&quot;token operator&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; answer &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; Math&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;answer&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; best&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token constant&quot;&gt;FULL&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;j&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; dist&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;j &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; answer&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The real version also remembers &lt;em&gt;which&lt;/em&gt; stop each state came from, so it can walk backwards and recover the route itself, not just its length.&lt;/p&gt;
&lt;p&gt;The cost is O(n² · 2ⁿ) time and O(n · 2ⁿ) memory. For a handful of stops that&#39;s instant. For 10 stops it&#39;s about 100,000 steps instead of 3.6 million orders. But 2ⁿ still grows fast, so past a certain size it&#39;s the wrong tool.&lt;/p&gt;
&lt;h2 id=&quot;big-trips-nearest-neighbour-then-2-opt&quot;&gt;Big trips: nearest neighbour, then 2-opt&lt;/h2&gt;
&lt;p&gt;For larger trips the planner gives up on &lt;em&gt;guaranteed&lt;/em&gt; optimal and aims for &lt;em&gt;very good, very fast&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1, nearest neighbour.&lt;/strong&gt; Start at home and always go to the closest stop you haven&#39;t visited yet. It&#39;s O(n²) and gives a reasonable route, but it&#39;s greedy: it often paints itself into a corner and has to make one long, ugly trip back at the end.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2, 2-opt.&lt;/strong&gt; Take the greedy route and keep improving it. Pick two edges, A→B and C→D. If swapping them for A→C and B→D (and reversing the stretch in between) makes the route shorter, do it. Repeat until no swap helps.&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;before:  A → B → … → C → D      (the two edges cross)
after:   A → C → … → B → D      (the B…C stretch is reversed)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Geometrically, 2-opt &lt;strong&gt;uncrosses crossing paths&lt;/strong&gt;. A route that crosses itself is never optimal, so removing every crossing gets you most of the way there. In practice, nearest neighbour plus 2-opt lands close to the true optimum.&lt;/p&gt;
&lt;h2 id=&quot;the-app-around-it&quot;&gt;The app around it&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Vite + React + TypeScript&lt;/strong&gt; for the front end.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leaflet&lt;/strong&gt; for the map: click to add stops, and the route is drawn as soon as it&#39;s solved.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;17 passing tests.&lt;/strong&gt; Route solvers are easy to get subtly wrong, and a wrong route still &lt;em&gt;looks&lt;/em&gt; like a route on the map, so tests were the only way to trust them.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-i-took-away&quot;&gt;What I took away&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Recognise the classic problem under the product.&lt;/strong&gt; &amp;quot;Order my errands&amp;quot; turned out to be TSP, and knowing that meant I could use 60 years of research instead of inventing something.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exact and heuristic can live together.&lt;/strong&gt; Small inputs get a provably best answer; large ones get a fast, good answer. The user just sees a route.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An exact solver is also a test oracle.&lt;/strong&gt; On small inputs, Held-Karp gives the true optimum, which is the yardstick for how good the 2-opt routes really are.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <title>Hello, world (again)</title>
    <link href="https://blog.vrp2206.com/posts/hello-world/" />
    <updated>2026-09-01T00:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/hello-world/</id>
    <content type="html">&lt;p&gt;My &lt;a href=&quot;https://www.vrp2206.com/&quot;&gt;portfolio&lt;/a&gt; shows the finished projects. This blog is for everything around them: the half-working prototypes, the hackathon weekends, the homelab rabbit holes, and the bits of CS that finally clicked.&lt;/p&gt;
&lt;h2 id=&quot;what-i-ll-post-here&quot;&gt;What I&#39;ll post here&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Project write-ups:&lt;/strong&gt; how something was built, what went wrong, and what I&#39;d do differently.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hackathon recaps:&lt;/strong&gt; the idea, the 3am pivots, and the result.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Homelab notes:&lt;/strong&gt; configs, monitoring, and the occasional broken server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Things I&#39;m learning:&lt;/strong&gt; algorithms, AI tooling, cloud, whatever I&#39;m deep in that month.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A lot of my building now happens alongside AI coding tools, mostly &lt;a href=&quot;https://claude.com/claude-code&quot;&gt;Claude Code&lt;/a&gt;. When that&#39;s the case I&#39;ll say so, and I&#39;ll be honest about where the tool helped and where it got things wrong. Those posts are tagged &lt;code&gt;claude-code&lt;/code&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Writing it down is the fastest way to find out whether I actually understand it.&lt;/p&gt;
&lt;/blockquote&gt;
</content>
  </entry>
</feed>