<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Xinyang Wu</title>
    <description>The latest articles on DEV Community by Xinyang Wu (@xinyangwuethz).</description>
    <link>https://dev.to/xinyangwuethz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3971050%2F40c7cbce-7957-4642-a778-9b8f0329c053.png</url>
      <title>DEV Community: Xinyang Wu</title>
      <link>https://dev.to/xinyangwuethz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/xinyangwuethz"/>
    <language>en</language>
    <item>
      <title>A Working Map of Computer Networking</title>
      <dc:creator>Xinyang Wu</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:39:29 +0000</pubDate>
      <link>https://dev.to/xinyangwuethz/a-working-map-of-computer-networking-287k</link>
      <guid>https://dev.to/xinyangwuethz/a-working-map-of-computer-networking-287k</guid>
      <description>&lt;h2&gt;
  
  
  Layering is the whole trick
&lt;/h2&gt;

&lt;p&gt;Networking looks like a pile of acronyms until you notice the one organizing idea behind all of it: &lt;strong&gt;layering&lt;/strong&gt;. Each layer solves exactly one problem and offers a clean interface to the layer above. That is why you can swap WiFi for a cable — a link-layer change — and your browser, living at the application layer, never notices.&lt;/p&gt;

&lt;p&gt;The OSI seven-layer model is the theory; the five-layer TCP/IP model is what people actually reason with:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Representative protocols&lt;/th&gt;
&lt;th&gt;Addressing unit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Application&lt;/td&gt;
&lt;td&gt;conversation rules between programs&lt;/td&gt;
&lt;td&gt;HTTP, DNS, SSH, SMTP&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport&lt;/td&gt;
&lt;td&gt;end-to-end delivery, reliability, ports&lt;/td&gt;
&lt;td&gt;TCP, UDP&lt;/td&gt;
&lt;td&gt;port&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;addressing and routing across networks&lt;/td&gt;
&lt;td&gt;IP, ICMP&lt;/td&gt;
&lt;td&gt;IP address&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Link&lt;/td&gt;
&lt;td&gt;delivery within one physical network&lt;/td&gt;
&lt;td&gt;Ethernet, ARP&lt;/td&gt;
&lt;td&gt;MAC address&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physical&lt;/td&gt;
&lt;td&gt;bits to signals&lt;/td&gt;
&lt;td&gt;copper, fiber, radio&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mechanism that stitches the layers together is &lt;strong&gt;encapsulation&lt;/strong&gt;. As data moves down the stack, each layer wraps it in its own header: your HTTP request becomes the payload of a TCP segment, which becomes the payload of an IP packet, which becomes the payload of an Ethernet frame. On the receiving side, each layer peels off its header and hands the payload up. Once encapsulation clicks, most of networking stops being trivia and starts being a map.&lt;/p&gt;

&lt;h2&gt;
  
  
  TCP vs UDP
&lt;/h2&gt;

&lt;p&gt;The transport layer offers two very different contracts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;TCP&lt;/th&gt;
&lt;th&gt;UDP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Connection&lt;/td&gt;
&lt;td&gt;connection-oriented (handshake first)&lt;/td&gt;
&lt;td&gt;connectionless (just send)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reliability&lt;/td&gt;
&lt;td&gt;lost segments are retransmitted&lt;/td&gt;
&lt;td&gt;no guarantees; packets may vanish&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ordering&lt;/td&gt;
&lt;td&gt;delivered in order&lt;/td&gt;
&lt;td&gt;no ordering promise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overhead&lt;/td&gt;
&lt;td&gt;heavier, 20-byte header&lt;/td&gt;
&lt;td&gt;light, 8-byte header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical use&lt;/td&gt;
&lt;td&gt;web pages, file transfer, mail&lt;/td&gt;
&lt;td&gt;video calls, games, DNS queries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My favorite mnemonic: &lt;strong&gt;TCP is a phone call&lt;/strong&gt; — you establish the line first, confirm the other side is listening, and speak in order. &lt;strong&gt;UDP is a postcard&lt;/strong&gt; — you drop it in the mailbox and hope.&lt;/p&gt;

&lt;p&gt;The interesting question is why anyone would choose the postcard. Real-time video is the canonical answer: in a live stream, a dropped frame is better than a stalled one. If TCP paused the stream to retransmit a frame from 300 ms ago, you would get exactly the freezing behavior you hate. A brief visual glitch and moving on is the correct trade, so real-time media rides on UDP and handles loss at the application layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opening and closing a connection
&lt;/h2&gt;

&lt;p&gt;TCP's reliability starts before any data flows, with the &lt;strong&gt;three-way handshake&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client -&amp;gt; SYN         "I want to connect; my sequence number is x"
server -&amp;gt; SYN + ACK   "Ready on my side; my sequence is y, and I saw your x"
client -&amp;gt; ACK         "I saw your y — data can flow"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why three messages and not two? Because both sides need evidence that &lt;strong&gt;both directions work&lt;/strong&gt; — that each peer can send and receive. After two messages, the server still has no proof the client ever heard its reply. Two-message setup also opens the door to a stale &lt;code&gt;SYN&lt;/code&gt; that wandered the network for a while: it could conjure a phantom connection the client never intended. The third message closes both gaps.&lt;/p&gt;

&lt;p&gt;Teardown takes &lt;strong&gt;four&lt;/strong&gt; messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client -&amp;gt; FIN   "No more data from me"
server -&amp;gt; ACK   "Understood"
server -&amp;gt; FIN   "I am finished too"
client -&amp;gt; ACK   "Goodbye"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why do the ACK and FIN not always merge the way SYN and ACK do during setup? Because closing is &lt;strong&gt;one-directional&lt;/strong&gt;. When the client says it has nothing left to send, the server may still have responses in flight. It acknowledges the client's FIN immediately, keeps sending until it is done, and only then sends its own FIN. Half-closed connections are a feature, not an accident. That said, when the server has nothing left to send — the typical short-lived HTTP exchange — it does combine the two into a single FIN+ACK segment, collapsing the close to three segments, which is exactly what most packet captures show.&lt;/p&gt;

&lt;h2&gt;
  
  
  From URL to pixels
&lt;/h2&gt;

&lt;p&gt;Typing a URL and hitting enter exercises every layer in sequence, which makes it the best single walkthrough in networking:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;DNS lookup&lt;/strong&gt; — the domain name becomes an IP address (application layer, usually over UDP).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TCP handshake&lt;/strong&gt; — three messages to the server's IP (transport layer).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS handshake&lt;/strong&gt; — for HTTPS, the two sides negotiate a symmetric key so everything after is encrypted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP request&lt;/strong&gt; — &lt;code&gt;GET /&lt;/code&gt; plus headers travels through the established, encrypted channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response&lt;/strong&gt; — the server returns a status code and the HTML body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt; — the browser parses HTML, CSS, and JavaScript, building the page and often firing more requests for images and scripts, each repeating some of the steps above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teardown&lt;/strong&gt; — connections close with the four-message sequence when they are no longer needed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every hop names its layer: DNS at the application layer, TCP at transport, IP routing at the network layer, Ethernet or WiFi at the link layer underneath it all. If you can narrate this end to end, the layer model has done its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTP essentials
&lt;/h2&gt;

&lt;p&gt;HTTP method semantics are worth internalizing, because retry logic and caching depend on them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Idempotent&lt;/th&gt;
&lt;th&gt;Safe (read-only)&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;fetch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUT&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;set or overwrite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DELETE&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;remove&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;create&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Idempotent means repeating the request leaves the system in the same state — which is why a client may safely retry a timed-out PUT but should think twice before retrying a POST.&lt;/p&gt;

&lt;p&gt;Status codes come in classes: 1xx informational, 2xx success (200), 3xx redirection (301, 304), 4xx client errors (401, 403, 404), 5xx server errors (500, 502, 503). The class tells you who to blame before you read the digits.&lt;/p&gt;

&lt;p&gt;Finally, HTTP is &lt;strong&gt;stateless&lt;/strong&gt;: the protocol itself remembers nothing between requests. Login state is layered on top with cookies, server-side sessions, or signed tokens — three different answers to the same question of how a stateless protocol keeps recognizing you.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS resolution
&lt;/h2&gt;

&lt;p&gt;Name resolution walks a chain of lookups — most of them caches — and the first hit wins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;browser cache -&amp;gt; hosts file (/etc/hosts) -&amp;gt; OS DNS cache -&amp;gt; local resolver (often the ISP's)
    -&amp;gt; root servers (.) -&amp;gt; TLD servers (.com) -&amp;gt; authoritative servers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One distinction worth keeping sharp: the hosts file is &lt;strong&gt;static configuration, not a cache&lt;/strong&gt;. The OS DNS cache proper — systemd-resolved on Linux, mDNSResponder on macOS — stores previous answers with their TTLs and can simply be flushed when it goes stale, while a hosts-file entry sits there until someone edits it out. When you are chasing a wrong DNS answer, knowing which of the two you are looking at decides the fix.&lt;/p&gt;

&lt;p&gt;The local resolver typically does the legwork for you — a &lt;strong&gt;recursive query&lt;/strong&gt; — walking from the root to the top-level domain to the authoritative server, then caching the answer with its TTL so the next lookup short-circuits. Queries usually travel over &lt;strong&gt;UDP port 53&lt;/strong&gt; because they are tiny and speed matters; large responses and zone transfers fall back to TCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flow control vs congestion control
&lt;/h2&gt;

&lt;p&gt;These two TCP mechanisms are easy to conflate because both slow a sender down. They protect different victims:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Flow control&lt;/th&gt;
&lt;th&gt;Congestion control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Protects&lt;/td&gt;
&lt;td&gt;the receiving host&lt;/td&gt;
&lt;td&gt;the network as a whole&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mechanism&lt;/td&gt;
&lt;td&gt;sliding window — the receiving side advertises how much it can accept&lt;/td&gt;
&lt;td&gt;slow start, congestion avoidance, fast retransmit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Viewpoint&lt;/td&gt;
&lt;td&gt;point-to-point, between two endpoints&lt;/td&gt;
&lt;td&gt;global, across everyone sharing the path&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Flow control is a courtesy between two machines: do not overrun the buffer at the far end. Congestion control is a social contract: probe the shared network gently, back off when packets start disappearing, because loss is the network's way of saying it is full.&lt;/p&gt;

&lt;h2&gt;
  
  
  Addresses and NAT
&lt;/h2&gt;

&lt;p&gt;An IPv4 address is 32 bits, and three ranges are reserved for private networks: &lt;code&gt;10.0.0.0/8&lt;/code&gt;, &lt;code&gt;172.16.0.0/12&lt;/code&gt;, and &lt;code&gt;192.168.0.0/16&lt;/code&gt;. &lt;strong&gt;CIDR&lt;/strong&gt; notation encodes the split between network and host bits: &lt;code&gt;/24&lt;/code&gt; means the first 24 bits name the network, leaving 8 bits for hosts — 256 addresses, of which about 254 are usable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NAT&lt;/strong&gt; is why your whole household shares one public IP. The router maps many private addresses to a single public one, distinguishing flows by port number. It is also why inbound connections to a machine behind NAT are hard: nobody outside can address it directly, which is the root problem that port forwarding and tunneling solutions exist to solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go deeper
&lt;/h2&gt;

&lt;p&gt;Three threads I want to pull on next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TLS in detail&lt;/strong&gt; — the hybrid-encryption handshake, best studied side by side with SSH's version of the same idea. I walked through the SSH stages in &lt;a href="https://xinyangwuethz.github.io/notes/what-happens-when-you-ssh/" rel="noopener noreferrer"&gt;a separate note&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP/1.1 vs 2 vs 3&lt;/strong&gt; — head-of-line blocking, multiplexing, and why HTTP/3 abandons TCP for QUIC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load balancing and CDNs&lt;/strong&gt; — where this map meets systems design.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. But having the map means that when something breaks — a timeout, a refused connection, a stale DNS answer — you know which layer to interrogate first.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>tcp</category>
      <category>http</category>
      <category>dns</category>
    </item>
    <item>
      <title>RAG Beyond the Demo: Pipeline, Citations, Evaluation, and When Not to Bother</title>
      <dc:creator>Xinyang Wu</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:38:17 +0000</pubDate>
      <link>https://dev.to/xinyangwuethz/rag-beyond-the-demo-pipeline-citations-evaluation-and-when-not-to-bother-3hoc</link>
      <guid>https://dev.to/xinyangwuethz/rag-beyond-the-demo-pipeline-citations-evaluation-and-when-not-to-bother-3hoc</guid>
      <description>&lt;h2&gt;
  
  
  What RAG buys you
&lt;/h2&gt;

&lt;p&gt;Retrieval-augmented generation has a one-sentence core: retrieve relevant documents first, put them in the prompt, and let the model answer from them. That single move addresses three structural weaknesses of an LLM on its own. &lt;strong&gt;Hallucination&lt;/strong&gt; gets reduced, because the model is conditioned on real documents you supplied — reduced, not eliminated, since nothing forces the decoder to stay inside the retrieved context, which is why faithfulness evaluation shows up later in this post. &lt;strong&gt;Stale or private knowledge&lt;/strong&gt; stops requiring retraining, because updating the system means swapping documents, not weights. And &lt;strong&gt;context cost&lt;/strong&gt; stays under control, because you send the few relevant chunks instead of the whole corpus on every request.&lt;/p&gt;

&lt;p&gt;The interesting decision is when &lt;em&gt;not&lt;/em&gt; to use it. I think of it as a triangle. If the corpus is small enough to fit in the context window, long context is the degenerate case of RAG — just send everything. If the corpus is large, or you need cost control, or you need citations, retrieval earns its place. And if what you actually want to change is the model's capability or style rather than its knowledge, neither helps: that is fine-tuning territory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two pipelines, not one
&lt;/h2&gt;

&lt;p&gt;Every RAG diagram I find useful separates two paths that run on different schedules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;offline: load -&amp;gt; chunk -&amp;gt; embed -&amp;gt; index
online:  query -&amp;gt; embed -&amp;gt; retrieve top-k -&amp;gt; (rerank) -&amp;gt; assemble context -&amp;gt; generate -&amp;gt; cite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The offline path runs when documents change. The online path runs per request. Keeping them mentally separate matters because they fail differently: the offline path fails silently (bad chunking quietly poisons every future answer), while the online path fails loudly and per-query. Evaluation, covered below, attaches to each path separately too.&lt;/p&gt;

&lt;p&gt;Chunking itself is a trade-off with no free setting. Chunks around 400 tokens with 10–20% overlap are a sane default; the overlap exists so a sentence split across a boundary still survives intact in one chunk. Larger chunks carry more context but retrieve more coarsely; smaller chunks retrieve precisely but strand facts without their surroundings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embeddings without the magic
&lt;/h2&gt;

&lt;p&gt;An embedding model maps text to a &lt;strong&gt;dense vector&lt;/strong&gt; — a few hundred to a couple thousand dimensions — such that semantically similar texts land near each other, measured by cosine similarity or dot product. Whether the vectors come from a hosted API or a local sentence-transformers model is completely transparent to the rest of the pipeline, which is a genuinely nice property: you can start local and swap later.&lt;/p&gt;

&lt;p&gt;A question worth answering precisely: why does this need a Transformer at all — would a simple MLP do? No, for two reasons. First, an MLP wants fixed-dimension input, and text is a variable-length token sequence; you need an encoder plus pooling just to produce something fixed-size. Second, and more fundamentally, the semantics live in &lt;strong&gt;large-scale contrastive pretraining&lt;/strong&gt;, not in the layer shapes. The baseline spectrum makes this visible: BM25 sees only surface word overlap, averaged word vectors lose word order and context, and contrastively trained bi-encoders are what finally make "vector near" mean "meaning near".&lt;/p&gt;

&lt;p&gt;One linear-algebra fact does most of the work in a minimal implementation: if you &lt;strong&gt;L2-normalize&lt;/strong&gt; the vectors, the dot product &lt;em&gt;is&lt;/em&gt; cosine similarity, so retrieval collapses to a single matrix-vector product. The whole retriever fits in a few lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;overlap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;overlap&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-small-en-v1.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;DOCS&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# (N, 384)
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;                    &lt;span class="c1"&gt;# dot product == cosine, thanks to normalization
&lt;/span&gt;    &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;)[::&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you switch to a hosted embeddings API, only the encode calls change — but normalize the returned vectors yourself (APIs do not always guarantee it), and never mix models between indexing and querying: changing the embedding model means rebuilding the index.&lt;/p&gt;

&lt;h2&gt;
  
  
  When brute force is the right call
&lt;/h2&gt;

&lt;p&gt;Exact nearest-neighbor search is O(N) per query, which sounds like a problem until you put numbers on N. Approximate indexes — &lt;strong&gt;HNSW&lt;/strong&gt; graphs, &lt;strong&gt;IVF&lt;/strong&gt; clustering — exist to trade a little recall for orders-of-magnitude faster queries at millions of vectors. At tens of pages of documentation, N is a few hundred chunks, and the brute-force matrix product above runs in microseconds.&lt;/p&gt;

&lt;p&gt;Reaching for a vector database at that scale adds operational surface and an approximation error for zero benefit. Declining to deploy one is not a shortcut; it is a sizing decision, and being able to say precisely when HNSW starts paying for itself is the part worth knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two-stage retrieval and hybrid search
&lt;/h2&gt;

&lt;p&gt;When retrieval quality does become the bottleneck, two upgrades come before anything exotic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two-stage retrieval&lt;/strong&gt; exploits an asymmetry: bi-encoders are fast but coarse, cross-encoders are accurate but slow. So recall broadly with the bi-encoder — say top-50 — then let a &lt;strong&gt;cross-encoder reranker&lt;/strong&gt; rescore just those candidates down to a top-5. You pay the expensive model only on a short list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hybrid search&lt;/strong&gt; covers the failure mode dense vectors are worst at: exact jargon, abbreviations, and identifiers that the embedding model never learned to place well. Run BM25 alongside the dense retriever and fuse the two rankings with &lt;strong&gt;reciprocal rank fusion&lt;/strong&gt;. A first version legitimately skips both upgrades; the point is knowing which symptom each one treats.&lt;/p&gt;

&lt;h2&gt;
  
  
  Citations: soft vs hard
&lt;/h2&gt;

&lt;p&gt;Here is the observation that reframed citations for me: the query never connects to sources — &lt;em&gt;the retrieved chunks do&lt;/em&gt;, and that connection is already fixed inside &lt;code&gt;retrieve()&lt;/code&gt; before the model generates a single token.&lt;/p&gt;

&lt;p&gt;That gives you two mechanisms. The &lt;strong&gt;soft&lt;/strong&gt; one embeds source markers in the context and prompts the model to repeat them inline. It gives sentence-level granularity, but the model can mis-attribute, skip citations, or invent sources that were never retrieved. The &lt;strong&gt;hard&lt;/strong&gt; one skips the model entirely: the application already knows which chunks it passed in, so it returns them as structured data alongside the generated text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The structured list is deterministic — those chunks are, with certainty, what the answer was conditioned on — but coarse: it says which documents were used, not which sentence supports which claim. Production systems combine both: hard sources as the authoritative record, soft inline markers for granularity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluate the two halves separately
&lt;/h2&gt;

&lt;p&gt;A RAG system breaks in two independent places, so evaluating it end-to-end mostly tells you &lt;em&gt;that&lt;/em&gt; it is wrong, not &lt;em&gt;where&lt;/em&gt;. Split the evaluation at the retrieval boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval evaluation&lt;/strong&gt; is deterministic and should come first. Build a golden set of queries mapped to their relevant chunks, then measure &lt;strong&gt;recall@k&lt;/strong&gt; (of the relevant chunks, how many made the top-k — the metric that matters most, because what is never retrieved can never be cited), plus MRR for ranking quality and precision@k for noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generation evaluation&lt;/strong&gt; is fuzzier and usually uses an LLM as judge, scoring &lt;strong&gt;faithfulness&lt;/strong&gt; (is every claim in the answer supported by the context?) and &lt;strong&gt;answer relevance&lt;/strong&gt; — the RAGAS-style criteria. Judges carry known biases toward position, verbosity, and their own outputs, so use a strong judge model and calibrate with human spot-checks rather than trusting scores blindly.&lt;/p&gt;

&lt;p&gt;Golden sets come from three places: hand labeling (best way to start), synthetic generation (ask a model to write the question a given chunk answers), and mining real queries from logs once you have traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt caching changes the calculus
&lt;/h2&gt;

&lt;p&gt;Prompt caching is the mechanism that quietly moves the RAG-versus-long-context break-even. Under causal attention, a token's representation depends only on what precedes it, so providers can cache the KV state of a request's prefix and skip recomputing it when the next request starts with the &lt;em&gt;byte-identical&lt;/em&gt; prefix.&lt;/p&gt;

&lt;p&gt;The iron rule follows directly: change one early byte and everything after it is invalidated. Requests render in a fixed order — tools, then system, then messages — so the static corpus belongs as early as possible and the volatile user query at the very end. The classic silent cache-killers are timestamps, request IDs, unsorted JSON serialization, and per-user values injected into the system prompt; the symptom is a cached-token counter in the usage stats that stays at zero forever.&lt;/p&gt;

&lt;p&gt;Providers differ in the details — some cache automatically past a minimum prefix length, others want explicit cache breakpoints — but cached reads generally cost on the order of a tenth of normal input tokens. That changes the economics: for a mid-sized corpus, you can park the whole thing in a cached prefix and pay full price only for each query. The break-even point where RAG beats long context moves noticeably further out than the un-cached math suggests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Why does L2-normalizing embeddings let a plain dot product stand in for cosine similarity, and what silently goes wrong if you skip the normalization?&lt;/li&gt;
&lt;li&gt;Your system returns a fluent, confidently wrong answer. Which half of the pipeline do you check first, and with which metric?&lt;/li&gt;
&lt;li&gt;What can prompt-inline citations do that deterministic retrieval-based sources cannot — and what is the reliability cost?&lt;/li&gt;
&lt;li&gt;A teammate adds a timestamp to the system prompt of a long-context setup that relies on prompt caching. What happens to cost, and how would you detect it from usage stats?&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>embeddings</category>
      <category>evaluation</category>
    </item>
    <item>
      <title>Refactoring OpenTelemetry Metrics: from God Class to Per-Subsystem Modules</title>
      <dc:creator>Xinyang Wu</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:37:41 +0000</pubDate>
      <link>https://dev.to/xinyangwuethz/refactoring-opentelemetry-metrics-from-god-class-to-per-subsystem-modules-180e</link>
      <guid>https://dev.to/xinyangwuethz/refactoring-opentelemetry-metrics-from-god-class-to-per-subsystem-modules-180e</guid>
      <description>&lt;h2&gt;
  
  
  The constraint that shapes everything
&lt;/h2&gt;

&lt;p&gt;A refactor that recently landed in a codebase I work in replaced our metrics god class with per-subsystem modules. The design was a colleague's work, not mine. But studying the change, and migrating my own instruments over to the new pattern, taught me more about OpenTelemetry's design constraints than any tutorial had.&lt;/p&gt;

&lt;p&gt;The constraint that shapes everything is easy to state: an OTel &lt;strong&gt;instrument&lt;/strong&gt; — a counter, a histogram, an observable gauge — can be created once per process and never unregistered. Register the same instrument twice and, depending on the SDK, you get warnings or subtly duplicated data. So every metrics architecture in an OTel codebase is really an answer to one question: &lt;strong&gt;where does the process-level singleton live?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The old answer in our codebase was one global &lt;code&gt;Metrics&lt;/code&gt; object, built at startup and threaded through the application by dependency injection. That does solve the singleton problem. It also creates a monster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The anti-pattern: one god class of mixins
&lt;/h2&gt;

&lt;p&gt;Because every subsystem needed its instruments on that one global object, the object accreted everything. The composition mechanism was multiple inheritance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GeneralMetricsMixin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;      &lt;span class="c1"&gt;# each defines instrument fields + an _init_*() method
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AcquisitionMetricsMixin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CameraMetricsMixin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Metrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GeneralMetricsMixin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AcquisitionMetricsMixin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;CameraMetricsMixin&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__post_init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_init_general_metrics&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_init_acquisition_metrics&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Beyond the usual god-object complaints — the class kept growing, and metric definitions lived far from the code that recorded them — mixins add a sharper failure mode. Mixins compose through Python's method resolution order, and &lt;strong&gt;same-named attributes silently override each other&lt;/strong&gt;. If two mixins both define &lt;code&gt;_duration_metric&lt;/code&gt;, the MRO keeps one and the other is shadowed without any error. A metric can simply stop being recorded, and nothing tells you.&lt;/p&gt;

&lt;p&gt;The old code carried the fingerprint of this hazard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;measurement_stopped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;GeneralMetricsMixin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;measurement_stopped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# explicit class to dodge a name collision
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When plain &lt;code&gt;self.measurement_stopped()&lt;/code&gt; is unsafe and methods must be called through an explicit base class, the design is already fighting you. The general lesson is the classic one: &lt;strong&gt;composition over inheritance&lt;/strong&gt;. Independent small classes cannot shadow each other's fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  The replacement pattern
&lt;/h2&gt;

&lt;p&gt;The refactor deleted the god class and gave each subsystem its own module with a uniform shape: a scope constant, a frozen dataclass holding the expensive OTel handles, a memoized factory, and a cheap wrapper class you can construct per request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dc&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;functools&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;

&lt;span class="n"&gt;_SCOPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.api.prediction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nd"&gt;@dc.dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_Instruments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;               &lt;span class="c1"&gt;# holds only the OTel handles
&lt;/span&gt;    &lt;span class="n"&gt;failure_counter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Counter&lt;/span&gt;
    &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Histogram&lt;/span&gt;

&lt;span class="nd"&gt;@functools.cache&lt;/span&gt;                  &lt;span class="c1"&gt;# memoization as the process-level singleton
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_get_instruments&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_Instruments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;meter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_meter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_SCOPE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_Instruments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;failure_counter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.failures&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_histogram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.duration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PredictionMetrics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="c1"&gt;# cheap; construct one per request
&lt;/span&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_instruments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_get_instruments&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_instruments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failure_counter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="nf"&gt;_settings_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_settings&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cause&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@functools.cache&lt;/code&gt; is doing the singleton's job: the factory body runs once per process, which is exactly the guarantee the instruments need, with no global object and no injection boilerplate.&lt;/p&gt;

&lt;p&gt;There is a nice separation hiding in this shape. &lt;code&gt;_Instruments&lt;/code&gt; is the expensive shared state — created once, immutable, identical for every caller. &lt;code&gt;PredictionMetrics&lt;/code&gt; is the cheap per-request context — it binds the current settings in its constructor so call sites don't have to pass them into every method. That split of heavyweight shared state from lightweight context is the &lt;strong&gt;flyweight&lt;/strong&gt; idea, and it shows up everywhere: HTTP clients, database pools, loaded model weights.&lt;/p&gt;

&lt;p&gt;Just as important: each subsystem's metric definitions now live next to the code that records them. Changing one feature touches one module, not a central registry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metric names are a public API
&lt;/h2&gt;

&lt;p&gt;The most instructive detail of the refactor is what did &lt;em&gt;not&lt;/em&gt; change: the emitted metric names. The internals were rewritten, but &lt;code&gt;myapp.api.prediction.failures&lt;/code&gt; kept flowing under exactly that name.&lt;/p&gt;

&lt;p&gt;Dashboards, monitors, and saved queries reference metric names as plain strings in another system. No compiler sees them, no test fails when they break. That makes a metric name a &lt;strong&gt;contract&lt;/strong&gt;: internals can change freely, but renaming a metric is a breaking change for everyone downstream. When our scope was eventually renamed, the same change also updated the monitoring docs and the affected dashboards — a rename is a migration, not an edit.&lt;/p&gt;

&lt;p&gt;The same discipline applies to database column names, REST paths, and event schemas: anything other systems address by name is a public API, whatever your codebase thinks of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cardinality is the bill
&lt;/h2&gt;

&lt;p&gt;OTel calls them &lt;strong&gt;attributes&lt;/strong&gt;, some backends say tags, Prometheus says labels — same thing: key-value pairs on each data point, so you can later filter and group by them. The backend stores &lt;strong&gt;one time series per unique combination of metric name and tag values&lt;/strong&gt;, and that product is what you pay for.&lt;/p&gt;

&lt;p&gt;Our failure counter carries tags like &lt;code&gt;model_variant&lt;/code&gt; (5 values), &lt;code&gt;aggregated&lt;/code&gt; (2), &lt;code&gt;remove_outliers&lt;/code&gt; (2), &lt;code&gt;normalization&lt;/code&gt; (3), and &lt;code&gt;cause&lt;/code&gt; (4):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5 × 2 × 2 × 3 × 4 = 240 time series   # fine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now add one tag holding a raw floating-point input, with effectively unlimited distinct values. Every distinct float multiplies those 240 series, and you get millions of series, a painful invoice, and slow queries — a classic &lt;strong&gt;cardinality explosion&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two defenses survived the refactor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1. Collapse unbounded values before tagging: strips float noise
#    so 2.0000001 becomes "2"; the parameter only takes a handful
#    of values in practice anyway.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_scale_tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scale_factor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;scale_factor&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second is a rule of thumb: &lt;strong&gt;log it, don't tag it&lt;/strong&gt;. Per-request detail — input names, exact intermediate values — goes into structured logs, which handle high cardinality natively and carry the trace id, so you can pivot from a metric spike to the traces and logs of the requests behind it. I wrote more about that pivot in &lt;a href="https://xinyangwuethz.github.io/notes/opentelemetry-first-principles/" rel="noopener noreferrer"&gt;OpenTelemetry from first principles&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Tags are for bounded dimensions: enums, booleans, small buckets. Everything else has a better home.&lt;/p&gt;

&lt;h2&gt;
  
  
  Push vs pull instruments
&lt;/h2&gt;

&lt;p&gt;OTel has two ways to feed a metric, and picking wrong makes code awkward.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Push (synchronous)&lt;/th&gt;
&lt;th&gt;Pull (observable)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;report when an event happens&lt;/td&gt;
&lt;td&gt;callback answers "what is it now?"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instruments&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Counter&lt;/code&gt;, &lt;code&gt;Histogram&lt;/code&gt; via &lt;code&gt;.add&lt;/code&gt; / &lt;code&gt;.record&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ObservableGauge&lt;/code&gt; with a callback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fits&lt;/td&gt;
&lt;td&gt;discrete events: failures, durations&lt;/td&gt;
&lt;td&gt;levels: temperature, queue depth&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Events push; levels get pulled. A failure is a moment in time, so you &lt;code&gt;.add(1)&lt;/code&gt; when it happens. A queue depth has no event — it just &lt;em&gt;is&lt;/em&gt; — so the SDK periodically invokes your callback at collection time.&lt;/p&gt;

&lt;p&gt;Sometimes business code wants a push-style API for something that is really a level. The codebase bridges that with a small helper: callers &lt;code&gt;set()&lt;/code&gt; the latest value whenever they like, and a registered callback hands the current snapshot to the SDK when collection runs. The concurrency detail is worth copying — hold the lock only to copy, never while yielding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ObservableMetric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Push-style set() bridged onto a pull-style observable gauge.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# copy under the lock
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                 &lt;span class="c1"&gt;# yield outside it
&lt;/span&gt;            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Observation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping the critical section to a dict copy means a slow exporter can never block the hot path that calls &lt;code&gt;set()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-test
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Why does OpenTelemetry force some kind of process-level singleton on you, and how does &lt;code&gt;functools.cache&lt;/code&gt; satisfy it?&lt;/li&gt;
&lt;li&gt;What is the silent failure mode of composing metrics via mixins, and what code smell is its fingerprint?&lt;/li&gt;
&lt;li&gt;A counter has five tags with 5, 2, 2, 3, and 4 possible values. How many time series is that — and what happens when someone adds a raw-float tag?&lt;/li&gt;
&lt;li&gt;You need to track failed requests and current queue depth. Which instrument type fits each, and why?&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>opentelemetry</category>
      <category>python</category>
      <category>refactoring</category>
      <category>observability</category>
    </item>
    <item>
      <title>OpenTelemetry from First Principles: Three Signals, One Identity</title>
      <dc:creator>Xinyang Wu</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:37:05 +0000</pubDate>
      <link>https://dev.to/xinyangwuethz/opentelemetry-from-first-principles-three-signals-one-identity-180g</link>
      <guid>https://dev.to/xinyangwuethz/opentelemetry-from-first-principles-three-signals-one-identity-180g</guid>
      <description>&lt;h2&gt;
  
  
  Why OpenTelemetry exists
&lt;/h2&gt;

&lt;p&gt;OpenTelemetry is a vendor-neutral telemetry standard: a unified &lt;strong&gt;API&lt;/strong&gt;, an &lt;strong&gt;SDK&lt;/strong&gt;, and a wire protocol (&lt;strong&gt;OTLP&lt;/strong&gt;). The point of the split is decoupling &lt;em&gt;how telemetry is produced&lt;/em&gt; from &lt;em&gt;which backend consumes it&lt;/em&gt;. Your business code writes against the OTel API; if you move from Datadog to Prometheus and Grafana, you change the exporter configuration at startup and touch zero lines of application logic.&lt;/p&gt;

&lt;p&gt;That is the headline benefit, but it undersells the design. The real advantage over running three separate tools — a metrics library, a tracing library, a logging pipeline — is that one SDK produces all three signals, and they share a &lt;strong&gt;Resource&lt;/strong&gt; (a common identity) and a &lt;strong&gt;context&lt;/strong&gt; (a common thread). Everything interesting about OTel follows from that sharing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three signals, three questions
&lt;/h2&gt;

&lt;p&gt;Each signal answers a different question, and the differences in shape are not accidental:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Character&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Metrics&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;What broke, and how badly?&lt;/td&gt;
&lt;td&gt;Pre-aggregated, cheap, low-cardinality, alertable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Traces&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Where in the request did it break?&lt;/td&gt;
&lt;td&gt;Causal chain plus timing, per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Logs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Why, in full detail?&lt;/td&gt;
&lt;td&gt;Arbitrary structure, high cardinality&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Metrics are the alarm: &lt;code&gt;myapp.api.prediction.failures&lt;/code&gt; climbing tells you &lt;em&gt;something&lt;/em&gt; is wrong. A trace decomposes one request into a tree of timed spans and tells you &lt;em&gt;which stage&lt;/em&gt; is slow or failing. Logs carry the arbitrary, high-cardinality detail — input names, intermediate values — that explains &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;There is a well-known critique, associated with Charity Majors, that the "three pillars" framing misses the point: having three data stores is not observability. Being able to slice by high-cardinality dimensions and pivot freely &lt;em&gt;between&lt;/em&gt; signals is. I find that critique exactly right, and it reframes the rest of this post: the mechanisms below are what turn three disconnected monitoring systems into one investigation workflow — metric spike, jump to a representative trace, jump to that request's logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The vocabulary that makes docs readable
&lt;/h2&gt;

&lt;p&gt;Five terms unlock most of the OTel documentation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resource&lt;/strong&gt; — &lt;em&gt;who&lt;/em&gt; is producing telemetry: &lt;code&gt;service.name&lt;/code&gt;, &lt;code&gt;service.version&lt;/code&gt;, &lt;code&gt;host.name&lt;/code&gt;. The shared identity of all three signals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope&lt;/strong&gt; (a Meter, Tracer, or Logger) — &lt;em&gt;which module&lt;/em&gt; is producing it: &lt;code&gt;metrics.get_meter("myapp.api.prediction")&lt;/code&gt; is a namespace, not a global.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument / Span&lt;/strong&gt; — the concrete gauge: a Counter or Histogram for metrics, a named unit of work with start and end times for traces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attributes&lt;/strong&gt; — key-value dimensions on a measurement or span. Same concept as Datadog tags and Prometheus labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OTLP&lt;/strong&gt; — the wire protocol from SDK to collector, typically gRPC on port &lt;code&gt;4317&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The API/SDK split
&lt;/h2&gt;

&lt;p&gt;The most consequential design decision in OTel is that the API and the SDK are separate packages with separate roles.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;API&lt;/strong&gt; is lightweight and is the only thing libraries depend on: &lt;code&gt;get_meter&lt;/code&gt;, &lt;code&gt;create_counter&lt;/code&gt;, &lt;code&gt;.add()&lt;/code&gt;, &lt;code&gt;start_as_current_span&lt;/code&gt;. If no SDK is installed, every one of these calls is a &lt;strong&gt;no-op&lt;/strong&gt; — no errors, near-zero overhead. That is why a third-party library can ship built-in instrumentation without forcing a backend, an exporter, or any cost on applications that do not care.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;SDK&lt;/strong&gt; is assembled exactly once, at application startup: tracer, meter, and logger providers, batch processors, exporters, sampling policy. The application — and only the application — decides whether telemetry is exported, where it goes, and how much is kept.&lt;/p&gt;

&lt;p&gt;In practice this looks like instrument definitions scattered across modules (API calls only) plus one &lt;code&gt;setup_telemetry(app)&lt;/code&gt; function in a &lt;code&gt;telemetry.py&lt;/code&gt; that wires up the three providers and points them at a collector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlation mechanism 1: shared Resource
&lt;/h2&gt;

&lt;p&gt;The coarsest correlation mechanism is also the easiest to overlook. Create one Resource and hang it on all three providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="n"&gt;SERVICE_NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;service_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SERVICE_VERSION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;service_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nc"&gt;TracerProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;MeterProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;LoggerProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;service.name&lt;/code&gt; is stamped on every metric point, every span, and every log record, and a single filter in the backend — &lt;code&gt;service:myapp&lt;/code&gt; — scopes all three signals to the same service at once. It sounds trivial. It stops being trivial the first time you try to correlate signals from a codebase where metrics and logs were configured by different people with different naming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlation mechanism 2: trace context
&lt;/h2&gt;

&lt;p&gt;Trace context is the spine of the whole system, and it operates at two levels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In-process&lt;/strong&gt;, the Python SDK keeps an &lt;em&gt;ambient&lt;/em&gt; current span in &lt;code&gt;contextvars&lt;/code&gt;, which survives &lt;code&gt;await&lt;/code&gt;. &lt;code&gt;tracer.start_as_current_span("prediction.combine")&lt;/code&gt; makes the new span current; any child span created inside — by you or by a library — attaches to it automatically, no parent-passing required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-process&lt;/strong&gt;, the W3C &lt;code&gt;traceparent&lt;/code&gt; header carries the context over HTTP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;traceparent: 00-&amp;lt;trace_id: 32 hex&amp;gt;-&amp;lt;parent_span_id: 16 hex&amp;gt;-&amp;lt;flags: 2 hex&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client instrumentation injects it on outbound requests; server instrumentation extracts it on inbound ones. One user request becomes one trace across services.&lt;/p&gt;

&lt;p&gt;Ambient context is also where I learned my favorite failure mode. The global &lt;code&gt;FastAPIInstrumentor().instrument()&lt;/code&gt; call works by monkey-patching &lt;code&gt;FastAPI.__init__&lt;/code&gt;, so it only affects app instances created &lt;em&gt;after&lt;/em&gt; it runs. In one codebase I work in, the apps were built at module import time, before telemetry setup — so they were silently uninstrumented. No error, no warning. No SERVER span meant no per-request root anchor, so manually created spans grafted themselves onto whatever stale ambient context was left on the event loop, and showed up inside unrelated traces from hours earlier. The fix was the instance-level &lt;code&gt;FastAPIInstrumentor.instrument_app(app)&lt;/code&gt; (order-independent), plus a regression test pinning the initialization contract.&lt;/p&gt;

&lt;p&gt;The lessons generalize: ambient context is &lt;strong&gt;implicit global state&lt;/strong&gt; — it never raises, it just quietly gives you wrong parentage. The server span is the per-request root anchor; without it, every manual span is adrift. And monkey-patch-style auto-instrumentation always has an initialization-order contract worth locking down with a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlation mechanism 3: logs that know their trace
&lt;/h2&gt;

&lt;p&gt;Connecting logs to traces requires no application changes at all. The OTLP &lt;code&gt;LoggingHandler&lt;/code&gt; reads the current span context at emit time and stamps &lt;code&gt;trace_id&lt;/code&gt; and &lt;code&gt;span_id&lt;/code&gt; onto every log record. Write structured logs — fields as attributes, not interpolated prose — and each line is automatically linked to the request that produced it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;LOGGER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prediction diagnostics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prediction.model_variant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prediction.input_names&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# high cardinality: logs, not metric tags
&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One backend click takes you from a trace to that request's logs, and back.&lt;/p&gt;

&lt;p&gt;A second war story lives here. After a dependency upgrade, every log line appeared &lt;em&gt;twice&lt;/em&gt; in the backend — one copy complete, one missing attributes. The cause: a newer version of the logging instrumentation library began auto-installing its own root logging handler, alongside the handler the codebase had added manually. Two OTLP handlers on the root logger, two exports per record. The fix was making the manual handler the &lt;em&gt;only&lt;/em&gt; export path and leaving a comment explaining why. The durable lesson: your telemetry export path must be unique and understood, and "duplicate log lines, one copy with fewer attributes" is the diagnostic fingerprint of a double handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlation mechanism 4: metrics that point at traces
&lt;/h2&gt;

&lt;p&gt;Two routes connect a metric spike back to concrete requests.&lt;/p&gt;

&lt;p&gt;The spec's route is &lt;strong&gt;exemplars&lt;/strong&gt;: by default, a measurement recorded inside a sampled span carries that span's &lt;code&gt;trace_id&lt;/code&gt; as an exemplar, so a histogram bucket can link directly to a representative trace.&lt;/p&gt;

&lt;p&gt;The pragmatic route, which I trust more day to day, is &lt;strong&gt;shared dimensions&lt;/strong&gt;: put the &lt;em&gt;same bounded attributes, with the same names and values&lt;/em&gt;, on both metric tags and span attributes — &lt;code&gt;prediction.model_variant&lt;/code&gt;, &lt;code&gt;prediction.aggregated&lt;/code&gt;, and so on. Then a spike on some metric slice becomes a trace search filtered by identical dimensions, which becomes one request's spans, which link to that request's logs. Spike to trace to log in two pivots, built entirely out of naming discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  One gotcha: temporality
&lt;/h2&gt;

&lt;p&gt;Metric &lt;strong&gt;temporality&lt;/strong&gt; is the classic OTel-to-backend integration trap. &lt;strong&gt;Delta&lt;/strong&gt; temporality reports only each export interval's increment; &lt;strong&gt;cumulative&lt;/strong&gt; reports the running total since process start. Counters and Histograms are usually best exported as delta — that is what lets a backend show per-interval minima and maxima — while an UpDownCounter measures a level, so cumulative matches its semantics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;temporality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AggregationTemporality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DELTA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Histogram&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AggregationTemporality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DELTA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;UpDownCounter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AggregationTemporality&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CUMULATIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap is that OTLP's default is cumulative while some backends prefer delta, and the mismatch produces charts that look plausible and are wrong. Configure it explicitly and write down why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-test
&lt;/h2&gt;

&lt;p&gt;Five questions worth being able to answer without looking up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What breaks if two services share a Resource but not trace context — what can you still correlate, and what can you not?&lt;/li&gt;
&lt;li&gt;Why can a third-party library safely call &lt;code&gt;create_counter&lt;/code&gt; even if the application never installs an SDK?&lt;/li&gt;
&lt;li&gt;A manually created span shows up inside a trace from hours ago. What is the likely mechanism, and what was missing?&lt;/li&gt;
&lt;li&gt;Every log line appears twice in your backend, one copy with fewer attributes. What is your first hypothesis?&lt;/li&gt;
&lt;li&gt;Why is delta temporality the usual choice for a Histogram but not for an UpDownCounter?&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>opentelemetry</category>
      <category>observability</category>
      <category>tracing</category>
      <category>python</category>
    </item>
    <item>
      <title>A Weekend of Kubernetes: FastAPI, Helm, and Breaking Things on Purpose</title>
      <dc:creator>Xinyang Wu</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:36:30 +0000</pubDate>
      <link>https://dev.to/xinyangwuethz/a-weekend-of-kubernetes-fastapi-helm-and-breaking-things-on-purpose-3nf8</link>
      <guid>https://dev.to/xinyangwuethz/a-weekend-of-kubernetes-fastapi-helm-and-breaking-things-on-purpose-3nf8</guid>
      <description>&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Reading about Pods and Services never made Kubernetes stick for me, so I gave it a weekend with a concrete goal: take two FastAPI services — an API service and an auth service — and get them running on a local &lt;strong&gt;kind&lt;/strong&gt; cluster behind a Helm chart shaped like something you would actually run in production.&lt;/p&gt;

&lt;p&gt;Two constraints kept the exercise honest. First, everything had to be &lt;strong&gt;additive&lt;/strong&gt;: no service code changes at all, just a Dockerfile, a kind config, and a chart. If the deployment layer needs the application to change, the deployment layer is wrong. Second, the chart had to go beyond a hello-world Deployment — config and secrets handled properly, liveness and readiness probes, autoscaling, and ingress with host-based routing, one hostname per service.&lt;/p&gt;

&lt;p&gt;The image is a slim multi-stage build that runs as a non-root user. The kind config maps host ports 80 and 443 into the node, so ingress traffic works from the host with plain &lt;code&gt;curl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cluster&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kind.x-k8s.io/v1alpha4&lt;/span&gt;
&lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;control-plane&lt;/span&gt;
    &lt;span class="na"&gt;extraPortMappings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
        &lt;span class="na"&gt;hostPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
        &lt;span class="na"&gt;hostPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cluster and namespace are both named &lt;code&gt;demo&lt;/code&gt;. A small Makefile wraps the lifecycle: &lt;code&gt;make up&lt;/code&gt; builds the image, creates the cluster, installs ingress-nginx, and runs &lt;code&gt;helm upgrade --install&lt;/code&gt;; &lt;code&gt;make down&lt;/code&gt; deletes everything. Being able to rebuild from zero in a few minutes mattered more than I expected — it is what made the breakage experiments later in this post cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chart anatomy
&lt;/h2&gt;

&lt;p&gt;The chart contains the standard cast: Deployment, Service, ConfigMap, Secret, Ingress, HorizontalPodAutoscaler, ServiceAccount, plus dev and prod values overlays and a &lt;code&gt;helm test&lt;/code&gt; pod that curls both services after every install.&lt;/p&gt;

&lt;p&gt;The part I like most: &lt;strong&gt;one image, two Deployments&lt;/strong&gt;, templated from a single &lt;code&gt;services:&lt;/code&gt; map in the values file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-m"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.api"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.demo.localhost&lt;/span&gt;
  &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-m"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.auth"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8001&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auth.demo.localhost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The templates &lt;code&gt;range&lt;/code&gt; over this map. Both services run the same image; the &lt;code&gt;command:&lt;/code&gt; field overrides the image's default &lt;code&gt;CMD&lt;/code&gt;, so one build artifact becomes two different processes on two different ports, each behind its own ingress hostname. Adding a third service is one new values entry and zero template edits — I verified this by adding a dummy &lt;code&gt;whoami&lt;/code&gt; service and watching a Deployment, a Service, and an ingress route appear for it without touching a single template. That is the test of a good chart: growth is data, not code.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a secret becomes config
&lt;/h2&gt;

&lt;p&gt;The single most clarifying exercise of the weekend was tracing one value — the auth signing key — from the values file all the way into the running process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;values.yaml  (secrets: section, overridable per environment)
      |  helm install renders the secret template
      v
Secret manifest    stringData: { app.toml: &amp;lt;the complete TOML&amp;gt; }
      |  applied to the cluster
      v
etcd               data: { app.toml: &amp;lt;base64&amp;gt; }    &amp;lt;- encoded, NOT encrypted
      |  kubelet starts the pod
      v
Pod                /config/app.toml  (tmpfs volume, never written to disk)
                   APP_CONFIG_FILE=/config/app.toml  (env var from the ConfigMap)
      |  application startup
      v
config = {**defaults, **toml.load(os.environ["APP_CONFIG_FILE"])}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that pipeline are worth pausing on. The first is that base64 is an encoding, not encryption — anyone with etcd access or broad RBAC can decode a Secret. A Kubernetes Secret is a distribution mechanism, not a vault. The second is the last line: the application merges the loaded TOML over its defaults with a &lt;strong&gt;shallow&lt;/strong&gt; dict merge. That one implementation detail dictates chart design. If the Secret carried only a partial overlay — say, just the section containing the signing key — the shallow merge would replace that entire section and silently drop its sibling defaults. So the whole config file lives in the Secret. Slightly inelegant, but correct, and the kind of constraint you only find by reading the app's config-loading code rather than the Helm docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small tricks that carry the chart
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Roll pods when config changes.&lt;/strong&gt; Kubernetes does not restart pods just because a Secret they mount was updated. The standard fix is a checksum annotation on the pod template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;checksum/config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;include&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(print&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$.Template.BasePath&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"/secret.yaml")&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sha256sum&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A config change produces a new hash, the pod template changes, and a rolling update happens for exactly the right reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let the HPA own the replica count.&lt;/strong&gt; If &lt;code&gt;replicas:&lt;/code&gt; is set on the Deployment while an HPA is active, every &lt;code&gt;helm upgrade&lt;/code&gt; fights the autoscaler and resets its scaling decision. The chart omits &lt;code&gt;replicas&lt;/code&gt; entirely whenever autoscaling is enabled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probe a free endpoint.&lt;/strong&gt; Liveness and readiness point at &lt;code&gt;/openapi.json&lt;/code&gt; — FastAPI serves it automatically, it needs no auth, and it touches no downstream dependency. It answers exactly one question, "is this process up and serving HTTP", which is the right question for a liveness probe to ask.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip the registry.&lt;/strong&gt; &lt;code&gt;kind load docker-image&lt;/code&gt; copies the locally built image straight into the cluster node, and &lt;code&gt;pullPolicy: IfNotPresent&lt;/code&gt; stops the kubelet from trying to pull it. Local iteration needs no registry at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diff before every upgrade.&lt;/strong&gt; The &lt;code&gt;helm-diff&lt;/code&gt; plugin renders what an upgrade &lt;em&gt;would&lt;/em&gt; change and shows it before anything is applied. Reading that diff is the habit that separates "it worked" from "I know what it did".&lt;/p&gt;

&lt;h2&gt;
  
  
  Breaking things on purpose
&lt;/h2&gt;

&lt;p&gt;The most valuable hours of the weekend were spent breaking a working deployment in controlled ways, because each breakage pairs a symptom with the one command that diagnoses it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong image tag.&lt;/strong&gt; Pods sit in &lt;code&gt;ImagePullBackOff&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; demo describe pod &amp;lt;pod&amp;gt;    &lt;span class="c"&gt;# Events show the pull error and the exact image name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Corrupt the TOML inside the Secret.&lt;/strong&gt; The container starts, Python throws during config load, the process exits, the kubelet restarts it: &lt;code&gt;CrashLoopBackOff&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; demo logs &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;--previous&lt;/span&gt;    &lt;span class="c"&gt;# the traceback from the crashed attempt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--previous&lt;/code&gt; flag matters — the current container may be mid-restart with an empty log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Selector that matches nothing.&lt;/strong&gt; Edit the Service's selector so it no longer matches the pod labels — or reinstall the chart with a mismatched pod-template label — and the Service silently loses its endpoints. Everything looks green — pods Running, Service present — yet the ingress returns 503.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; demo get endpoints    &lt;span class="c"&gt;# an empty ENDPOINTS column means selector mismatch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The obvious version of this experiment — relabeling a live pod — does not stay broken, and &lt;em&gt;why&lt;/em&gt; it heals is its own lesson. Each Deployment's ReplicaSet selects pods by the same per-service labels the Service uses, so the moment a pod's label changes, the ReplicaSet disowns it and spins up a replacement that matches again. Endpoints repopulate within seconds and the ingress recovers. That quarantine behavior is a classic debugging move in its own right: strip a label from a misbehaving pod and it drops out of the Service, replaced by a fresh pod, while the original stays alive for inspection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale the ingress controller to zero.&lt;/strong&gt; &lt;code&gt;curl&lt;/code&gt; gets connection refused, while &lt;code&gt;kubectl get ingress&lt;/code&gt; still shows a perfectly healthy-looking object. The lesson generalizes: Kubernetes objects describe &lt;em&gt;intent&lt;/em&gt;, not the current state of the data path. The Ingress resource is inert configuration; the controller pod does the actual work.&lt;/p&gt;

&lt;p&gt;The common thread: symptoms surface at different layers, and debugging speed is mostly about knowing which layer emits which symptom before you start typing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd add next
&lt;/h2&gt;

&lt;p&gt;The chart is deliberately local-only, and each gap points at a well-known production tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TLS&lt;/strong&gt; via cert-manager with a self-signed cluster issuer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External Secrets Operator&lt;/strong&gt;, so real secrets never live in a values file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt;: kube-prometheus-stack plus an OpenTelemetry Collector, and one Grafana dashboard for the two services — see &lt;a href="https://xinyangwuethz.github.io/notes/opentelemetry-first-principles/" rel="noopener noreferrer"&gt;OpenTelemetry from first principles&lt;/a&gt; for why I want metrics, traces, and logs wired through one pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI&lt;/strong&gt; that builds the image per commit and pushes the packaged chart to an OCI registry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NetworkPolicies&lt;/strong&gt; restricting which pods may talk to the auth service, and a PodDisruptionBudget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is roughly a weekend of its own, which feels like the right unit for this kind of learning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-test
&lt;/h2&gt;

&lt;p&gt;Five questions I now expect myself to answer cold. If any of them feels fuzzy to you, that section of the docs is worth a re-read.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Why is there a ReplicaSet between a Deployment and its Pods?&lt;/strong&gt; The Deployment owns the rollout strategy; each ReplicaSet just guarantees the replica count for one revision of the pod template.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Service has no endpoints — what three things do you check?&lt;/strong&gt; The selector matches the pod labels; the pods pass their readiness probes; the Service port maps to the container port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What triggers a rolling update?&lt;/strong&gt; Any change to the pod template — image tag, env vars, resources, or the config checksum annotation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The HPA shows &lt;code&gt;&amp;lt;unknown&amp;gt;&lt;/code&gt; for CPU — why?&lt;/strong&gt; Either metrics-server is missing (on kind it also needs &lt;code&gt;--kubelet-insecure-tls&lt;/code&gt;), or the container declares no CPU request to compute a percentage against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are Kubernetes Secrets encrypted?&lt;/strong&gt; No — base64-encoded in etcd. Real deployments layer on etcd encryption at rest, tight RBAC, and an external secret store.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>kubernetes</category>
      <category>helm</category>
      <category>docker</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>An Agent Is a Loop: a Working Mental Model for Agentic Systems</title>
      <dc:creator>Xinyang Wu</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:35:54 +0000</pubDate>
      <link>https://dev.to/xinyangwuethz/an-agent-is-a-loop-a-working-mental-model-for-agentic-systems-3ckl</link>
      <guid>https://dev.to/xinyangwuethz/an-agent-is-a-loop-a-working-mental-model-for-agentic-systems-3ckl</guid>
      <description>&lt;h2&gt;
  
  
  The one-sentence definition
&lt;/h2&gt;

&lt;p&gt;Strip away the vendor decks and an agent is exactly this: &lt;strong&gt;a language model placed inside a loop that can call tools, remember things, and hand control back to a human when it gets stuck.&lt;/strong&gt; Everything else — orchestration frameworks, memory stores, multi-agent topologies — is engineering around that loop.&lt;/p&gt;

&lt;p&gt;The loop has a name: &lt;strong&gt;ReAct&lt;/strong&gt; (Reasoning + Acting). Reason about the current state, take one action, observe the result, reason again, until the goal is met. In pseudocode it is embarrassingly small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;user_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TOOL_SCHEMAS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;                       &lt;span class="c1"&gt;# done: final answer
&lt;/span&gt;        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# keep the model's own turn
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# code runs the tool
&lt;/span&gt;            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tool_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every concept below is an answer to an engineering problem this loop creates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five layers
&lt;/h2&gt;

&lt;p&gt;When I sketch an agentic system, I draw five layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Governance&lt;/strong&gt; — how much autonomy the agent gets, and when it must escalate to a human.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The orchestration loop&lt;/strong&gt; — reason, act, observe, repeat until done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capabilities&lt;/strong&gt; — tool calling, retrieval and grounding, integration with real workflows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State&lt;/strong&gt; — short-term context plus long-term memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The factory&lt;/strong&gt; — how agents are created and configured in the first place.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The loop sits in the middle. Governance constrains it from above; capabilities and state feed it from below; the factory stamps out configured instances of it. The layers are worth keeping separate because they fail differently: a grounding bug produces confident nonsense, while a governance bug produces an agent that deletes something it should have asked about first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who owns the control flow
&lt;/h2&gt;

&lt;p&gt;The most consequential design decision is who decides the next step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model-driven&lt;/strong&gt; control flow lets the LLM pick the next tool call. It is flexible and handles situations you never anticipated — and it is also unpredictable, hard to reproduce, and capable of wandering off or looping forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deterministic workflows&lt;/strong&gt; hard-code the control flow and use the model only inside individual nodes. They are reproducible, testable, and cheap.&lt;/p&gt;

&lt;p&gt;Between the extremes sit a few named patterns: &lt;strong&gt;prompt chaining&lt;/strong&gt; (a fixed pipeline of LLM steps), &lt;strong&gt;routing&lt;/strong&gt; (classify the input first, then dispatch), &lt;strong&gt;orchestrator-worker&lt;/strong&gt; (one coordinator decomposes a task and farms pieces out), and &lt;strong&gt;evaluator-optimizer&lt;/strong&gt; (generate, critique, revise).&lt;/p&gt;

&lt;p&gt;The real question is never "should we build an agent?" It is &lt;strong&gt;which decisions go to the model and which stay in code&lt;/strong&gt;. Default to a workflow; hand a decision to the model only when the next step genuinely cannot be known in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool calling and the security boundary
&lt;/h2&gt;

&lt;p&gt;Tool calling is how the loop touches the world, and its mechanics matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The model receives a list of tools, each with a name, a description, and a JSON schema for its parameters.&lt;/li&gt;
&lt;li&gt;When the model wants a tool, it emits a &lt;strong&gt;structured call&lt;/strong&gt; — a name plus arguments. It does not execute anything.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;harness&lt;/strong&gt; — your program — validates and executes the call, then appends the result to the conversation.&lt;/li&gt;
&lt;li&gt;The model reads the result and continues.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is the security boundary. &lt;strong&gt;The model requests; code decides.&lt;/strong&gt; Every permission check, rate limit, and audit log lives in the harness, which is precisely why the model must never execute anything directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP&lt;/strong&gt; (Model Context Protocol) standardizes the plumbing — a common way to plug tools and data sources into any model, a USB port for tools instead of a custom connector per integration.&lt;/p&gt;

&lt;p&gt;Two failure modes recur. Vague tool descriptions produce erratic tool choice: the description is the interface, so write it like documentation. And too many tools degrade selection quality — past a couple dozen, retrieve over the tool catalog and present only the relevant few.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grounding
&lt;/h2&gt;

&lt;p&gt;A model's knowledge is frozen at training time, and it fills gaps confidently and wrongly. &lt;strong&gt;Grounding&lt;/strong&gt; means binding answers to verifiable sources, and the mainstream implementation is &lt;strong&gt;RAG&lt;/strong&gt;: embed the question, find the most similar chunks in a vector store, put them in the prompt, and answer from them.&lt;/p&gt;

&lt;p&gt;Each stage has its own failure point. Chunks too large drown the signal in noise; too small, they sever meaning. An embedding model mismatched to the domain retrieves plausible-looking irrelevance. Pure vector search misses exact keywords, which is why serious pipelines run &lt;strong&gt;hybrid search&lt;/strong&gt; (BM25 plus vectors) and add a &lt;strong&gt;reranker&lt;/strong&gt; to reorder candidates. Citations on the final answer keep it auditable.&lt;/p&gt;

&lt;p&gt;The slogan worth remembering: &lt;strong&gt;retrieval is the means; grounding is the goal.&lt;/strong&gt; I go deeper on the pipeline and its evaluation in &lt;a href="https://xinyangwuethz.github.io/notes/rag-beyond-the-demo/" rel="noopener noreferrer"&gt;RAG beyond the demo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  State
&lt;/h2&gt;

&lt;p&gt;An agent has two memories with very different physics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short-term memory is the context window&lt;/strong&gt; — finite and expensive. When it fills up, the options are: &lt;strong&gt;compaction&lt;/strong&gt; (summarize older turns), a &lt;strong&gt;sliding window&lt;/strong&gt; (drop the oldest), &lt;strong&gt;offloading&lt;/strong&gt; (write intermediate results to an external store), and &lt;strong&gt;retrieval&lt;/strong&gt; (pull back only the relevant fragments on demand).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-term memory is anything that survives the session&lt;/strong&gt;: plain files and structured records (deterministic, human-inspectable) or vector stores (fetched back by semantic similarity). Two more terms earn their keep: &lt;strong&gt;checkpointing&lt;/strong&gt;, so a crashed run restarts from the last saved state instead of from zero, and the &lt;strong&gt;episodic versus semantic&lt;/strong&gt; split — a log of what happened versus distilled knowledge extracted from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One agent or many
&lt;/h2&gt;

&lt;p&gt;Multi-agent architectures buy exactly three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Context isolation&lt;/strong&gt; — the big one. A sub-agent starts with a fresh context window, so the coordinator is not bloated by every detail of every subtask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallelism&lt;/strong&gt; — independent subtasks run concurrently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specialization&lt;/strong&gt; — each agent gets a narrow role prompt and a small tool set.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The costs are real: coordination overhead, errors that amplify as they propagate between agents, higher latency, multiplied token spend, and much harder debugging.&lt;/p&gt;

&lt;p&gt;Multi-agent systems are not smarter; they are better at isolating context and running in parallel. If a single agent with well-chosen tools can do the job, use the single agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Autonomy and escalation
&lt;/h2&gt;

&lt;p&gt;Autonomy is a spectrum, not a switch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Suggest-only&lt;/td&gt;
&lt;td&gt;The system proposes; a human performs every action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human-in-the-loop&lt;/td&gt;
&lt;td&gt;The agent proposes actions; each one needs approval first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human-on-the-loop&lt;/td&gt;
&lt;td&gt;The agent acts on its own; a human monitors and can stop it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full autonomy&lt;/td&gt;
&lt;td&gt;No human in the process&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My rule of thumb: &lt;strong&gt;how strictly an action is gated should scale with its irreversibility times its blast radius.&lt;/strong&gt; Reads and queries can run fully automatically. Writing a local file is fine to review after the fact. Sending email, moving money, deleting data, publishing anything outward — a human approves first, every time.&lt;/p&gt;

&lt;p&gt;Escalation is the safety valve that makes autonomy tolerable. The agent stops and hands control back when it is uncertain, blocked, missing permissions, or facing a high-risk irreversible action. Mechanisms include confidence thresholds, an explicit ask-a-human tool, and review queues; in multi-agent setups, a stuck worker escalates to its orchestrator. The framing I hold onto: &lt;strong&gt;escalation is not failure — it is the responsible default.&lt;/strong&gt; Better to ask one question too many than to act irreversibly while unsure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent factories
&lt;/h2&gt;

&lt;p&gt;Once one agent works, you want twenty, and the factory pattern applies directly. An agent is fully described by its &lt;strong&gt;configuration&lt;/strong&gt;: a role prompt, a tool set, a model choice, and a permission set. Define those as data — declaratively — and a factory spawns instances from templates on demand.&lt;/p&gt;

&lt;p&gt;The payoff is the same as everywhere the pattern shows up: consistency, versioned definitions, and cheap extension. Adding a new agent means adding configuration, not writing code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole model in one paragraph
&lt;/h2&gt;

&lt;p&gt;A factory turns declarative configuration into agents. Each agent runs a ReAct orchestration loop; inside the loop it acts through tool calls that the harness executes, learns what it never knew through retrieval, and remembers through short- and long-term state. Whether you run one loop or several depends on whether you need context isolation and parallelism. How much the loop may do without asking is set by its autonomy level, and when it exceeds that level — or simply gets stuck — an escalation path hands control back to a human. Workflow integration then wires the whole thing into real systems: triggers start it, hooks instrument it, and approval checkpoints keep the irreversible steps honest.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>agents</category>
      <category>architecture</category>
      <category>aiengineering</category>
    </item>
    <item>
      <title>Your SSH Key Isn’t Always the Problem: A Layer-by-Layer Debugging Guide</title>
      <dc:creator>Xinyang Wu</dc:creator>
      <pubDate>Sun, 19 Jul 2026 06:55:41 +0000</pubDate>
      <link>https://dev.to/xinyangwuethz/your-ssh-key-isnt-always-the-problem-a-layer-by-layer-debugging-guide-281p</link>
      <guid>https://dev.to/xinyangwuethz/your-ssh-key-isnt-always-the-problem-a-layer-by-layer-debugging-guide-281p</guid>
      <description>&lt;p&gt;I once ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh demo-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and immediately suspected my SSH key.&lt;/p&gt;

&lt;p&gt;The actual error was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssh: Could not resolve hostname demo-server: Name or service not known
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My key was innocent. SSH had not opened a TCP connection, exchanged a single protocol message, checked the server's identity, or attempted user authentication. It did not even know which IP address to contact.&lt;/p&gt;

&lt;p&gt;That failure taught me a more useful way to debug SSH: &lt;strong&gt;treat every error as a timestamp&lt;/strong&gt;. It tells you how far the connection progressed before it stopped.&lt;/p&gt;

&lt;p&gt;Instead of changing keys, restarting services, and deleting &lt;code&gt;known_hosts&lt;/code&gt; entries at random, ask one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which layer did I fail to reach?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here is the model I now use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The connection has seven checkpoints
&lt;/h2&gt;

&lt;p&gt;When you type &lt;code&gt;ssh demo-server&lt;/code&gt;, the useful mental model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;configuration
    → name resolution
    → TCP connection
    → SSH transport and key exchange
    → server verification
    → user authentication
    → channel creation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each stage depends on the previous one. A failure in name resolution cannot be fixed by rotating an authentication key, because the authentication stage never happened.&lt;/p&gt;

&lt;p&gt;These are diagnostic checkpoints, not seven separate wire-protocol layers. In the SSH protocol, server host-key authentication is part of transport key exchange; I separate verification here because it produces its own recognizable failures and fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 0: SSH resolves its configuration
&lt;/h2&gt;

&lt;p&gt;Before contacting the network, the client combines command-line options with SSH configuration, usually from &lt;code&gt;~/.ssh/config&lt;/code&gt; and the system configuration.&lt;/p&gt;

&lt;p&gt;Imagine this entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host lab-*
    User dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;ssh lab-gpu&lt;/code&gt; matches the pattern and sets the remote user to &lt;code&gt;dev&lt;/code&gt;. It does &lt;strong&gt;not&lt;/strong&gt; give &lt;code&gt;lab-gpu&lt;/code&gt; an IP address. Without a &lt;code&gt;HostName&lt;/code&gt;, the destination is still literally &lt;code&gt;lab-gpu&lt;/code&gt;, so the operating system must resolve that name later.&lt;/p&gt;

&lt;p&gt;A complete alias might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host demo-server
    HostName 192.0.2.10
    User dev
    Port 22
    IdentityFile ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fastest way to inspect what SSH actually derived is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-G&lt;/span&gt; demo-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a compact view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-G&lt;/span&gt; demo-server | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(hostname|user|port|identityfile) '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is safer than reasoning from one config block by eye. Multiple matching blocks may contribute settings, and for most options SSH uses the first value it obtains. “The most specific block wins” is therefore a misleading model.&lt;/p&gt;

&lt;p&gt;If the resolved hostname, user, port, or identity file is wrong, stay at this stage. The network is not your problem yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: The operating system resolves the hostname
&lt;/h2&gt;

&lt;p&gt;SSH next needs to turn the resolved hostname into an address. Depending on the machine, that may involve:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DNS&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/etc/hosts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;a VPN or overlay network's DNS&lt;/li&gt;
&lt;li&gt;other system name-service mechanisms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One subtle trap is confusing a deployment tool's inventory with the operating system's resolver. An inventory might contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="err"&gt;lab-gpu&lt;/span&gt; &lt;span class="py"&gt;ansible_host&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;192.0.2.10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ansible understands that mapping. OpenSSH does not automatically read Ansible's inventory. Unless &lt;code&gt;lab-gpu&lt;/code&gt; is also defined through SSH configuration or a system resolver, &lt;code&gt;ssh lab-gpu&lt;/code&gt; can still fail with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Could not resolve hostname
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That message is unusually precise: configuration completed, but the client could not obtain an address. No packet reached an SSH server.&lt;/p&gt;

&lt;p&gt;First use &lt;code&gt;ssh -G&lt;/code&gt; to find the effective &lt;code&gt;hostname&lt;/code&gt;; resolver tools do not read &lt;code&gt;~/.ssh/config&lt;/code&gt; aliases. If that effective value is a name such as &lt;code&gt;server.example.com&lt;/code&gt;, check the Linux system name-service path with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;getent hosts server.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On macOS, the equivalent system lookup can be inspected with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dscacheutil &lt;span class="nt"&gt;-q&lt;/span&gt; host &lt;span class="nt"&gt;-a&lt;/span&gt; name server.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To query DNS specifically rather than the full system resolver path, use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short server.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check whether the name only exists while a company VPN, mesh VPN, or private DNS service is active.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: The client opens a TCP connection
&lt;/h2&gt;

&lt;p&gt;Once the client has an address, it tries to connect to the configured port—normally port 22.&lt;/p&gt;

&lt;p&gt;At this point, similar-looking messages describe different conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Connection timed out&lt;/strong&gt;: packets may be dropped by a firewall, the route may be broken, the VPN may be missing, or the host may be unavailable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No route to host&lt;/strong&gt;: the local networking stack cannot find a usable path, or an intermediate device reports that the destination is unreachable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection refused&lt;/strong&gt;: the client received an active rejection, usually because the destination has nothing listening on that port. An intermediary can also reject it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A small TCP probe against the effective address and port from &lt;code&gt;ssh -G&lt;/code&gt; helps isolate this layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nc &lt;span class="nt"&gt;-vz&lt;/span&gt; 192.0.2.10 22
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does not prove that SSH authentication will work. It only answers a narrower and very useful question: can I establish direct TCP connectivity to this host and port? If SSH uses &lt;code&gt;ProxyJump&lt;/code&gt; or &lt;code&gt;ProxyCommand&lt;/code&gt;, a probe from your machine does not test the same path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: SSH negotiates a secure transport
&lt;/h2&gt;

&lt;p&gt;After TCP succeeds, the client and server exchange SSH protocol versions and negotiate algorithms. They perform a key exchange to derive fresh session keys, and the server proves control of its host key by signing data from the exchange.&lt;/p&gt;

&lt;p&gt;A successful exchange creates the encrypted, integrity-protected transport used by the rest of the session. Modern cipher suites often provide authenticated encryption directly, so it is better to think in terms of confidentiality and integrity than to assume every connection uses a separate MAC algorithm.&lt;/p&gt;

&lt;p&gt;At the wire-protocol level, the host-key proof belongs to this exchange. The next checkpoint is the client's decision about whether to trust that key.&lt;/p&gt;

&lt;p&gt;Failures here look different from network failures. Examples include protocol banners that never arrive, an early connection reset, or messages such as “no matching key exchange method found.” These often point to incompatible algorithm policies, a non-SSH service on the port, an intermediary, or a server-side SSH problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: The client verifies the server
&lt;/h2&gt;

&lt;p&gt;Encryption is not enough if you encrypt a session to the wrong machine. The client therefore checks the server's host key against &lt;code&gt;~/.ssh/known_hosts&lt;/code&gt; or another configured host-key database.&lt;/p&gt;

&lt;p&gt;On a first connection, many setups use trust on first use: you verify and accept the fingerprint, and the client remembers it. On later connections, the key should match.&lt;/p&gt;

&lt;p&gt;If you see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REMOTE HOST IDENTIFICATION HAS CHANGED!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the connection has already passed name resolution, TCP, and enough of the SSH handshake to receive a host key. The warning could mean a legitimate rebuild or address reassignment—but it could also indicate misrouting or an active attack.&lt;/p&gt;

&lt;p&gt;Do not make “delete the &lt;code&gt;known_hosts&lt;/code&gt; entry” your automatic response. First verify the new fingerprint through a trusted, independent channel. Remove or replace the old entry only after you understand why it changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 5: The server authenticates the user
&lt;/h2&gt;

&lt;p&gt;Only now does your personal SSH key enter the story.&lt;/p&gt;

&lt;p&gt;With public-key authentication:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the private key remains on the client;&lt;/li&gt;
&lt;li&gt;the corresponding public key is normally listed in the remote account's &lt;code&gt;authorized_keys&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the client signs session-bound authentication data to prove that it controls the private key;&lt;/li&gt;
&lt;li&gt;the server verifies that signature with the public key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The private key is not uploaded to the server.&lt;/p&gt;

&lt;p&gt;Two similarly named files have completely different jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Usually lives on&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;known_hosts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;client&lt;/td&gt;
&lt;td&gt;Verifies the server's identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;authorized_keys&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;server&lt;/td&gt;
&lt;td&gt;Lists public keys allowed to authenticate as a user&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This distinction explains why the following error is actually evidence of partial success:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Permission denied (publickey,password)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client resolved a destination, connected over TCP, negotiated SSH, and accepted the server identity. It then failed to authenticate the requested user.&lt;/p&gt;

&lt;p&gt;Now—and only now—it makes sense to inspect the resolved username, offered identities, file permissions, account policy, SSH agent, and the remote &lt;code&gt;authorized_keys&lt;/code&gt; entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 6: SSH opens a channel
&lt;/h2&gt;

&lt;p&gt;A successful authentication does not necessarily mean you will receive an interactive shell. SSH can open different channels for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an interactive shell&lt;/li&gt;
&lt;li&gt;a single remote command&lt;/li&gt;
&lt;li&gt;SFTP&lt;/li&gt;
&lt;li&gt;local, remote, or dynamic port forwarding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;ExitOnForwardFailure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-L&lt;/span&gt; 127.0.0.1:8080:127.0.0.1:3000 demo-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;asks the client to listen only on the local loopback interface at port &lt;code&gt;8080&lt;/code&gt;, without running a remote command. A connection to that port travels through the encrypted SSH session, then the remote SSH server connects to &lt;code&gt;127.0.0.1:3000&lt;/code&gt; from its own network context. &lt;code&gt;ExitOnForwardFailure&lt;/code&gt; catches failure to establish the requested listener, although it cannot guarantee that the target service will accept a later forwarded connection.&lt;/p&gt;

&lt;p&gt;If authentication succeeds but forwarding reports “administratively prohibited” or “open failed,” the problem is at the channel or destination-policy layer—not with DNS or your key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the error as a stage marker
&lt;/h2&gt;

&lt;p&gt;Here is the compact version I wish I had earlier:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error or symptom&lt;/th&gt;
&lt;th&gt;Last relevant stage&lt;/th&gt;
&lt;th&gt;Check next&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Could not resolve hostname&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;name resolution&lt;/td&gt;
&lt;td&gt;effective &lt;code&gt;HostName&lt;/code&gt;, DNS, hosts file, VPN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;No route to host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;network path&lt;/td&gt;
&lt;td&gt;routes, VPN, interface, gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Connection timed out&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;TCP path&lt;/td&gt;
&lt;td&gt;reachability, firewall, correct address and port&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Connection refused&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;TCP destination&lt;/td&gt;
&lt;td&gt;SSH service and listening port&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no matching key-exchange algorithm&lt;/td&gt;
&lt;td&gt;SSH transport&lt;/td&gt;
&lt;td&gt;client/server algorithm policies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;host identification changed&lt;/td&gt;
&lt;td&gt;server verification&lt;/td&gt;
&lt;td&gt;independently verify the new fingerprint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Permission denied (publickey)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;user authentication&lt;/td&gt;
&lt;td&gt;resolved user, offered key, &lt;code&gt;authorized_keys&lt;/code&gt;, account policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;forwarding is prohibited or open fails&lt;/td&gt;
&lt;td&gt;channel creation&lt;/td&gt;
&lt;td&gt;SSH server policy and target reachability&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The table is not a replacement for logs, but it prevents category errors. It tells you where to begin.&lt;/p&gt;

&lt;h2&gt;
  
  
  My three-step first response
&lt;/h2&gt;

&lt;p&gt;When an SSH connection fails, I now start with these steps.&lt;/p&gt;

&lt;p&gt;First, inspect the effective configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-G&lt;/span&gt; demo-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, on a Unix-like server, get a stage-by-stage trace without password prompts and make a successful probe exit immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-vvv&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;BatchMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;ConnectTimeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5 demo-server &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verbose output usually makes the boundary visible: configuration applied, address resolved, connection attempted, host key checked, identities offered, or channel rejected.&lt;/p&gt;

&lt;p&gt;Be careful before sharing that output. Sanitize hostnames, usernames, addresses, paths, key fingerprints, and other infrastructure details.&lt;/p&gt;

&lt;p&gt;Third, test the suspected boundary independently. Resolve the effective hostname if resolution failed; probe the effective address and port if they exist; inspect offered keys only if authentication was actually reached. Remember that resolver and TCP tools do not apply your SSH alias or proxy configuration for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debug the earliest failure, not the loudest theory
&lt;/h2&gt;

&lt;p&gt;SSH combines configuration, naming, networking, cryptography, identity, and multiplexed channels behind one short command. That makes it feel mysterious—but it also makes its errors surprisingly informative.&lt;/p&gt;

&lt;p&gt;The next time &lt;code&gt;ssh&lt;/code&gt; fails, read the error as a timestamp, identify the earliest incomplete checkpoint, and test that boundary directly. A DNS problem does not need a new key. A refused TCP connection does not need a cleared &lt;code&gt;known_hosts&lt;/code&gt;. And an authentication error is proof that several lower layers already worked.&lt;/p&gt;

&lt;p&gt;That one mental model turns SSH debugging from guesswork into a sequence of small, testable questions.&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>networking</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>10 Cross-Topic Lessons from a 123/138 LeetCode Sprint</title>
      <dc:creator>Xinyang Wu</dc:creator>
      <pubDate>Sat, 06 Jun 2026 10:02:16 +0000</pubDate>
      <link>https://dev.to/xinyangwuethz/6-cross-topic-lessons-from-grinding-46-leetcode-problems-1bh2</link>
      <guid>https://dev.to/xinyangwuethz/6-cross-topic-lessons-from-grinding-46-leetcode-problems-1bh2</guid>
      <description>&lt;p&gt;I first published this retrospective 46 problems into a 12-week DSA sprint. My tracker is now at &lt;strong&gt;123 / 138 slots&lt;/strong&gt;: I have worked through Week 11 and am &lt;strong&gt;2 / 12 into Week 12&lt;/strong&gt;. The tracker deliberately repeats a few problems across topics, so 123 is a progress metric—not a claim of 123 unique accepted problems.&lt;/p&gt;

&lt;p&gt;The expanded tracker did not produce dozens of unrelated tricks. It made the same failures show up in new disguises. A bad state definition in dynamic programming felt a lot like an under-specified sliding window. Marking a BFS node too late felt like inserting into a hash map too early. A wrong heap invariant looked suspiciously like a wrong binary-search invariant.&lt;/p&gt;

&lt;p&gt;These are the ten lessons that survived that repetition. The bugs below are not hypothetical warnings. They are mistakes I wrote, plus the smallest counterexamples that finally made the mistake obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. If I cannot define the state in one sentence, I am not ready to update it
&lt;/h2&gt;

&lt;p&gt;My first House Robber state was internally inconsistent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The initialization says &lt;code&gt;dp[1]&lt;/code&gt; means “money from robbing house 1.” The recurrence needs it to mean “the best result using houses 0 through 1.” Those are different contracts.&lt;/p&gt;

&lt;p&gt;The counterexample is &lt;code&gt;[2, 1, 1, 2]&lt;/code&gt;. Initializing &lt;code&gt;dp[1] = 1&lt;/code&gt; permanently loses the valid choice of taking the first house. The recurrence returns 3 instead of 4. I added a separate &lt;code&gt;vmax&lt;/code&gt; to patch the output, but the wrong state had already contaminated later states. A downstream maximum could not repair an upstream definition.&lt;/p&gt;

&lt;p&gt;The correct base case has the same meaning as the recurrence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;prev2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;prev1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:]:&lt;/span&gt;
    &lt;span class="n"&gt;prev2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prev1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same lesson appeared outside DP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In Fruit Into Baskets, I stored only the kinds of fruit in a deque. That could not answer “when has this kind completely left the window?” The input &lt;code&gt;[1,1,1,2,3,3]&lt;/code&gt; exposed it: my window kept three kinds and reported 5 instead of 4. The state needed counts, not just names.&lt;/li&gt;
&lt;li&gt;In Maximum Product Subarray, keeping only the largest product ending here loses a negative value that may become the next maximum. For &lt;code&gt;[2,-5,-2,-4,3]&lt;/code&gt;, the state must retain both the current maximum and minimum to recover the answer 24.&lt;/li&gt;
&lt;li&gt;In Longest Palindromic Substring, one left endpoint per right endpoint is not enough. Palindromicity depends on both boundaries, so the state naturally becomes &lt;code&gt;dp[left][right]&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My current rule is simple: finish the sentence &lt;strong&gt;“this state contains exactly…”&lt;/strong&gt; before writing an update. If I need a patch variable later, I first suspect that sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The empty state is data, not a special case
&lt;/h2&gt;

&lt;p&gt;Prefix sums taught me to put &lt;code&gt;prefix[0] = 0&lt;/code&gt; in front. Later topics made the idea more general: the empty state should be represented by the identity that cooperates with the operation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Empty-state value&lt;/th&gt;
&lt;th&gt;Why it works&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sum&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;x + 0 = x&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;x * 1 = x&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimum&lt;/td&gt;
&lt;td&gt;&lt;code&gt;inf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a real candidate always beats it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-inf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a real candidate always beats it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Counting-DP empty choice&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;there is exactly one way to choose nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unreachable count&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no valid construction has reached this state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefix-frequency count&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{0: 1}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one empty prefix exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Earliest prefix index&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{0: -1}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the empty prefix ends before index 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One of my earliest versions used an inclusive prefix array, appended a zero at the &lt;em&gt;end&lt;/em&gt;, and relied on Python's negative indexing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# left == 0 reads prefix[-1]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It passed, but only because &lt;code&gt;prefix[-1]&lt;/code&gt; wrapped around to the hidden zero. The same code is an out-of-bounds bug in languages without negative indexing. The honest form is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Coin Change exposed the other half of the rule. I used &lt;code&gt;-1&lt;/code&gt; for “unreachable” while minimizing. Then unreachable states won every &lt;code&gt;min&lt;/code&gt;, and &lt;code&gt;-1 + 1&lt;/code&gt; created fake zero-cost solutions. With &lt;code&gt;coins = [2,3]&lt;/code&gt; and &lt;code&gt;amount = 4&lt;/code&gt;, that version returned 0 instead of 2.&lt;/p&gt;

&lt;p&gt;Internally, &lt;code&gt;inf&lt;/code&gt; is the right value because it composes safely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;dp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;coin&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;coins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;coin&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;coin&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The external API may require &lt;code&gt;-1&lt;/code&gt;. That does not mean &lt;code&gt;-1&lt;/code&gt; belongs inside the recurrence. I now separate the algorithm's internal algebra from the return-value protocol, and translate only at the exit.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Update order is part of the algorithm
&lt;/h2&gt;

&lt;p&gt;Two adjacent lines are not interchangeable just because both eventually run.&lt;/p&gt;

&lt;p&gt;In prefix-sum counting, the map is a record of the past. I must query it before the current prefix joins that past:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
&lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# query history
&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;# then become history
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I insert first, the current prefix can match itself. For Subarray Sum Equals K with &lt;code&gt;nums = [1,-1]&lt;/code&gt; and &lt;code&gt;k = 0&lt;/code&gt;, the correct answer is 1; inserting first counts two zero-length “subarrays” as well and returns 3.&lt;/p&gt;

&lt;p&gt;The same ordering constraint reappeared in three different tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BFS:&lt;/strong&gt; mark a node when it enters the queue, not when it leaves. Otherwise several parents can enqueue the same node before its first dequeue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Union-Find cycle detection:&lt;/strong&gt; ask whether &lt;code&gt;find(u) == find(v)&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; union. If I union first, the condition is true for every edge. On &lt;code&gt;[[1,2],[2,3],[3,1],[3,4]]&lt;/code&gt;, the redundant edge is &lt;code&gt;[3,1]&lt;/code&gt;, not the first or last edge by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compressed 0/1 knapsack:&lt;/strong&gt; iterate capacity backward. With one item &lt;code&gt;2&lt;/code&gt; and target &lt;code&gt;4&lt;/code&gt;, a forward scan sets &lt;code&gt;dp[2]&lt;/code&gt; and then reuses that freshly written value to set &lt;code&gt;dp[4]&lt;/code&gt;—using the same item twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are all temporal invariants. “Seen” means seen &lt;strong&gt;before now&lt;/strong&gt;. “Already connected” means connected &lt;strong&gt;before this edge&lt;/strong&gt;. “Previous row” means the state &lt;strong&gt;before this item&lt;/strong&gt;. Moving a line changes that meaning, even when the variables have the same names.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Every destructive move needs a proof
&lt;/h2&gt;

&lt;p&gt;Popping a stack entry, advancing a pointer, pruning a branch, and moving a binary-search boundary all destroy candidates. Before doing any of them, I now ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What fact proves this candidate can never be the answer?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My rotated-array minimum passed tests with two overlapping safety nets: an early “peek at the previous element” return, plus a final fallback. The loop itself used &lt;code&gt;right = mid - 1&lt;/code&gt; even when &lt;code&gt;mid&lt;/code&gt; could still be the minimum. Delete either safety net and the code quietly breaks.&lt;/p&gt;

&lt;p&gt;The clean invariant keeps &lt;code&gt;mid&lt;/code&gt; whenever it may still be the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;   &lt;span class="c1"&gt;# mid is provably not the minimum
&lt;/span&gt;    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;      &lt;span class="c1"&gt;# mid may be the minimum, so keep it
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Container With Most Water gave me the same lesson in greedy form. I tried moving both ends when neither immediate next move improved the current area. On &lt;code&gt;[5,1,100,50]&lt;/code&gt;, that jumps past the optimal pair &lt;code&gt;(100,50)&lt;/code&gt; with area 50.&lt;/p&gt;

&lt;p&gt;The valid greedy move is not “take the next thing that looks better.” It is “discard the shorter wall because every narrower container that keeps it is capped by the same short wall.” That is a domination proof.&lt;/p&gt;

&lt;p&gt;Longest Increasing Subsequence exposed the distinction between &lt;strong&gt;pop&lt;/strong&gt; and &lt;strong&gt;replace&lt;/strong&gt;. Treating its &lt;code&gt;tails&lt;/code&gt; array like a monotonic stack and popping larger endings destroys the historical fact that a subsequence of that length has existed. For &lt;code&gt;[2,3,1,4]&lt;/code&gt;, popping gives length 2 instead of 3. The correct operation replaces exactly one threshold—the first value greater than or equal to the new number—without erasing longer records.&lt;/p&gt;

&lt;p&gt;The syntax is tiny; the proof is the algorithm.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Constraints are algorithm instructions
&lt;/h2&gt;

&lt;p&gt;I used to read constraints after understanding the problem. Now I read them as a list of approaches the problem setter is trying to kill.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;“O(log n) required”&lt;/strong&gt; killed my “binary search, then expand left and right” solution for finding a target range. On &lt;code&gt;[8,8,8,8,8]&lt;/code&gt;, the expansion is linear. The answer needs two boundary searches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“The array may contain negatives”&lt;/strong&gt; kills a sum-based sliding window because the window sum is no longer monotone. Prefix sum plus a hash map survives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;“O(n) required”&lt;/strong&gt; in Longest Consecutive Sequence kills &lt;code&gt;sorted(set(nums))&lt;/code&gt;. Sorting gets the right result in &lt;code&gt;O(n log n)&lt;/code&gt; but misses the point; scanning only from values whose predecessor is absent gives linear total work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coordinates up to &lt;code&gt;10^9&lt;/code&gt; or &lt;code&gt;2^31 - 1&lt;/code&gt;&lt;/strong&gt; kill per-coordinate arrays and loops. I tried a difference array for Interval List Intersections and a coordinate-by-coordinate loop for Skyline. One wants impossible memory; the other can run for billions of empty positions. The right iteration domain is intervals or event points, not the numeric axis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This question has become part of my pre-code checklist:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which obvious solution is this constraint designed to exclude?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It catches complexity bugs before a correct-looking implementation makes them harder to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. When many searches share a destination, reverse the search
&lt;/h2&gt;

&lt;p&gt;Several graph problems became simple only after I stopped searching from every unknown point.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Surrounded Regions is awkward as “which regions are enclosed?” It becomes easy as “which &lt;code&gt;O&lt;/code&gt; cells can the boundary reach?” Mark those safe cells, then flip the rest.&lt;/li&gt;
&lt;li&gt;Pacific Atlantic Water Flow is expensive as “can this cell flow to each ocean?” Reverse the edges: start from both oceans and climb to cells of equal or greater height. The answer is the intersection of the two reachable sets.&lt;/li&gt;
&lt;li&gt;01 Matrix asks every cell for its nearest zero. Put &lt;strong&gt;all zeros&lt;/strong&gt; into one queue at distance 0 and expand once.&lt;/li&gt;
&lt;li&gt;Rotting Oranges asks how simultaneous infection spreads. Put &lt;strong&gt;all rotten oranges&lt;/strong&gt; into the initial queue; one BFS layer is one minute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shared template is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deque&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all_states_with_known_answer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;mark_all_as_seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;popleft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;nxt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;reverse_or_outward_neighbors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;nxt&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;nxt&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nxt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nxt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running one BFS per source repeats the same regions. A multi-source BFS is not merely an optimization; it models simultaneous expansion correctly.&lt;/p&gt;

&lt;p&gt;I now look for phrases such as “nearest source,” “eventually reaches a boundary,” or “all sources spread at once.” They often mean: &lt;strong&gt;start from the states whose answers are already known and propagate outward&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The lifetime of &lt;code&gt;visited&lt;/code&gt; depends on the question
&lt;/h2&gt;

&lt;p&gt;I once treated “mark visited” as a generic graph rule. Backtracking showed why that is incomplete.&lt;/p&gt;

&lt;p&gt;For flood fill, the question is about nodes: “which cells belong to this component?” Once a cell is processed, visiting it again has no value. The mark is permanent.&lt;/p&gt;

&lt;p&gt;For Word Search or permutations, the question is about paths: “which choices form this particular solution?” A cell or item may be used by a different sibling path. The mark must live only for the current recursive frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="nf"&gt;backtrack&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leaving out the restoration does not just leak state; it permanently blocks legal sibling branches and creates false negatives.&lt;/p&gt;

&lt;p&gt;I hit the companion bug in Subsets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# stores the same mutable list object
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After recursion unwound, every entry referred to the same now-empty list. The fix is a snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;[:])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This also explains why “generate everything, then deduplicate” is usually a smell. My first subset approach generated both &lt;code&gt;[1,2]&lt;/code&gt; and &lt;code&gt;[2,1]&lt;/code&gt; and tried to filter afterward. A &lt;code&gt;start&lt;/code&gt; index makes the invalid ordering impossible to generate. For duplicate values, sorting plus same-level pruning gives each result one canonical path.&lt;/p&gt;

&lt;p&gt;The rule I keep now is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enumerating &lt;strong&gt;nodes&lt;/strong&gt;: mark permanently.&lt;/li&gt;
&lt;li&gt;Enumerating &lt;strong&gt;paths&lt;/strong&gt;: mark on entry, restore on exit.&lt;/li&gt;
&lt;li&gt;Saving a mutable path: copy at the moment it becomes a result.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. A recursive function can return one value and build another answer
&lt;/h2&gt;

&lt;p&gt;Tree diameter initially tempted me into two recursive functions: compute a node's height, then recursively compute diameters and call height again at every node. The logic is correct; on a skewed tree the repeated height work makes it &lt;code&gt;O(n^2)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One postorder traversal can produce two different quantities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;nonlocal&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# answer using both branches
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# value one parent can extend
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distinction is structural. A path that continues to the parent can use only one child branch. A path whose highest point is the current node can join both.&lt;/p&gt;

&lt;p&gt;Maximum Path Sum uses the same skeleton:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;return the best one-sided gain to the parent;&lt;/li&gt;
&lt;li&gt;update a global best with left + node + right;&lt;/li&gt;
&lt;li&gt;clamp negative child gains to zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Its smallest counterexample is also its most important initialization test: a one-node tree &lt;code&gt;[-3]&lt;/code&gt;. Initializing the global answer to 0 returns a path that does not exist. It must start from a real node value or &lt;code&gt;-inf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Minimum Depth delivered a related warning about base cases. Replacing &lt;code&gt;max&lt;/code&gt; with &lt;code&gt;min&lt;/code&gt; in the maximum-depth recurrence fails on &lt;code&gt;[1,None,2]&lt;/code&gt;: the nonexistent left branch contributes 0 and wins, producing depth 1 instead of 2. An identity that is harmless under one aggregation can poison another.&lt;/p&gt;

&lt;p&gt;I now write down two contracts for recursive tree problems: &lt;strong&gt;what this call returns upward&lt;/strong&gt;, and &lt;strong&gt;what candidate answer this node contributes globally&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Graph modeling happens before DFS, BFS, or Union-Find
&lt;/h2&gt;

&lt;p&gt;The hardest part of several graph problems was choosing what a node meant.&lt;/p&gt;

&lt;p&gt;In Bus Routes, counting station-to-station edges answers the wrong question. The cost is buses boarded, so a BFS layer must represent one additional route. The useful index is &lt;code&gt;stop -&amp;gt; routes containing that stop&lt;/code&gt;, with separate visited sets for stops and routes.&lt;/p&gt;

&lt;p&gt;In Accounts Merge, my account-to-account model was correct but expensive: compare every pair of accounts for shared email, then union matching accounts. That is &lt;code&gt;O(n^2)&lt;/code&gt; before considering set intersections.&lt;/p&gt;

&lt;p&gt;The cleaner model makes &lt;strong&gt;emails&lt;/strong&gt; the nodes. Union all emails within each account. If an email appears in two accounts, it is literally the shared node that joins the components. The pairwise comparison disappears.&lt;/p&gt;

&lt;p&gt;The same modeling question showed up elsewhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Word Ladder nodes are words; edges are one-character changes.&lt;/li&gt;
&lt;li&gt;Open the Lock nodes are four-digit strings; edges are one wheel turn.&lt;/li&gt;
&lt;li&gt;Clone Graph is primarily an &lt;code&gt;old_node -&amp;gt; new_node&lt;/code&gt; mapping problem; DFS versus BFS is secondary.&lt;/li&gt;
&lt;li&gt;In grid Union-Find, &lt;code&gt;(row, col)&lt;/code&gt; becomes &lt;code&gt;row * width + col&lt;/code&gt;, but boundary checks must still happen in two dimensions. Checking only the flattened id lets the right edge wrap into the next row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before choosing a traversal, I now ask three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What exactly is a node?&lt;/li&gt;
&lt;li&gt;What operation creates an edge?&lt;/li&gt;
&lt;li&gt;What does one unit of cost or one BFS layer mean?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Getting those right often makes the algorithm routine. Getting them wrong can make a correct traversal solve a different problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Keep the unresolved frontier, not the whole history
&lt;/h2&gt;

&lt;p&gt;My best heap improvements came from asking what must be eligible &lt;strong&gt;right now&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For Merge K Sorted Lists, I first pushed every node and used the list index as a tuple tie-breaker. One list containing &lt;code&gt;[1,1,2]&lt;/code&gt; breaks it: equal values from the same list also share the same list index, so Python eventually tries to compare two &lt;code&gt;ListNode&lt;/code&gt; objects and raises &lt;code&gt;TypeError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The better invariant is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The heap contains at most one node from each list: that list's smallest unresolved node.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Pop one node, then push its successor. Now the list index is unique within the heap, the heap size stays at most &lt;code&gt;K&lt;/code&gt;, and the complexity becomes &lt;code&gt;O(N log K)&lt;/code&gt; instead of sorting all &lt;code&gt;N&lt;/code&gt; nodes.&lt;/p&gt;

&lt;p&gt;IPO exposed the same frontier bug in a different form. I rescanned every project on every round and reinserted affordable projects, so the same project could be completed twice. With &lt;code&gt;k = 2&lt;/code&gt;, &lt;code&gt;w = 0&lt;/code&gt;, profits &lt;code&gt;[1,2]&lt;/code&gt;, and capital &lt;code&gt;[0,3]&lt;/code&gt;, my version earned the first project's profit twice and returned 2; the correct answer is 1.&lt;/p&gt;

&lt;p&gt;Sorting projects by capital and advancing a pointer fixes the lifecycle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;each newly affordable project enters the profit heap once;&lt;/li&gt;
&lt;li&gt;unchosen affordable projects stay eligible in the heap;&lt;/li&gt;
&lt;li&gt;the chosen project leaves once;&lt;/li&gt;
&lt;li&gt;the pointer never moves backward because capital never decreases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same skeleton later powered offline interval queries and Skyline: sort by the condition that unlocks a candidate, advance one-way, keep active candidates in a heap, and lazily remove candidates only when they can affect the top.&lt;/p&gt;

&lt;p&gt;A heap is not a bag of everything I have seen. A monotonic stack is not a bag of previous indices. They are compressed representations of the &lt;strong&gt;unresolved frontier&lt;/strong&gt;. If I cannot state exactly why every stored item is still eligible, I probably do not yet have the invariant.&lt;/p&gt;




&lt;h2&gt;
  
  
  The meta-lesson
&lt;/h2&gt;

&lt;p&gt;At 46 problems, I thought my bugs lived in edge cases around otherwise-correct algorithms. At 123 tracker slots, I think that distinction is mostly false. The “edge” details are the algorithm:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What does the state mean?&lt;/li&gt;
&lt;li&gt;How is the empty state represented?&lt;/li&gt;
&lt;li&gt;In what order do reads, writes, marks, and moves happen?&lt;/li&gt;
&lt;li&gt;What proof allows a candidate to be discarded?&lt;/li&gt;
&lt;li&gt;Which approach does a constraint rule out?&lt;/li&gt;
&lt;li&gt;Can the search run backward from known answers?&lt;/li&gt;
&lt;li&gt;How long should a visited mark live?&lt;/li&gt;
&lt;li&gt;What returns to the parent, and what updates the global answer?&lt;/li&gt;
&lt;li&gt;What are the graph's actual nodes and edges?&lt;/li&gt;
&lt;li&gt;What exactly belongs in the unresolved frontier?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The most useful part of my notes is still the same as it was in the first version: every problem gets a “bug I actually wrote” and a counterexample. The fix tells me how to pass one test. The counterexample tells me which assumption was false—and that is the part that transfers to the next topic.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What mistake has followed you across the largest number of seemingly unrelated problems?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>leetcode</category>
      <category>python</category>
      <category>interview</category>
    </item>
  </channel>
</rss>
