<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Marcello Oliveira · In Production</title><description>Stories about shipping AI and software to production, by Marcello Oliveira.</description><link>https://mrcll.me/</link><language>en</language><atom:link href="https://mrcll.me/rss.xml" rel="self" type="application/rss+xml"/><item><title>Prompt injection is our generation’s XSS</title><link>https://mrcll.me/stories/prompt-injection-is-our-xss/</link><guid isPermaLink="true">https://mrcll.me/stories/prompt-injection-is-our-xss/</guid><description>There is no textContent for a model: what XSS and XSRF teach about prompt injection, and why the defense moves from input to privilege.</description><pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;XSS and XSRF are the same flaw: a system that cannot tell data from instructions, or that acts with authority nobody asked for. LLMs inherited both.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Picture a web app that keeps a JWT in localStorage, valid for two hours. One of its pages has an input, and whatever you type is shown to everyone who visits later. An attacker types a script into it. From then on every visitor’s browser runs that script and hands over the token, and the attacker can use their accounts for up to two hours.&lt;/p&gt;
&lt;p&gt;The fix is almost boring: write the text with textContent instead of innerHTML, or sanitize it before it reaches the DOM. The browser has a reliable way to say “this is text, not code”, and the hole closes.&lt;/p&gt;
&lt;p&gt;Its cousin, XSRF, is about a different mistake. Cookies go along with any request to a site, even when another site triggers it, and even through an invisible iframe that makes you click something you never saw. The browser acts with an authority the user did not grant in that click.&lt;/p&gt;
&lt;p&gt;Now look at an LLM assistant. It reads text from outside, tickets, emails, documents, web pages, and it usually has tools: send an email, query data, create records. Data and instructions travel in the same channel, and authority rides along without anyone asking. It is the old mix, both flaws at once.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (InjectionSandbox): open the web version at https://mrcll.me/stories/prompt-injection-is-our-xss/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;With no defenses, the ticket becomes an order. Fencing the untrusted text helps until the attacker closes the fence themselves; try the third preset. What actually holds is taking the tool away from the task. Summarizing does not need send_email, and any side effect waits for a person to confirm it.&lt;/p&gt;
&lt;h2&gt;Nothing to escape with&lt;/h2&gt;
&lt;p&gt;XSS got solved because the browser can draw a hard line around text. A model cannot. You can ask, fence, repeat and warn, and there is still no guaranteed escape. So the defense has to move away from the input and onto privilege. What the model is allowed to do matters more than what it read.&lt;/p&gt;
&lt;p&gt;In my XSS demo a stolen token lasted two hours. For an agent with tools, the window is a single call. I treat everything a model reads as untrusted, including our own documents. I give each task the fewest tools it needs. And model output is rendered as text, never piped into innerHTML, SQL or a shell.&lt;/p&gt;</content:encoded><category>AI</category><category>Backend</category><category>Frontend</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>We’re bad intuitive statisticians. So are LLMs.</title><link>https://mrcll.me/stories/bad-intuitive-statisticians/</link><guid isPermaLink="true">https://mrcll.me/stories/bad-intuitive-statisticians/</guid><description>Monty Hall, a hostel in Foz do Iguaçu, and what temperature and top-p really do to an LLM’s next token.</description><pubDate>Tue, 10 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;A hostel in Foz do Iguaçu, three doors and a goat taught me more about temperature and top-p than any model card.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I was backpacking through Foz do Iguaçu with Kahneman’s &lt;em&gt;Thinking, Fast and Slow&lt;/em&gt; in my bag when two other backpackers asked what it was about. I gave them the book’s most famous trap: a man is described as shy and helpful. Is he a librarian or a farmer?&lt;/p&gt;
&lt;p&gt;They both said librarian. Everyone does. But there are far more farmers than librarians, so the boring answer is the likely one. The description wins and the base rate loses. We are not intuitive statisticians, and the people who build with language models are no exception.&lt;/p&gt;
&lt;p&gt;A model does not look an answer up. At every step it holds a probability distribution over the next token and draws from it. Temperature and top-p do not make it more creative or more careful; they change the shape of that distribution before the draw. Most teams tune them the way my friends in the hostel guessed: by feel, from one demo.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (Sampler): open the web version at https://mrcll.me/stories/bad-intuitive-statisticians/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Drag temperature down to 0.1 and “tests” wins every single time: deterministic and confidently dull. Push it to 2 and “moon” starts turning up in your incident summary. Top-p is the guard rail. It cuts the tail before the draw, so a hot temperature cannot reach tokens that were never plausible. None of this shows in a single answer. It shows when you sample a thousand times, which is exactly what your users will do for you.&lt;/p&gt;
&lt;h2&gt;Three doors&lt;/h2&gt;
&lt;p&gt;Back in the hostel I pulled out Monty Hall. Three doors, one car, two goats. You pick a door. The host, who knows where the car is, opens another one and shows you a goat, then offers a switch. Both of them said it made no difference: two doors left, fifty-fifty. Play a round, then let the machine play a thousand.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (MontyHall): open the web version at https://mrcll.me/stories/bad-intuitive-statisticians/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Switching wins two times in three. Your first pick is right a third of the time, so it is wrong two thirds of the time, and the host has just shown you where not to look. His choice is evidence, and evidence changes the distribution. That is Bayes, and it is also the whole job with language models: the prompt, the retrieved documents and the examples are evidence that move what the model samples from. Temperature only decides how far into that distribution you let it reach.&lt;/p&gt;
&lt;p&gt;There was one more trap that night, and it is the one I now see most in evaluations. A Fluminense striker scores 1 of 3 shots in the league and 95 of 97 in the Libertadores. A Flamengo striker is better in both: 101 of 300 and 96 of 97. Add the totals and Fluminense wins, 96% to 50%. Simpson’s paradox. A model can beat the baseline in every slice of your eval and still lose overall, or the other way around.&lt;/p&gt;
&lt;p&gt;So I pick temperature per feature, from samples. I judge prompt changes on hundreds of runs, never on the one answer from the demo. And I split every eval by segment before I believe its total.&lt;/p&gt;</content:encoded><category>AI</category><category>Fundamentals</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>0.1 + 0.2 and someone’s salary</title><link>https://mrcll.me/stories/floats-and-salaries/</link><guid isPermaLink="true">https://mrcll.me/stories/floats-and-salaries/</guid><description>Why money is never a float: IEEE 754 bits, payroll drift and rate rounding, shown one payslip at a time.</description><pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;In a programming contest, an epsilon makes the bug go away. In payroll, an epsilon is a person who got paid one cent less.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Here is a program that should print true:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;double phi = (1 + sqrt(5)) / 2;
printf(1 / phi == phi - 1 ? &quot;true&quot; : &quot;false&quot;);   /* false */&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The golden ratio satisfies 1/φ = φ − 1 exactly. The computer disagrees in the sixteenth decimal, because the two sides reach the same number through different roundings. In competitive programming the fix is a reflex: compare with a tolerance, &lt;code&gt;fabs(x - y) &amp;lt; 1e-9&lt;/code&gt;, and move on to the next problem. I did that for years.&lt;/p&gt;
&lt;p&gt;To see why, look at what a double actually holds. 0.1 has no exact binary form, so the machine stores the closest value it can.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (FloatBits): open the web version at https://mrcll.me/stories/floats-and-salaries/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Then the numbers became salaries. Payroll across countries is additions, rates and roundings, millions of times a month, in currencies that do not even agree on how many decimals they have: the Japanese yen has none, the Kuwaiti dinar has three. Every rounding lands in someone’s account, and the totals must reconcile to the last unit. The scenario below is generic, not any company’s system. Add the same amount to a running total, payslip after payslip.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (PayrollDrift): open the web version at https://mrcll.me/stories/floats-and-salaries/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;The drift is a tiny fraction of a cent. Round the total and it even looks right, which is the trap. The equality check fails, the reconciliation job raises an alarm, and someone adds an epsilon to silence it. From then on the epsilon is policy.&lt;/p&gt;
&lt;h2&gt;Where the cents really go&lt;/h2&gt;
&lt;p&gt;The money that actually goes missing comes from somewhere else: applying a rate, an FX conversion or an uplift, and rounding every line.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (RateRounding): open the web version at https://mrcll.me/stories/floats-and-salaries/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;At a rate of 1.15, 2,376 of 100,000 lines round to the wrong cent with floating point. With integer cents and a decimal rate, none do. An epsilon cannot fix that, because nothing is “close enough” about a cent paid to the wrong person.&lt;/p&gt;
&lt;p&gt;The fix is a representation, not a comparison:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;Store money as an integer count of minor units plus a currency, and take the number of decimals from the currency (ISO 4217), never from a global “2”.&lt;/li&gt;&lt;li&gt;Do rate math in a decimal type, choose one rounding rule (half-up or half-even), write it down, apply it once at a defined step, and property-test it against exact arithmetic.&lt;/li&gt;&lt;li&gt;Treat reconciliation as equality. If totals only match with an epsilon, the bug is upstream.&lt;/li&gt;&lt;/ol&gt;</content:encoded><category>Backend</category><category>Fundamentals</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>Tokens are compression: what Shannon explains about your AI bill</title><link>https://mrcll.me/stories/tokens-are-compression/</link><guid isPermaLink="true">https://mrcll.me/stories/tokens-are-compression/</guid><description>Shannon entropy, tokenizers and your AI bill: type any text and watch information, tokens and cost move together.</description><pubDate>Sun, 08 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;Why some prompts cost twice as much as others, explained by a gigabyte of ones and a sentence about a fire.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Every LLM invoice is a count of tokens, in and out. Teams look at it the way they look at a phone bill: a number that goes up. It is more interesting than that. A token count is a measurement of how surprising your text is.&lt;/p&gt;
&lt;p&gt;Start with a file where every byte is 1. Make it a gigabyte. How much information does it hold? Almost none. If you know it is all ones, you only need its length. Data and information are different things. Or take the sentence “I saw a fire, and it had flames.” Every fire has flames; the second half can go and nothing is lost. Information is surprise.&lt;/p&gt;
&lt;p&gt;A tokenizer works on that idea. Before a model reads anything, the text is compressed into tokens, pieces from a dictionary learned from the training data. Frequent pieces get a token of their own. Rare ones get spelled out in several. So the bill tracks surprise, not length. Try it:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (TokenMeter): open the web version at https://mrcll.me/stories/tokens-are-compression/&lt;/p&gt;&lt;/blockquote&gt;
&lt;h2&gt;The arithmetic of “euameiela”&lt;/h2&gt;
&lt;p&gt;Run the figure on “euameiela”. Nine characters, 72 bits on disk, and only about 21.8 bits of information, 2.419 bits per character. Roughly 70% of the space is wasted. A hand-built prefix code, one bit for the frequent “e”, longer codes for the rare letters, squeezes it to 22 bits. That is as low as it goes: bits are whole numbers and entropy is the floor.&lt;/p&gt;
&lt;p&gt;Byte-pair encoding is the same trick at scale. Instead of giving “e” a short code, it gives common chunks of text a single token. Then paste a UUID, a base64 blob or a minified JSON into the box above. Entropy per character climbs, the words stop looking like words, and the token count runs ahead of the character count.&lt;/p&gt;
&lt;p&gt;Language matters too. With mostly English training data, an English sentence usually costs fewer tokens than the same sentence in Portuguese. The two presets show it.&lt;/p&gt;
&lt;p&gt;What I do with this: I treat IDs, hashes and noisy JSON in prompts as the expensive part they are. I measure with the provider’s own tokenizer in every language we ship. And anything sent on every request gets cached, shortened or removed, because it is a line in the budget whether anyone reads it or not.&lt;/p&gt;</content:encoded><category>AI</category><category>Fundamentals</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>From Q-learning to LLM agents</title><link>https://mrcll.me/stories/q-learning-to-llm-agents/</link><guid isPermaLink="true">https://mrcll.me/stories/q-learning-to-llm-agents/</guid><description>From a Q-learning maze to LLM agents in production: in an agent, the reward is the design.</description><pubDate>Sat, 07 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;The first agent I trained needed 799 episodes to escape a small maze. Production agents get no practice runs at all.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The first agent I trained lived in a grid. One cell paid +1, one cost −1, one was a wall, and the rest was empty floor. It knew nothing. It learned from rewards alone, with the Bellman equation doing the bookkeeping:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;at episode 0 it wandered;&lt;/li&gt;&lt;li&gt;at episode 199 it knew more, and still failed;&lt;/li&gt;&lt;li&gt;at episode 599 it was getting there;&lt;/li&gt;&lt;li&gt;by episode 799 it solved the maze every time.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Today “agent” almost always means a language model in a loop: read the state, pick a tool, look at the result, repeat. The vocabulary survived intact. Agent, environment, reward, policy. What did not survive is the training. Nobody gives you 800 episodes on real users before the agent has to behave.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (QMaze): open the web version at https://mrcll.me/stories/q-learning-to-llm-agents/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Train one episode at a time and watch the arrows spread backwards from the +1, like a stain. Set the step penalty to zero and the agent gets lazy: it still arrives, in no hurry at all. Set exploration to zero and it may settle for the first path that happened to work. The maze makes something obvious that is easy to forget with LLMs: the reward function is the design. Penalties are what make the agent finish quickly.&lt;/p&gt;
&lt;p&gt;With an LLM agent the policy arrives pre-trained, the model plus your prompt. The reward is still yours to write. It becomes the eval that scores the whole trajectory instead of only the final answer. The step penalty becomes the limit on steps, tokens and time. And one property of Q-learning carries over exactly: the values only stay right while the environment stays the same. Change a tool, an API or a schema and the old policy is stale until you run the evals again.&lt;/p&gt;
&lt;p&gt;799 episodes for a three-by-four grid. Your agent in production gets zero. Write the reward first.&lt;/p&gt;</content:encoded><category>AI</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>Running code on other people’s sites</title><link>https://mrcll.me/stories/running-code-on-other-peoples-sites/</link><guid isPermaLink="true">https://mrcll.me/stories/running-code-on-other-peoples-sites/</guid><description>One tracking pixel, three sites, and what it takes to run your code as a guest on someone else’s website.</description><pubDate>Fri, 06 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;I once built a script that lived inside our customers’ websites. The mechanism that tracks you across the web is the same one; the responsibility is what changes.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;At Social Miner I built and maintained URSA: a script our customers embedded through a pixel, the way you embed Google Tag Manager. It ran in a hostile environment, inside the browser sandbox of sites we did not control and could not test against in advance. Its onsite and offsite parts talked through postMessage, with a small pub/sub on top. Behavior events were batched in IndexedDB and shipped by a service worker, so nothing was lost when someone closed the tab. URSA also captured push-notification permission, which is what made our Push Notification product possible. I led a team of four on it and on the Channel Builder, built with .NET and React.&lt;/p&gt;
&lt;p&gt;Code in someone else’s page sees a lot. To understand how much, imagine a large company, call it MOZART, that wants to recognize the same person anywhere on the web without ever knowing who they are. It defines a person as a set of actions: visited the Palmeiras page on globoesporte at 6:30 pm, then the São Paulo state government site at 6:35 pm. From that it guesses: supports Palmeiras, lives in São Paulo, has free time around 6:30. To stitch the visits together it needs only two HTTP headers. The referer says where a request came from. A cookie, mozart.id, identifies the browser. Any site that loads mozart.com/external.png hands over both.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (TrackingPixel): open the web version at https://mrcll.me/stories/running-code-on-other-peoples-sites/&lt;/p&gt;&lt;/blockquote&gt;
&lt;h2&gt;A pixel is enough&lt;/h2&gt;
&lt;p&gt;Visit the three sites with everything allowed and watch the profile assemble. Then block third-party cookies: each request arrives anonymous and the visits stop linking. Trim the referer to the origin and “Palmeiras” disappears, because the page path no longer travels. Browsers have moved to that side, limiting third-party cookies and trimming cross-site referers by default.&lt;/p&gt;
&lt;p&gt;For code embedded in good faith that is not an obstacle. It is the contract. Every widget, SDK and embedded chat is a guest in someone else’s house, and a good guest:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;stays isolated, in an iframe or Shadow DOM, with no globals and no leaking CSS;&lt;/li&gt;&lt;li&gt;relies only on what was offered, assumes no third-party cookies and a trimmed referer, and asks for anything else explicitly;&lt;/li&gt;&lt;li&gt;has a budget for weight and for failure, so that when it breaks the host page stays up.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;A one-by-one image used to be enough to learn a team, a city and a time of day. What you do with what arrives is the whole difference.&lt;/p&gt;</content:encoded><category>Frontend</category><category>Backend</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>Progressive enhancement for AI-generated UI</title><link>https://mrcll.me/stories/progressive-enhancement-ai-ui/</link><guid isPermaLink="true">https://mrcll.me/stories/progressive-enhancement-ai-ui/</guid><description>Progressive enhancement for AI-generated UI: ship the core without the model and feature-detect the AI layer.</description><pubDate>Thu, 05 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;Some people will not get the full experience. That was true of old browsers, and it is true again now that a model sits inside the interface.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Does it really matter to use a feature that not every browser supports? We already accept different experiences on different devices. Why not on different browsers?&lt;/p&gt;
&lt;p&gt;The answer turns on one detail. People on an older browser may have no choice, or may not know how to upgrade. That is not their fault, so the core has to work everywhere and the rest comes on top: &lt;code&gt;@supports&lt;/code&gt; to detect CSS features, Modernizr when you need more. Progressive enhancement is an old idea. It is about to be useful again.&lt;/p&gt;
&lt;p&gt;AI-generated UI adds a dependency that behaves worse than any browser ever did. It is sometimes slow, sometimes wrong, sometimes down, and sometimes switched off by the user. If the page only makes sense when the model answers, your provider’s worst day becomes your product’s worst day. The model is the least reliable layer you have ever shipped.&lt;/p&gt;
&lt;p&gt;Here is a card built in layers. Take them away one at a time.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (LayeredCard): open the web version at https://mrcll.me/stories/progressive-enhancement-ai-ui/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Turn JavaScript off and the facts are still there. Pretend the browser has no container queries and the filters stay, only stacked. Make the model fail and only the summary goes away; its reserved slot says so instead of collapsing the layout. Make the model slow and the text arrives in pieces, in a place that was already its own.&lt;/p&gt;
&lt;p&gt;Zero model calls are needed for the page to be useful. That is the whole design.&lt;/p&gt;
&lt;p&gt;The tools changed; the idea did not. Where &lt;code&gt;@supports&lt;/code&gt; used to stand, there is now a timeout, a kill switch and a user setting. The core does not depend on the new layer, and the new layer announces itself when it arrives: streamed into a slot that was reserved for it, so nothing jumps, and labelled, so nobody mistakes generated text for the facts around it.&lt;/p&gt;</content:encoded><category>AI</category><category>Frontend</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>Why I wrote my own renderer</title><link>https://mrcll.me/stories/why-i-wrote-my-own-renderer/</link><guid isPermaLink="true">https://mrcll.me/stories/why-i-wrote-my-own-renderer/</guid><description>bradom: six rendering strategies written from scratch, from innerHTML to Virtual DOM and tagged templates, to make one tooling choice.</description><pubDate>Wed, 04 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;Before picking the tooling for XP’s design system, I wanted to understand what it did inside. The result was bradom: six ways to render the same interface, written from scratch.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;When I had to choose the tools for the XP Inc. design system, none of them was a perfect fit. Every option had benefits and costs, and reading comparisons was not settling anything. So I raised a slightly cheeky possibility with the team: what if we built our own library? If others built one, why not us?&lt;/p&gt;
&lt;p&gt;That question became bradom, a study monorepo with six packages, each rendering the same interface a different way:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;naive innerHTML;&lt;/li&gt;&lt;li&gt;Document Fragment;&lt;/li&gt;&lt;li&gt;a Virtual DOM without diffing;&lt;/li&gt;&lt;li&gt;a full Virtual DOM;&lt;/li&gt;&lt;li&gt;tagged template literals (left incomplete);&lt;/li&gt;&lt;li&gt;a React benchmark to keep me honest.&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;The code is on &lt;a href=&quot;https://github.com/marcello0liveira/bradom&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Where the time goes&lt;/h2&gt;
&lt;p&gt;The browser turns bytes into characters, tokens, nodes, DOM and CSSOM, combines them into the render tree, computes layout and paints. Changing the DOM can restart part of that path, and avoiding needless reflow is most of what rendering optimization means.&lt;/p&gt;
&lt;p&gt;The easiest approach, rewriting innerHTML, is also the worst. It throws away element state, like an input’s focus and the text in it, and it opens a door to XSS. There is a reason React calls its version dangerouslySetInnerHTML. Type into the filter below and watch what each strategy does to the list.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (RenderCost): open the web version at https://mrcll.me/stories/why-i-wrote-my-own-renderer/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Going from “ab” to “a”, innerHTML throws everything away and rebuilds it. The Virtual DOM lets you write code as if you were recreating everything, then compares the new tree with the old one and touches only what changed, so state survives. Its price is evaluating the whole tree on every change, including the parts that never change.&lt;/p&gt;
&lt;h2&gt;The cache nobody tells you about&lt;/h2&gt;
&lt;p&gt;Tagged templates have a quiet property. The first argument the tag function receives, the array of static strings, is the same object every time the same template runs. You can use it as a cache key. The HTML is already split into static parts and dynamic holes, so only the holes need re-evaluating. UI = f(state), recomputed only where the state changed.&lt;/p&gt;
&lt;p&gt;So did we ship bradom? No. For Soma, the design system, I chose a technology that was not my own. Writing the renderer was never about shipping it. It was about choosing with my hands instead of with blog posts, knowing exactly where the cost would be: in nodes evaluated and DOM operations, not in lines of code.&lt;/p&gt;</content:encoded><category>Frontend</category><category>Fundamentals</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>The Northeast and the Brasileirão</title><link>https://mrcll.me/stories/northeast-brasileirao/</link><guid isPermaLink="true">https://mrcll.me/stories/northeast-brasileirao/</guid><description>Northeastern clubs in the Brasileirão: 44,006 km of travel for Fortaleza against 11,992 for São Paulo clubs in 2023.</description><pubDate>Tue, 03 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;Two teams out of twenty. A share that was once half the table, and once zero.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Five games in fifteen days. In 2022 Fortaleza hosted São Paulo, flew to play Vitória, Botafogo and Alianza Lima, and came home for Fluminense. That is 10,362 km in a straight line, and the flight is not always chartered.&lt;/p&gt;
&lt;p&gt;The 2023 Série A started with 2 Northeastern clubs out of 20, or 10%. It has been very different. In 1962, 9 of the clubs in the Taça Brasil, half the field, came from the Northeast. In 1967, in the Torneio Roberto Gomes Pedrosa, there were none. Today’s share sits closer to the region’s slice of GDP, 13.6%, than to its share of the population, 30%.&lt;/p&gt;
&lt;p&gt;There are many reasons for that, and distance weighs more than most, because the calendar never stops to let anyone recover. Here is what a season of travel looks like.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (TravelChart): open the web version at https://mrcll.me/stories/northeast-brasileirao/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;To play the 2023 Série A, Fortaleza travels 44,006 km in a straight line. The São Paulo clubs travel 11,992.&lt;/p&gt;
&lt;p&gt;And still: Fortaleza became the first Northeastern club to play the Libertadores in consecutive years, 2022 and 2023, and in 2022 drew the two biggest crowds of the group stage, 50,429 paying fans against Colo-Colo and 48,867 against River Plate. Every league table hides a cost it does not show. In this one, it is the distance.&lt;/p&gt;</content:encoded><category>Off the code</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>Fibonacci of 10¹⁸ in 84 multiplications</title><link>https://mrcll.me/stories/linear-recurrences-log-time/</link><guid isPermaLink="true">https://mrcll.me/stories/linear-recurrences-log-time/</guid><description>Fibonacci of 10^18 in 84 matrix multiplications: linear recurrences in logarithmic time.</description><pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;Every linear recurrence can be written as matrix multiplication. And matrices can be raised to a power fast.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;What is the 10¹⁸th Fibonacci number, modulo something reasonable? A loop would still be running long after the contest ended. You get there with 84 multiplications.&lt;/p&gt;
&lt;p&gt;A linear recurrence is a sequence in which each term is a linear combination of the ones before it, and Fibonacci is the famous example. Any such step can be written as a matrix T times a vector of recent terms. One application of T moves the sequence forward by one. Two applications, T², move it by two. And T^(n−1), applied to f(0) and f(1), which you already know, lands on f(n).&lt;/p&gt;
&lt;p&gt;So the question becomes how fast you can raise a matrix to a power. You do not need n multiplications to reach xⁿ. From x you get x², from x² you get x⁴, and from x⁴ you get x⁸. Any exponent is a sum of those powers of two, read straight from its binary digits. Squaring turns n steps into about log n.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (Recurrence): open the web version at https://mrcll.me/stories/linear-recurrences-log-time/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;The loop needs 10¹⁸ steps; the matrix needs 84 multiplications. When something grows one step at a time, look for the form that doubles. There almost always is one.&lt;/p&gt;</content:encoded><category>ICPC</category><category>Fundamentals</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>How many swaps until it is sorted?</title><link>https://mrcll.me/stories/counting-inversions/</link><guid isPermaLink="true">https://mrcll.me/stories/counting-inversions/</guid><description>Counting inversions with Bubblesort and Mergesort, and why the same count compares two rankings.</description><pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;Counting inversions is a famous problem with many known solutions. Bubblesort counts by swapping; Mergesort counts without looking at every pair.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Take [1,4,3,2] and run one pass of Bubblesort. Compare 1 and 4: leave them. Compare 4 and 3: swap. Compare 4 and 2: swap. You are left with [1,3,2,4], and one more swap finishes the job. Three swaps in total.&lt;/p&gt;
&lt;p&gt;That is not a coincidence. Every adjacent swap undoes exactly one inversion, a pair that is in the wrong order, so the number of swaps Bubblesort makes is the number of inversions. A counter next to the swap is the whole algorithm.&lt;/p&gt;
&lt;p&gt;It is also O(n²), which is fine for four numbers and useless for a million. Mergesort sorts in O(n log n), and it can count while it sorts. When the merge takes an element from the right half before what is left of the left half, that element jumps over every one of them, and each jump is an inversion. Add them up and you never look at a single pair directly.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (Inversions): open the web version at https://mrcll.me/stories/counting-inversions/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;The count is more than a contest trick. It is a distance between two orderings, the number of pairs two lists disagree on. When a new reranker goes out and you want to know how far it moved from the old ranking, this is the number, and now you can compute it in O(n log n).&lt;/p&gt;</content:encoded><category>ICPC</category><category>Fundamentals</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>Counting without enumerating</title><link>https://mrcll.me/stories/digit-dp/</link><guid isPermaLink="true">https://mrcll.me/stories/digit-dp/</guid><description>Digit dynamic programming and the tight flag: counting numbers with a digit property without enumerating them.</description><pubDate>Sat, 31 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;How many numbers between A and B have some property in their digits? Digit DP answers without looking at them one by one.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;How many numbers up to 1,000 have no digit 4 and never contain “13”? You could check all thousand. The answer is 712, and you can get it by visiting 9 states.&lt;/p&gt;
&lt;p&gt;First, a range is two prefixes: the count for [A, B] is the count for [0, B] minus the count for [0, A−1]. So you only ever count from zero up to some N.&lt;/p&gt;
&lt;p&gt;Inside [0, N], build the number from its most significant digit and carry one flag, tight, which says whether you are still pressed against the limit. If N is 723 and you put a 7 first, the next digit can only go up to 2. Put a 6 first and you are free: anything from 0 to 9 works from there on.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (DigitDP): open the web version at https://mrcll.me/stories/digit-dp/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Why does memoization pay off? Think of an 18-digit number that ends in 0123456789. Every possible prefix in front of that ending leads to the same question about the same remaining digits, 10⁸ identical calculations if you recompute it each time. Store it once and look it up.&lt;/p&gt;
&lt;p&gt;The state stays small: the position, whatever the question needs to remember (here, whether the last digit was a 1), and tight. The count of states depends on how many digits N has, not on how big N is. Before you enumerate anything, ask what the smallest state is that sums up everything that came before.&lt;/p&gt;</content:encoded><category>ICPC</category><category>Fundamentals</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>Cut it into √n pieces</title><link>https://mrcll.me/stories/root-decomposition/</link><guid isPermaLink="true">https://mrcll.me/stories/root-decomposition/</guid><description>Square-root decomposition for range queries, from my first talk at the first Code Camp IFPB/ACM.</description><pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;The first talk I ever gave, at the first Code Camp IFPB/ACM, was about this.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The first talk I ever gave was at the first Code Camp IFPB/ACM, and it was about a technique you can explain in one sentence: split the array into blocks of size √n and keep the sum of each block.&lt;/p&gt;
&lt;p&gt;That is it. A range-sum query in the simple loop costs up to n steps. With blocks, it costs the loose ends, at most 2√n single elements at the edges, plus the whole blocks in the middle, at most √n sums you already have. Updating one value means fixing one element and one block sum.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (SqrtDecomp): open the web version at https://mrcll.me/stories/root-decomposition/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;For 800,000 elements, that is the gap between 800,000 steps and 2,798. Why √n and not some other size? Bigger blocks mean fewer of them but longer loose ends; smaller blocks, the opposite. √n is where the two costs meet.&lt;/p&gt;
&lt;p&gt;It is not the fastest structure there is; a segment tree does better. But square-root decomposition is the one you can write correctly in ten minutes, in a contest, under pressure, and that counts for a lot. The good-enough structure you fully understand beats the optimal one you copy.&lt;/p&gt;</content:encoded><category>ICPC</category><category>Fundamentals</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item><item><title>Brasileirão 2023, the most titles on the pitch</title><link>https://mrcll.me/stories/brasileirao-2023-titles/</link><guid isPermaLink="true">https://mrcll.me/stories/brasileirao-2023-titles/</guid><description>The 2023 Brasileirão started with 65 national titles on the pitch, the most in the points era since 2003.</description><pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;Since the points era began in 2003, the Série A had never started with this much tradition.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Add up the national titles of every club in a Série A season and you get a rough measure of how heavy the shirts are. The 2023 Brasileirão started with 65 of them, the most since the points era began in 2003. The year before it was 52. The previous high was 60.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Interactive figure (TitlesChart): open the web version at https://mrcll.me/stories/brasileirao-2023-titles/&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;Comparing seasons needs a footnote, though. In 2003 and 2004 the league had 24 teams, in 2005 it had 22, and only from 2006 did it settle at 20. More teams on the pitch, more possible titles. Two champions were missing from the count, too: Sport (1987) and Guarani (1978) were playing in Série B.&lt;/p&gt;
&lt;p&gt;At the other end, five clubs could lift the trophy for the first time: América-MG, Bragantino, Cuiabá, Fortaleza and Goiás.&lt;/p&gt;
&lt;p&gt;Do heavier shirts make a tighter race? Not necessarily. One of the seasons near the top of this chart ended with a standard deviation of 16.26 points in the final table, well off the usual balance. Tradition fills the pitch. It does not decide the table.&lt;/p&gt;</content:encoded><category>Off the code</category><author>oliv.marq.marcello@gmail.com (Marcello Oliveira)</author></item></channel></rss>