<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="pt"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://0jonjo.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://0jonjo.github.io/" rel="alternate" type="text/html" hreflang="pt" /><updated>2026-09-06T16:14:30+00:00</updated><id>https://0jonjo.github.io/feed.xml</id><title type="html">João Gilberto Saraiva</title><subtitle>software engineer | professor | writer</subtitle><author><name>João Gilberto Saraiva</name></author><entry xml:lang="en"><title type="html">Polished Ruby Programming: A book about fundamentals that landed right in my AI work</title><link href="https://0jonjo.github.io/blog/2026/polished-ruby-programming/" rel="alternate" type="text/html" title="Polished Ruby Programming: A book about fundamentals that landed right in my AI work" /><published>2026-08-13T00:00:00+00:00</published><updated>2026-08-13T00:00:00+00:00</updated><id>https://0jonjo.github.io/blog/2026/polished-ruby-programming</id><content type="html" xml:base="https://0jonjo.github.io/blog/2026/polished-ruby-programming/"><![CDATA[<p>A few weeks ago, someone from <a href="https://www.packtpub.com/">Packt</a> reached out to me on LinkedIn. They had just released the second edition of <a href="https://code.jeremyevans.net/polished-ruby-programming.html">Polished Ruby Programming</a> and offered to send me a copy if I was willing to share my thoughts on it. You know I enjoy good tech books and work heavily with Ruby, so here we are. When the ebook arrived, I started skimming through it, expecting a solid, standard refresher on best practices.</p>

<p>It turned out to be much more than that—and in a direction I completely didn’t expect.</p>

<h2 id="whats-inside">What’s inside</h2>

<p>The scope is massive. Across 428 pages, <a href="https://code.jeremyevans.net/">Jeremy Evans</a> covers core classes, variable and method design, error handling, code formatting, library and plugin architecture, metaprogramming, DSLs, testing, refactoring, deprecation, design patterns, concurrency, static versus duck typing, and optimization.</p>

<p>Evans is a Ruby committer and the maintainer of <a href="https://roda.jeremyevans.net/">Roda</a> and <a href="https://sequel.jeremyevans.net/">Sequel</a>, so his opinions come with receipts. What I appreciated most is that almost nothing is handed down as an absolute rule. Even the SOLID principles arrive with a warning against applying them dogmatically. Every technique—a plugin system, a DSL, <code class="language-plaintext highlighter-rouge">method_missing</code>, static types—is presented as a trade-off. You are expected to decide for yourself, but you get a clear description of what each choice will cost you later. That’s a rarity in programming books.</p>

<p>A lot of the content is highly valuable on its own. But two chapters genuinely stopped me in my tracks because they answered questions I’d been wrestling with all year while building AI features into production Rails apps.</p>

<p>Here are my two highlights.</p>

<h2 id="first-highlight-breaking-circuits">First highlight: Breaking circuits</h2>

<p>This one hit close to home.</p>

<p>Chapter 5 is all about error handling: return values versus exceptions, designing APIs that are hard to misuse, fail-open versus fail-closed, retrying transient errors with backoff, and shaping exception hierarchies. Deep in the chapter, Evans builds a small circuit breaker from scratch: if a service fails three times in a minute, you stop calling it for a while, preventing every request from queuing up behind a provider that’s already down.</p>

<p>Then he closes the section with the exact kind of pragmatic advice I wish more books gave:</p>

<blockquote>
  <p>“If you need a circuit breaker for production code, you should probably use one of the many circuit breaker gems for Ruby instead of trying to implement a circuit breaker yourself, unless you have specific requirements not handled by an existing gem.”</p>
</blockquote>

<p>That is the exact conclusion I reached last year when writing <a href="https://jetrockets.com/blog/building-a-resilient-ai-client-in-ruby-with-stoplight-and-ruby_llm">Building a Resilient AI Client in Ruby with Stoplight and ruby_llm</a> for the <a href="https://www.linkedin.com/company/jetrockets/">JetRockets</a> blog. The goal was to detect when a model provider is failing, trip the circuit, fail over to a backup model, and keep the conversation history intact. I went with <a href="https://github.com/bolshakov/stoplight">Stoplight</a> rather than rolling my own, for the exact reason Evans points out.</p>

<p>Still, reading the from-scratch implementation was incredibly useful. You end up understanding exactly what the gem handles for you, and more importantly, what it doesn’t. This chapter also gave me an insight my own article was missing: separating permanent from transient errors is a structural decision that belongs in your exception hierarchy, not in a <code class="language-plaintext highlighter-rouge">rescue</code> clause bolted on later. A rate limit and a malformed request are fundamentally different failures, and only one of them deserves a retry. Until now, I’d been treating both simply as “the provider is having a bad day.”</p>

<h2 id="second-highlight-only-mock-what-you-cannot-control">Second highlight: Only mock what you cannot control</h2>

<p>The testing chapter drops this rule:</p>

<blockquote>
  <p>“If you must mock, only mock what you cannot control, such as calls to external APIs.”</p>
</blockquote>

<p>For standard HTTP clients, most of us just nod and move on. But try applying that to a feature built around a Large Language Model, and it becomes the definitive answer to a testing problem I’d been circling for weeks.</p>

<p>An AI model isn’t a normal dependency. It’s slow, it costs money on every call, and—the part that really wrecks a test suite—it’s non-deterministic. Same input, different output. This usually leaves you with two bad options. Option A: Mock the entire pipeline, turning your tests into pure theater. They pass forever, asserting only that your own stubs were called, while real regressions walk straight through to production. Option B: Mock nothing, leaving you with a test suite that is flaky, painfully slow, and bills you on every CI run.</p>

<p>Evans’ rule puts the boundary exactly where it belongs. The model provider API is the <em>only</em> thing I cannot control, so it is the <em>only</em> thing I stub. Everything else runs for real: prompt assembly, the batching loop over long content, the context management that keeps terminology consistent across chunks, the state machine of the background job, and saving the final result. If I break the batching logic, a test fails. That’s exactly what tests are for.</p>

<p>I also learned two hard lessons by getting this wrong initially. First, stubbing the client isn’t enough—you also have to pin the exact payload it hands back. Otherwise, the non-determinism just moves one layer down and your assertions start drifting. Second, this testing style severely punishes heavy inline setup. Once I moved the sample AI context into fixtures, the specs became readable and just as fast as any other model test in our suite.</p>

<p>The same chapter states the trade-off plainly, and it’s a quote I’d gladly put on my wall:</p>

<blockquote>
  <p>“Slower, reliable tests are better than fast tests that break without reason (false positives) and don’t catch actual breakage (false negatives).”</p>
</blockquote>

<h2 id="zero-hits">“Zero” hits</h2>

<p>Out of curiosity, I eventually ran a search across the entire ebook for the terms “LLM”, “AI”, and “ChatGPT”.</p>

<p>I found exactly one hit: a passing sentence about how users search for library documentation. As a guide for building AI features? Zero hits.</p>

<p>And that is the part I keep coming back to. This isn’t a book about AI, and that is precisely why it helped so much. A model provider is slow, occasionally unavailable, rate-limited, non-deterministic, and entirely outside your control. We already have decades of solid engineering practices for handling dependencies that behave exactly like that.</p>

<p>The only genuinely new thing is that AI failures are more convincing. A broken API simply returns a <code class="language-plaintext highlighter-rouge">503</code>. A broken model returns a fluent, confident, and nicely formatted wrong answer.</p>

<p>The architectural boundary is exactly where it always was. It just matters a lot more now.</p>

<h2 id="who-should-read-it">Who should read it</h2>

<p>Intermediate and senior Ruby developers, without hesitation. If you’re still learning the syntax, learn it somewhere else first—this book assumes you already write Ruby and want to write it better.</p>

<p>And if you maintain any long-lived applications, Chapter 12 earns the read all on its own. Evans argues that features should be treated as liabilities rather than assets, since every single one of them carries a permanent maintenance cost. Therefore, removing a feature is a net gain. It’s the exact same conviction I wrote about a while back regarding <a href="/blog/2024/remocao-codigo/">removing legacy code</a>, just stated much more sharply than I managed at the time.</p>

<p><em>Disclosure: Packt sent me a review copy of the book. The opinions here are entirely my own.</em></p>

<p><img width="640" height="333" alt="HannaBarakat -CambridgeDiversity FundPas(t)imesin the Computer Lab -640x333" src="https://github.com/user-attachments/assets/489984c5-0dbc-40a5-af1e-f1838b7f41c7" /></p>

<p>Image: “Pas(t)imes in the Computer Lab”, Hanna Barakat &amp; Cambridge Diversity Fund. <a href="https://betterimagesofai.org/images?artist=HannaBarakat&amp;title=Pas%28t%29imesintheComputerLab">Better Images of AI, Creative Commons 4.0</a></p>]]></content><author><name>João Gilberto Saraiva</name></author><category term="misc" /><category term="programming" /><category term="ruby" /><category term="ai" /><summary type="html"><![CDATA[Reviewing the second edition of Jeremy Evans' book: what it covers, and the two chapters that answered questions I had been chewing on all year while shipping AI features in Rails.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://github.com/user-attachments/assets/489984c5-0dbc-40a5-af1e-f1838b7f41c7" /><media:content medium="image" url="https://github.com/user-attachments/assets/489984c5-0dbc-40a5-af1e-f1838b7f41c7" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Five resources and one strategy for Claude Certification</title><link href="https://0jonjo.github.io/blog/2026/studing-for-claude-architect/" rel="alternate" type="text/html" title="Five resources and one strategy for Claude Certification" /><published>2026-07-06T00:00:00+00:00</published><updated>2026-07-06T00:00:00+00:00</updated><id>https://0jonjo.github.io/blog/2026/studing-for-claude-architect</id><content type="html" xml:base="https://0jonjo.github.io/blog/2026/studing-for-claude-architect/"><![CDATA[<p>For the past month, I’ve been preparing for the <a href="https://anthropic-partners.skilljar.com/page/partner-certifications">Claude Architect Foundations</a> certification. The exam validates your knowledge of the Claude AI model, its architecture, and the best practices for building robust applications. Since any company aiming to complete the <a href="https://claude.com/partners">Claude Partner Network program</a> needs a minimum number of certified architects, my team at <a href="https://www.linkedin.com/company/jetrockets/">JetRockets</a> and I are preparing to take it soon. The exam is not free, so the goal is to pass on the first attempt.</p>

<p>The exam consists of 60 multiple-choice questions with a 120-minute limit. The syllabus is divided into five main areas: Agentic Architecture &amp; Orchestration (27%), Tool Design &amp; MCP Integration (18%), Claude Code Configuration &amp; Workflows (20%), Prompt Engineering &amp; Structured Output (20%), and Context Management &amp; Reliability (15%). It tests both theoretical knowledge and practical skills — meaning hands-on experience with Claude and its ecosystem is essential.</p>

<p>Anthropic provides a <a href="https://anthropic-partners.skilljar.com/page/partner-certifications">study guide</a> with recommended resources, including the official documentation and sample questions. Additionally, there are several online courses breaking down the objectives. I did some research, exchanged materials with colleagues also studying for the exam, and here are the resources I found most useful:</p>

<ol>
  <li><a href="https://anthropic-partners.skilljar.com/collections">Official Antrophic courses</a>: video, texts, and quizzes organized into specific learning paths, plus free courses on Claude and AI in general.</li>
  <li><a href="https://anthropic-partners.skilljar.com/page/partner-certifications">Claude Architect Foundations Study Guide</a>: the official reference for example questions, in and out-of-scope topics, and exam preparation details.</li>
  <li><a href="https://www.claude-certification-guide.com/">Claude Certification Guide</a>: a non-official website that I’m using to test my knowledge with mock exams and community discussions.</li>
  <li><a href="https://www.youtube.com/watch?v=reDRM0tqhNs">Claude Certified Architect - Foundations by freeCodeCamp.org</a>: an excellent crash course video to complement the official materials and help you prepare for the exam.</li>
  <li><a href="https://github.com/paullarionov/claude-certified-architect">Claude Certified Architect Study Guide (paullarionov)</a>: an open-source GitHub repository with a curated guide and additional study materials.</li>
</ol>

<p>To prepare for the exam, I am not following a rigid, linear plan. Instead, I am dynamically alternating between the four required official courses, reading external articles, and practicing with mock questions. This flexible strategy is very similar to the one I used to pass the Google Associate Cloud Engineer certification some time ago.</p>

<p>That being said, there is a distinct difference between these two exams. The Google Cloud certification is extremely broad — it covers a massive landscape of services, expecting you to understand many topics without necessarily deep-diving into all of them. The Anthropic exam is the opposite. It has fewer main themes, but it requires you to go much deeper into concepts like agentic architecture, prompt engineering, and context management.</p>

<p>Despite this contrast, both exams share the same core philosophy: they are highly contextual. You won’t see questions asking for simple definitions. Instead, they give you a real-world problem and ask for the most appropriate solution.</p>

<p>For example, a typical question for the Anthropic exam looks something like this:</p>

<blockquote>
  <p><strong>Example Question:</strong>
<em>You are designing a customer support agent using Claude that needs to check order statuses from a secure internal database. Which approach is the best way to handle this while maintaining security and efficiency?</em></p>

  <p>A) Provide the database credentials in the system prompt so Claude can write and execute SQL queries directly.
B) Provide a massive CSV export of the database in the context window.
C) Create a Model Context Protocol (MCP) server that exposes a specific <code class="language-plaintext highlighter-rouge">get_order_status</code> tool, passing only the necessary parameters.
D) Use a generic web-search tool to scrape the internal dashboard.</p>

  <p><em>(The correct answer is C, because it uses the proper architectural pattern for tools and keeps credentials secure outside the model).</em></p>
</blockquote>

<p>This means that simply reading the docs is not enough. You really need to think about the situation and understand <em>why</em> you are choosing a specific architectural pattern.</p>

<p>In the end, preparing for the Claude Architect Foundations is less about cramming documentation and more about adopting an agentic mindset — putting yourself in the shoes of an AI architect who is constantly balancing efficiency, security, and context limits to solve real-world constraints.</p>

<p><img width="100%" alt="Weaving wires into a computer monitor" src="https://github.com/user-attachments/assets/d95a655f-d120-4c67-8b0b-ade52a251026" /></p>

<p>Image: <em>Weaving Wires 1</em> by Hanna Barakat / AIxDESIGN / <a href="https://betterimagesofai.org">Better Images of AI</a>, Creative-Commons License.</p>]]></content><author><name>João Gilberto Saraiva</name></author><category term="misc" /><category term="programming" /><category term="learning" /><category term="ai" /><summary type="html"><![CDATA[Preparing for the Claude Architect Foundations certification: five essential study resources, a dynamic learning strategy, and the mindset needed to pass the exam.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://github.com/user-attachments/assets/d95a655f-d120-4c67-8b0b-ade52a251026" /><media:content medium="image" url="https://github.com/user-attachments/assets/d95a655f-d120-4c67-8b0b-ade52a251026" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">From Backend to MLOps: a runner’s road to business rules, deployments and monitoring</title><link href="https://0jonjo.github.io/blog/2026/from-backend-to-mlops/" rel="alternate" type="text/html" title="From Backend to MLOps: a runner’s road to business rules, deployments and monitoring" /><published>2026-06-06T00:00:00+00:00</published><updated>2026-06-06T00:00:00+00:00</updated><id>https://0jonjo.github.io/blog/2026/from-backend-to-mlops</id><content type="html" xml:base="https://0jonjo.github.io/blog/2026/from-backend-to-mlops/"><![CDATA[<p>By the end of last semester, I was preparing for a deep dive into deployments, monitoring, maintenance, and the operational crossroads where Backend meets DevOps. My goal was to bridge my previous cloud experience—especially with GCP—with the modern stack we rely on at <a href="https://www.linkedin.com/company/jetrockets/">JetRockets</a>, such as <a href="https://kamal-deploy.org/">Kamal</a>, <a href="https://www.appsignal.com/">AppSignal</a>, <a href="https://www.digitalocean.com/">DigitalOcean</a>, and <a href="https://www.cloudflare.com/">Cloudflare</a>. When the syllabus dropped for my second semester in the <a href="https://pes.imd.ufrn.br/pes/index">Artificial Intelligence program at UFRN</a>, I was thrilled to spot MLOps (Machine Learning Operations) on the list. While the name might sound niche, <a href="https://en.wikipedia.org/wiki/MLOps">MLOps</a> is essentially the application of strict software engineering and DevOps principles to machine learning, ensuring models aren’t just local experiments, but are effectively deployed, monitored, and maintained in production.</p>

<p><img width="640" height="460" alt="image" src="https://github.com/user-attachments/assets/ae6da9b6-b918-4b44-a468-b3cd89dcf641" /></p>

<p>Going in, I fully expected we’d immediately dive into server clusters, CI/CD pipelines, and configuration files. I’m glad I was wrong. The course actually kicked off with <a href="https://online.hbs.edu/blog/post/what-is-design-thinking">Design Thinking</a>. It forced us to step back from the terminal and discuss the importance of understanding the business problem, the user, and the real-world context before throwing infrastructure at a problem.</p>

<p>To bring these concepts out of the classroom and into reality, I needed a concrete project. I chose running analysis—a domain I know deeply after nine years in the sport. While I already had the core math mapped out in <a href="https://rubygems.org/gems/calcpace">an open-source gem</a>, I wanted to build a complete system to test these operational practices in the wild. The ultimate result of this effort is <a href="https://calcpace.app/">Calcpace</a>. Recently shipped to production, it is a fully-fledged web application offering running conversions, VO2 max estimates, race calendars, and pace predictions in 15 different languages. Under the hood, it serves as the perfect sandbox for merging backend and ML operations, powered by Ruby on Rails, PostgreSQL, Redis, Sidekiq, Kamal, AppSignal, DigitalOcean, and Cloudflare.</p>

<p>Here are some highlights of how the MLOps principles we studied mapped directly onto this project:</p>

<ol>
  <li>
    <p><strong>Design Thinking and the Cost of an Error</strong>: In ML, optimizing a mathematical metric means nothing if it doesn’t solve a user’s problem. For Calcpace, this meant studying existing market solutions and identifying exactly where runners struggle with pace and VO2 max calculations. It echoed the principles of <a href="https://www.domainlanguage.com/ddd/blue-book/">Domain-Driven Design, by Eric Evans</a>—you have to align your software architecture with the business reality. In a running app, a ‘bad prediction’ isn’t just a UI bug; it could mean an athlete pacing a marathon entirely wrong and hitting the wall.</p>
  </li>
  <li>
    <p><strong>TDD as a Pipeline Foundation</strong>: We revisited Test-Driven Development not just as a coding habit, but as a critical safeguard. Automated testing is the backbone of both standard software engineering and MLOps. In ML, you don’t just test if the code compiles; you test if the data schema is valid and if the model’s baseline performance holds. Solidifying the test suite for Calcpace was essential to ensure the core math remained completely reliable as new features were added.</p>
  </li>
  <li>
    <p><strong>The “Last Mile” of Model Serving with Rails</strong>: Ruby on Rails might not be the default choice for training models, but it is an exceptional tool for the “last mile”—delivering ML insights to the end user. My focus here isn’t on the mathematical training itself, but on building a robust, containerized environment using Kamal. By treating the complex logic (currently encapsulated in the Ruby Gem) as a decoupled service within a Rails/Docker stack, I’m ensuring that when we swap a heuristic for a complex predictive model, the infrastructure won’t blink. Furthermore, managing the latency of serving these predictions within the standard Rails request-response cycle is the real engineering challenge that separates a simple script from a production-ready system.</p>
  </li>
</ol>

<p><img width="772" height="462" alt="Screenshot from 2026-06-06 17-35-34" src="https://github.com/user-attachments/assets/c7ee6436-7613-49a5-a995-5c9599452fcf" /></p>

<ol>
  <li>
    <p><strong>CI/CD and Workflow Automation</strong>: Continuous Integration and Deployment are non-negotiable. While the course explored ML-specific tracking tools like Weights &amp; Biases alongside GitHub Actions, I focused on mastering the latter for workflow automation. Every time a new version is released, GitHub Actions runs the test suite and seamlessly triggers a Kamal deployment. It’s a closed loop that ensures the live application is always perfectly synced with the repository.</p>
  </li>
  <li>
    <p><strong>Beyond Uptime (Monitoring Data Sanity)</strong>: The course introduced the LGTM stack (Looker, Grafana, Tempo, Mimir) for deep observability, which is standard for tracking infrastructure and data drift in ML. For the Calcpace ecosystem, AppSignal paired with Cloudflare serves as our equivalent. It provides fantastic out-of-the-box APM, tracking request latency and error rates. But an MLOps mindset demands more: we must monitor data sanity. If a runner’s VO2 Max prediction suddenly spikes to 99 due to a malformed input, our APM and strict validations help us catch that “silent failure” before it ruins the user’s trust.</p>
  </li>
  <li>
    <p><strong>Lean Infrastructure and Domain Management</strong>: We zoomed out to look at infrastructure as a whole—managing VMs, securing CI/CD secrets, and configuring scalable storage. In practice, this also became an exercise in cost efficiency. By leveraging Kamal’s container orchestration, I managed to pack the entire production stack—the Rails app, PostgreSQL database, and Sidekiq for async jobs—into a single $6/month DigitalOcean droplet without sacrificing the ability to scale later. Furthermore, establishing the production environment meant handling the custom <code class="language-plaintext highlighter-rouge">.app</code> domain registration and managing DNS and strict SSL configurations through Cloudflare, alongside setting up Cloudflare R2 for cost-effective, S3-compatible object storage.</p>
  </li>
  <li>
    <p><strong>DevOps in an AI-Scraped World</strong>: Finally, exposing an API or web app today means dealing with a massive influx of automated traffic. I configured custom WAF (Web Application Firewall) rules to block malicious actors while ensuring the site remains fully accessible to legitimate crawlers. This balance is vital for SEO and for ensuring the content is properly indexed by modern AI search bots.</p>
  </li>
</ol>

<p>Ultimately, the final deliverable for this discipline isn’t just a static repository; it’s a living, breathing product. By building <a href="https://calcpace.app/">Calcpace</a> — and seeing its core open-source gem cross the 10,000 downloads milestone — it reinforced my conviction that a real challenge of a production-ready system lies in reliability, deployment pipelines, and operational monitoring.</p>

<h3 id="further-reading">Further Reading</h3>

<ul>
  <li><strong><a href="https://github.com/adaj/mlops-2026-1">UFRN MLOps Course Repository</a></strong>: The official repository from the MLOps discipline at UFRN. It contains the reference architecture used in our classes, including Python model serving and observability with Kibana.</li>
  <li><strong><a href="https://jetrockets.com/blog/how-to-use-basecamp-s-kamal-with-aws-and-github">How to use Basecamp’s Kamal with AWS and GitHub</a></strong>: A practical guide by JetRockets’ CTO, Igor Alexandrov, on orchestrating container deployments with Kamal and GitHub Actions.</li>
  <li><strong><a href="https://martinfowler.com/articles/cd4ml.html">Continuous Delivery for Machine Learning (CD4ML)</a></strong>: core article on applying standard CI/CD and DevOps practices to ML systems.</li>
  <li><strong><a href="https://www.domainlanguage.com/ddd/">Domain-Driven Design by Eric Evans</a></strong>: The foundational book on aligning software models with business reality.</li>
</ul>

<p><img width="1024" height="1024" alt="image" src="https://github.com/user-attachments/assets/cce45f91-8dce-41e1-92e1-2480d1f9dbd2" /></p>

<p>Images: “Running down the shadows” of Vinoth Chandar. <a href="https://openverse.org/image/b85eb957-92f7-43b9-8560-3922b2beb361">Open Verse, Creative Commons 2.0</a> and MLops (<a href="https://en.wikipedia.org/wiki/MLOps">Wikipedia</a>)</p>]]></content><author><name>João Gilberto Saraiva</name></author><category term="misc" /><category term="programming" /><category term="learning" /><category term="ai" /><category term="ruby" /><summary type="html"><![CDATA[The intersection of Backend Development and MLOps, a deep dive into applying DevOps principles, Design Thinking, and deployment strategies to machine learning systems using the Calcpace app.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://github.com/user-attachments/assets/75629b8e-47f8-4520-bbfe-42fd8d005b92" /><media:content medium="image" url="https://github.com/user-attachments/assets/75629b8e-47f8-4520-bbfe-42fd8d005b92" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Migrating Rails views to Jet UI: a guide with ViewComponent and Tailwind v4</title><link href="https://0jonjo.github.io/blog/2026/jet-ui-migration/" rel="alternate" type="text/html" title="Migrating Rails views to Jet UI: a guide with ViewComponent and Tailwind v4" /><published>2026-05-06T00:00:00+00:00</published><updated>2026-05-06T00:00:00+00:00</updated><id>https://0jonjo.github.io/blog/2026/jet-ui-migration</id><content type="html" xml:base="https://0jonjo.github.io/blog/2026/jet-ui-migration/"><![CDATA[<p>Last week, I wrote an article about migrating Calcpace views to use jet_ui to <a href="https://jetrockets.com/blog/migrating-rails-views-to-jet-ui-a-real-world-guide-with-viewcomponent-and-tailwind-v4">JetRockets blog</a>. Here is the full article:</p>

<p>Maintaining a consistent UI in a growing Rails application is a classic challenge. We often start with the best intentions, clean HTML and utility classes, but as the app scales, we inevitably fall into “UI boilerplate fatigue.” Whether it’s copy-pasting the same “Avatar with initials” logic across dozens of views or reinventing the wheel for every animated toast, this duplication slowly erodes our development velocity.</p>

<p>To solve this, I recently migrated <a href="https://calcpace.app">Calcpace</a>, a running and cycling tracker built with Rails 8, to <a href="https://github.com/jetrockets/jet_ui">jet_ui</a>, JetRockets’ component library. In this article, I’ll show you how we used Calcpace as a real-world playground to standardize our interface, leverage Tailwind CSS v4, and solve the production “gotchas” that often come with gem-based assets.</p>

<h2 id="the-problem-copy-paste-debt">The Problem: Copy-Paste Debt</h2>

<p>Before the migration, our UI was functional but repetitive. Handling profile pictures required manual conditional logic for avatars and initials in every view:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# Before: UI logic leaking into views %&gt;</span>
<span class="cp">&lt;%</span> <span class="k">if</span> <span class="n">current_profile</span><span class="p">.</span><span class="nf">avatar</span><span class="p">.</span><span class="nf">attached?</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">image_tag</span> <span class="n">current_profile</span><span class="p">.</span><span class="nf">avatar</span><span class="p">.</span><span class="nf">variant</span><span class="p">(</span><span class="ss">resize_to_fill: </span><span class="p">[</span><span class="mi">24</span><span class="p">,</span> <span class="mi">24</span><span class="p">]),</span> <span class="ss">class: </span><span class="s2">"w-6 h-6 rounded-full"</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">else</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center text-xs"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">current_profile</span><span class="p">.</span><span class="nf">initials</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p><img width="615" height="344" alt="image" src="https://github.com/user-attachments/assets/044907e3-1f49-434f-b929-0edc1e33f210" /></p>

<p>This “inline Tailwind” approach lacks a single source of truth. Changing a border radius meant a tedious search-and-replace across the entire codebase.</p>

<h2 id="the-strategy-incremental-migration">The Strategy: Incremental Migration</h2>

<p>One of the biggest concerns when adopting a component library is the “big bang” rewrite. Do you have to change every view at once? Absolutely not.</p>

<p><code class="language-plaintext highlighter-rouge">jet_ui</code> is designed for incremental adoption. In Calcpace, we didn’t touch our legacy views initially. We started by replacing the most “noisy” elements—flashes and avatars—and then moved to complex data tables. You can have a page powered entirely by <code class="language-plaintext highlighter-rouge">jet_ui</code> components sitting right next to a legacy ERB view using plain Tailwind utility classes. They coexist perfectly because <code class="language-plaintext highlighter-rouge">jet_ui</code> respects your existing Tailwind configuration while providing the structure of ViewComponent. This removes the psychological barrier of migration: you can improve your app one component at a time.</p>

<h2 id="requirements-and-plugging-in-jet-ui">Requirements and Plugging in Jet UI</h2>

<p><code class="language-plaintext highlighter-rouge">jet_ui</code> is built on <a href="https://viewcomponent.org">ViewComponent</a> and <a href="https://tailwindcss.com">Tailwind CSS v4</a>. It follows a “Rails-native” philosophy, leveraging the latest tools in the ecosystem:</p>

<ul>
  <li>Ruby &gt;= 3.0 and Rails &gt;= 7.0 (Calcpace runs on Rails 8.1).</li>
  <li>Tailwind CSS v4 (via <code class="language-plaintext highlighter-rouge">tailwindcss-rails &gt;= 4.x</code>).</li>
  <li>Stimulus and Turbo (standard in modern Rails).</li>
</ul>

<p>In your <code class="language-plaintext highlighter-rouge">Gemfile</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">gem</span> <span class="s2">"view_component"</span>
<span class="n">gem</span> <span class="s2">"jet_ui"</span>
</code></pre></div></div>

<h2 id="the-power-of-generators">The Power of Generators</h2>

<p>One of <code class="language-plaintext highlighter-rouge">jet_ui</code>’s standout features is its suite of generators. They don’t just copy files; they wire up your entire application.</p>

<h3 id="jet_uiinstall"><code class="language-plaintext highlighter-rouge">jet_ui:install</code></h3>
<p>This sets up the library in your application (CSS + JS). It is safe to re-run after gem upgrades, as already-configured steps are automatically skipped:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails generate jet_ui:install
</code></pre></div></div>

<h3 id="jet_uieject"><code class="language-plaintext highlighter-rouge">jet_ui:eject</code></h3>
<p>If you need to customize a component beyond standard options, you can “eject” it. This copies the Ruby class, ERB template, and Stimulus controller directly into your <code class="language-plaintext highlighter-rouge">app/components/jet_ui/</code> folder. The ejected files take precedence automatically.</p>

<p>You can eject multiple components at once and use flags to keep your codebase lean:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Eject button, card, and flash</span>
rails generate jet_ui:eject btn card flash

<span class="c"># Skip specific files if you only want to customize the template</span>
rails generate jet_ui:eject btn <span class="nt">--skip-test</span> <span class="nt">--skip-preview</span>
rails generate jet_ui:eject flash <span class="nt">--skip-javascript</span>
</code></pre></div></div>

<h2 id="production-readiness-the-vendoring-strategy">Production Readiness: The Vendoring Strategy</h2>

<p>While the <code class="language-plaintext highlighter-rouge">install</code> generator works perfectly for local development by pointing to the gem’s path, production environments like Docker or CI require a more portable approach. To ensure a deterministic build and clean logs, we adopt a Vendoring strategy. Instead of relying on absolute filesystem paths that change between environments, we copy the CSS directly into the repository but place it outside the standard Rails asset search path to avoid duplicate serving.</p>

<ol>
  <li>Vendor the assets programmatically:
    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> vendor/stylesheets
<span class="nb">cp</span> <span class="nt">-r</span> <span class="si">$(</span>bundle show jet_ui<span class="si">)</span>/app/assets/stylesheets/<span class="k">*</span> vendor/stylesheets/
</code></pre></div>    </div>
  </li>
  <li>Update your Tailwind source file:
    <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* app/assets/tailwind/application.css */</span>
<span class="k">@import</span> <span class="s1">"tailwindcss"</span><span class="p">;</span>
<span class="k">@import</span> <span class="s1">"../../../vendor/stylesheets/jet_ui.css"</span><span class="p">;</span>
</code></pre></div>    </div>
  </li>
</ol>

<p>Why this approach?</p>
<ul>
  <li>Portability: The build works in Docker, CI, and any developer’s machine without modifications.</li>
  <li>Clean Logs: By placing files in <code class="language-plaintext highlighter-rouge">vendor/stylesheets</code> (which Propshaft ignores by default), the asset pipeline won’t try to serve individual component files (like <code class="language-plaintext highlighter-rouge">popover.css</code>). This prevents the “404 Not Found” noise in production logs for files already bundled into your main CSS.</li>
</ul>

<p><img width="734" height="312" alt="image" src="https://github.com/user-attachments/assets/839cb7b0-a01c-4e3b-9765-2cdf2f5176dc" /></p>

<h2 id="customizing-the-theme-with-tailwind-v4">Customizing the Theme with Tailwind v4</h2>

<p><code class="language-plaintext highlighter-rouge">jet_ui</code> uses modern CSS variables. Instead of overriding thousands of utility classes, you update the theme variables in your CSS source:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@theme</span> <span class="p">{</span>
  <span class="py">--accent-hue</span><span class="p">:</span> <span class="m">163</span><span class="p">;</span>      <span class="c">/* Calcpace Emerald */</span>
  <span class="py">--accent-chroma</span><span class="p">:</span> <span class="m">0.2</span><span class="p">;</span>
  <span class="py">--accent-lightness</span><span class="p">:</span> <span class="m">0.52</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every component, from buttons to focus rings, will now use your custom palette.</p>

<h2 id="replacing-the-noise-real-world-examples">Replacing the Noise: Real-World Examples</h2>

<h3 id="interactive-form-groups">Interactive Form Groups</h3>
<p>We used <code class="language-plaintext highlighter-rouge">jet_ui.group</code> to standardize selectors. For our activity unit toggle (KM/MI), the component handles the styling and layout, leaving us with a clean DSL:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">jet_ui</span><span class="p">.</span><span class="nf">group</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">radio_button</span> <span class="ss">:unit</span><span class="p">,</span> <span class="s2">"km"</span><span class="p">,</span> <span class="ss">checked: </span><span class="kp">true</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">radio_button</span> <span class="ss">:unit</span><span class="p">,</span> <span class="s2">"mi"</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<h3 id="advanced-composition-tables-and-tabs">Advanced Composition: Tables and Tabs</h3>
<p>The World Records page was our “stress test” for displaying dense data. By composing <code class="language-plaintext highlighter-rouge">tabs</code>, <code class="language-plaintext highlighter-rouge">card</code>, and <code class="language-plaintext highlighter-rouge">table</code>, we reduced a complex view to a readable DSL:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">jet_ui</span><span class="p">.</span><span class="nf">tabs</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">jet_ui</span><span class="p">.</span><span class="nf">tabs_item</span> <span class="s2">"KM"</span><span class="p">,</span> <span class="ss">href: </span><span class="n">records_path</span><span class="p">(</span><span class="ss">unit: </span><span class="s2">"km"</span><span class="p">),</span> <span class="ss">active: </span><span class="vi">@unit</span> <span class="o">==</span> <span class="s2">"km"</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">jet_ui</span><span class="p">.</span><span class="nf">tabs_item</span> <span class="s2">"MI"</span><span class="p">,</span> <span class="ss">href: </span><span class="n">records_path</span><span class="p">(</span><span class="ss">unit: </span><span class="s2">"mi"</span><span class="p">),</span> <span class="ss">active: </span><span class="vi">@unit</span> <span class="o">==</span> <span class="s2">"mi"</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>

<span class="cp">&lt;%=</span> <span class="n">jet_ui</span><span class="p">.</span><span class="nf">card</span> <span class="ss">class: </span><span class="s2">"overflow-hidden"</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">jet_ui</span><span class="p">.</span><span class="nf">table</span> <span class="ss">hovered: </span><span class="kp">true</span> <span class="k">do</span> <span class="cp">%&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">jet_ui</span><span class="p">.</span><span class="nf">table_thead</span> <span class="k">do</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">jet_ui</span><span class="p">.</span><span class="nf">table_tr</span> <span class="k">do</span> <span class="cp">%&gt;</span>
        <span class="cp">&lt;%=</span> <span class="n">jet_ui</span><span class="p">.</span><span class="nf">table_th</span> <span class="p">{</span> <span class="s2">"Event"</span> <span class="p">}</span> <span class="cp">%&gt;</span>
        <span class="c">&lt;%# ... %&gt;</span>
      <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>This composition proves the architectural leverage: we get a professional data grid with integrated navigation, all following the same design system with zero manual CSS.</p>

<p><img width="1001" height="514" alt="image" src="https://github.com/user-attachments/assets/77774ac0-f804-472e-89ae-670751139237" /></p>

<h2 id="why-jet_ui-the-alternatives">Why jet_ui? (The Alternatives)</h2>

<p>You might ask: “Why not just use Flowbite, shadcn-rails, or RailsUI?”</p>

<p>While those are great tools, they occupy different niches. Flowbite is fantastic for Tailwind-first projects but isn’t built as a first-class <code class="language-plaintext highlighter-rouge">ViewComponent</code> library, often requiring you to wrap their HTML yourself. shadcn-rails follows the “copy-paste” philosophy which is great for total control, but lacks a clean, gem-based upgrade path for those who want their design system managed as a dependency. RailsUI is a premium, template-oriented solution that is excellent for rapid prototyping but might feel too opinionated for existing apps.</p>

<p><code class="language-plaintext highlighter-rouge">jet_ui</code> sits in the “Goldilocks” zone: it’s ViewComponent-native, Tailwind v4-native, and gem-distributed with an “eject-on-demand” safety valve. You get the maintenance benefits of a gem with the flexibility of local code when you need it.</p>

<h2 id="conclusion-architectural-leverage">Conclusion: Architectural Leverage</h2>

<p>Migration isn’t just about “fancy” code. It’s about reducing cognitive load. Developers can focus on building features using high-level components rather than wrestling with low-level utility classes in every single view. If you’re building a modern Rails app, <code class="language-plaintext highlighter-rouge">jet_ui</code> is the bridge between the flexibility of Tailwind and the structure of a professional design system.</p>

<h2 id="links">Links</h2>
<ul>
  <li>📦 <a href="https://rubygems.org/gems/jet_ui">Jet_UI on RubyGems</a></li>
  <li>📁 <a href="https://github.com/jetrockets/jet_ui">Jet_UI Repo</a></li>
  <li>🏃 <a href="https://calcpace.app">Calcpace</a></li>
</ul>]]></content><author><name>João Gilberto Saraiva</name></author><category term="misc" /><category term="programming" /><category term="ruby" /><category term="ai" /><summary type="html"><![CDATA[Ar real-world experience migrating Calcpace to Jet UI, a ViewComponent-based library built on Tailwind CSS v4. How we standardized our UI, leveraged generators, and solved production gotchas.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://github.com/user-attachments/assets/77774ac0-f804-472e-89ae-670751139237" /><media:content medium="image" url="https://github.com/user-attachments/assets/77774ac0-f804-472e-89ae-670751139237" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Calcpace: open beta, maps, bot protection and a growing gem</title><link href="https://0jonjo.github.io/blog/2026/calcpace-open-beta/" rel="alternate" type="text/html" title="Calcpace: open beta, maps, bot protection and a growing gem" /><published>2026-04-06T00:00:00+00:00</published><updated>2026-04-06T00:00:00+00:00</updated><id>https://0jonjo.github.io/blog/2026/calcpace-open-beta</id><content type="html" xml:base="https://0jonjo.github.io/blog/2026/calcpace-open-beta/"><![CDATA[<p>Calcpace.app is now open to anyone — no invite code needed. This post covers what changed since the closed beta: new gem modules, GPS tracking with maps, bot protection, the infrastructure decisions behind each feature, and a few lessons learned the hard way.</p>

<p>The <code class="language-plaintext highlighter-rouge">calcpace</code> gem is a pure Ruby library — no I/O, no Rails, no external dependencies. Every formula lives in its own module, included into the main <code class="language-plaintext highlighter-rouge">Calcpace</code> class. The site consumes it as a regular gem dependency; when a new module ships, the site updates the <code class="language-plaintext highlighter-rouge">Gemfile</code> and builds on top of it. Since v1.8, three modules were added.</p>

<p><strong>CameronPredictor</strong> — an exponential race prediction formula that runs alongside the existing Riegel predictor (<code class="language-plaintext highlighter-rouge">T2 = T1 × (D2/D1)^1.06</code>). Cameron tends to be more conservative than Riegel for shorter base distances, which matters when you’re predicting a marathon from a 5K time. Having both lets you compare and pick the more realistic estimate for your training context.</p>

<p><strong>TrackCalculator</strong> — GPS math: Haversine distance between coordinate pairs, cumulative elevation gain, and per-km splits from an array of raw trackpoints. Pure calculation — no map rendering, no file parsing. The module receives coordinates and returns numbers; the site handles everything else.</p>

<p><strong>Vo2maxEstimator</strong> — Daniels &amp; Gilbert formula. Given a race time and distance, returns an estimated VO2max and a fitness classification (from “Poor” to “Elite”). The gem API convention applies here: inputs use symbols (<code class="language-plaintext highlighter-rouge">:km</code>, <code class="language-plaintext highlighter-rouge">:mi</code>), outputs are always strings — never symbols.</p>

<p>The GPS flow on the site: when a user uploads a GPX file, Active Storage saves it to Cloudflare R2 and enqueues <code class="language-plaintext highlighter-rouge">GpxParseJob</code> to Sidekiq. The job runs <code class="language-plaintext highlighter-rouge">GpxParser</code>, which extracts trackpoints (lat, lon, elevation, time), then calls <code class="language-plaintext highlighter-rouge">TrackCalculator</code> from the gem to compute distance, elevation gain, and splits. Trackpoints are persisted and the activity is updated with the computed stats. The map renders with Leaflet.js and OpenStreetMap tiles — no API key, no vendor lock-in, no usage limits.</p>

<p>Moving from invite-only to open registration meant adding real bot protection. The approach: Cloudflare Turnstile on the registration form, Rack::Attack for rate limiting at the Rails level. Turnstile was straightforward to integrate, with two gotchas that cost more time than expected. The widget script URL uses <code class="language-plaintext highlighter-rouge">/v0/</code>, not <code class="language-plaintext highlighter-rouge">/v1/</code> — the docs aren’t always consistent about this. And the form param is <code class="language-plaintext highlighter-rouge">cf-turnstile-response</code> (hyphens), not <code class="language-plaintext highlighter-rouge">cf_turnstile_response</code> (underscores) — Rails’ <code class="language-plaintext highlighter-rouge">params</code> hash keeps hyphens, so the usual convention doesn’t apply. There’s also a pending issue: when the registration form reloads after a validation error, Turbo replaces the DOM but doesn’t re-initialize the widget, so the next submission fails silently. The fix is re-initializing after a Turbo render — still pending.</p>

<p>For file storage, Active Storage with Cloudflare R2 as the backend handles avatars, activity photos, and GPX files. S3-compatible, free egress within Cloudflare’s network, no CDN configuration needed.</p>

<p>Rails 8 ships <code class="language-plaintext highlighter-rouge">generates_token_for</code> as a first-class model API for signed, expiring tokens. The email verification flow uses it: on registration a token is generated with 48h expiry, the verification email includes a signed URL, and unverified accounts are blocked from logging in with a resend option shown. The verification email is HTML now — styled with a button and a copyable fallback link. Before this it was plain text, which looked unfinished for an open product. Transactional emails go through Resend API; the password reset flow was already using it and verification plugged into the same setup.</p>

<p>Every user gets a public profile at <code class="language-plaintext highlighter-rouge">calcpace.app/:username</code> — avatar, bio, city, country, and activity feed. Each activity has an individual <code class="language-plaintext highlighter-rouge">public</code> boolean: toggle it on the activity form and it appears or disappears from the public feed immediately. Activities can also have a photo attached. Your profile page only shows what you choose to share.</p>

<p>The clearest architectural decision in this project: the gem does pure calculation, the site does I/O. No file parsing in the gem, no HTTP, no database. This makes the gem testable in isolation (Minitest, no Rails required) and keeps the site thin — controllers call gem methods and persist results, nothing more. The delivery sequence is always: implement module in gem → write tests → publish new version → update <code class="language-plaintext highlighter-rouge">Gemfile</code> in site → build the UI on top. GitHub Actions publishes to RubyGems automatically on version bump in <code class="language-plaintext highlighter-rouge">lib/calcpace/version.rb</code>.</p>

<p>What’s next: internationalization first — the app is English-only for now, but the i18n infrastructure is already in place. Adding PT-BR, Spanish, German, and French means auditing hardcoded strings, migrating <code class="language-plaintext highlighter-rouge">GpxParser</code> error messages to i18n keys, and translating the emails. Then content pages: pace conversion tables, race equivalent tables, Boston qualifying times, world records. Mile splits in the activity view are also coming — the gem already supports it via <code class="language-plaintext highlighter-rouge">track_splits(points, 1.609)</code>, the UI just needs a toggle. Strava and Garmin direct connection is planned — GPX import already works for the manual export flow, the next step is OAuth2 for automatic sync.</p>

<ul>
  <li><a href="https://calcpace.app">calcpace.app</a></li>
  <li><a href="https://rubygems.org/gems/calcpace">calcpace gem on RubyGems</a></li>
  <li><a href="https://github.com/0jonjo/calcpace_web">calcpace_web on GitHub</a></li>
</ul>

<p><img width="1024" height="683" alt="image" src="https://github.com/user-attachments/assets/da5c62bd-2290-4118-99a4-ddf27140c870" /></p>

<blockquote>
  <p>Image: <a href="https://openverse.org/image/c512f324-473b-4a6c-b769-0a993b9aa445">“BP Running Track @ Glasgow Airport”</a> by JCDecaux Creative Solutions. <a href="https://creativecommons.org/licenses/by-nc-nd/2.0/">Open Verse, Creative Commons BY-NC-ND 2.0</a></p>
</blockquote>]]></content><author><name>João Gilberto Saraiva</name></author><category term="misc" /><category term="programming" /><category term="ruby" /><summary type="html"><![CDATA[From closed beta to open registration: new gem modules, GPS tracking with maps, bot protection, and the infrastructure decisions behind each feature.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://github.com/user-attachments/assets/da5c62bd-2290-4118-99a4-ddf27140c870" /><media:content medium="image" url="https://github.com/user-attachments/assets/da5c62bd-2290-4118-99a4-ddf27140c870" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Calcpace Web: the calculator now in the browser</title><link href="https://0jonjo.github.io/blog/2026/calpace-web/" rel="alternate" type="text/html" title="Calcpace Web: the calculator now in the browser" /><published>2026-03-26T00:00:00+00:00</published><updated>2026-03-26T00:00:00+00:00</updated><id>https://0jonjo.github.io/blog/2026/calpace-web</id><content type="html" xml:base="https://0jonjo.github.io/blog/2026/calpace-web/"><![CDATA[<p>The <code class="language-plaintext highlighter-rouge">calcpace</code> gem just hit 7,000 downloads, so I celebrated the best way I know how: I ran a 5k and finally built <a href="https://calcpace.app">calcpace.app</a> to bring those calculations to everyone, no Ruby console required.</p>

<p>The core logic for this project has actually been brewing in my head for about five years. Combining my daily routine as a runner with the logic I apply professionally when building delivery and routing systems, it just made perfect sense to model pace, distance, and time mathematically. I eventually sat down and started writing the core Ruby code four years ago, which evolved into the open-source gem.</p>

<p>When the gem crossed the 7k mark, I realized it was time to make it accessible to non-programmers.</p>

<p>The app provides a suite of free running tools: pace, speed, finish time, split breakdowns, and a race predictor using both the Riegel and Cameron formulas. The converter covers 30 combinations across distance, speed, and pace—km, mi, meters, yards, feet, knots—all easily switchable between metric and imperial systems.</p>

<p>I didn’t want it to be just another “enter your numbers” calculator. There are dedicated guide pages explaining the math behind each formula. For instance, you can read about why the Cameron predictor tends to be more conservative than Riegel for shorter base distances, and what that actually means for your training blocks.</p>

<p>From planning to writing the code and setting up the infrastructure, the web version took me about four days. I used this as an opportunity to build with a modern, pragmatic stack:</p>

<ul>
  <li><strong>Ruby on Rails 8:</strong> The foundation of the app. It provides a solid structure and allows me to practice “dogfooding” by consuming my own gem in a production environment. The gem does all the heavy lifting for the calculations, keeping the Rails controllers incredibly clean.</li>
  <li><strong>The Basecamp Way:</strong> I treated this project as a playground to strictly follow 37signals’ design principles. That means pushing the logic down to rich models, keeping controllers thin, and leveraging <code class="language-plaintext highlighter-rouge">Current</code> attributes to handle global state cleanly.</li>
  <li><strong>Tailwind CSS:</strong> To keep my focus on the backend and infrastructure, I used Tailwind to rapidly build a clean, responsive interface without writing custom CSS files.</li>
  <li><strong>Testing &amp; CI/CD:</strong> Since this serves as a live environment for the gem, reliability is key. The app is fully tested with Minitest, and GitHub Actions runs the CI/CD pipeline on every push to <code class="language-plaintext highlighter-rouge">main</code>.</li>
  <li><strong>Kamal &amp; VPS:</strong> I wanted to step away from traditional, expensive PaaS solutions. I deployed the application using Kamal, which packages the Rails app into Docker containers and handles zero-downtime deployments directly to a DigitalOcean Virtual Private Server (VPS).</li>
</ul>

<h3 id="take-it-for-a-spin">Take it for a spin</h3>

<p>While the personal activity tracker (run/ride logging) is technically in closed testing, I built a sandbox and share with some friends so they can explore the UI and see the gem working in a real database environment.</p>

<p>To keep things tidy, a <code class="language-plaintext highlighter-rouge">GuestResetJob</code> runs via <code class="language-plaintext highlighter-rouge">sidekiq-cron</code> to wipe the guest activities and recreate the sample profile every Monday at 3 AM.</p>

<h3 id="whats-next">What’s Next</h3>

<p>The engine is running smoothly, but there’s more to come. I am currently working on adding VO2max, VDOT, and training zone calculations—these will be introduced as new modules in the gem and will get their own guide pages on the site.</p>

<p>Links:</p>
<ul>
  <li><a href="https://calcpace.app">calcpace.app</a></li>
  <li><a href="https://rubygems.org/gems/calcpace">calcpace gem</a></li>
  <li><a href="https://github.com/0jonjo/calcpace_web">GitHub repo</a></li>
</ul>

<p><img width="1535" height="932" alt="image" src="https://github.com/user-attachments/assets/45e56e58-7039-4121-af7d-c6ab846c2045" /></p>]]></content><author><name>João Gilberto Saraiva</name></author><category term="misc" /><category term="programming" /><category term="ruby" /><summary type="html"><![CDATA[The calcpace gem just hit 7,000 downloads, so I ran a 5k and built calcpace.app to bring those calculations to everyone, no Ruby required.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://github.com/user-attachments/assets/45e56e58-7039-4121-af7d-c6ab846c2045" /><media:content medium="image" url="https://github.com/user-attachments/assets/45e56e58-7039-4121-af7d-c6ab846c2045" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Building Semantic Search with AI and Vector Embedding in Rails</title><link href="https://0jonjo.github.io/blog/2026/embbendings-ai/" rel="alternate" type="text/html" title="Building Semantic Search with AI and Vector Embedding in Rails" /><published>2026-02-26T00:00:00+00:00</published><updated>2026-02-26T00:00:00+00:00</updated><id>https://0jonjo.github.io/blog/2026/embbendings-ai</id><content type="html" xml:base="https://0jonjo.github.io/blog/2026/embbendings-ai/"><![CDATA[<p>This month, I wrote an article on building semantic search with vector embeddings in Ruby using the ruby_llm gem and PostgreSQL’s pgvector extension to <a href="https://jetrockets.com/blog/building-semantic-search-with-ai-and-vector-embedding-in-rails">JetRockets blog</a>. Here is the full article:</p>

<p>Traditional keyword search is fundamentally limited—it can only match exact words or their basic variations. Search for “customer pain points” and you’ll miss documents titled “User Frustrations” or “Client Challenges,” even though they’re semantically identical. This limitation becomes critical when managing large document repositories where users need to find information based on meaning, not memorized keywords. The solution is semantic search using vector embeddings, which represent text as mathematical vectors that capture conceptual similarity. In previous articles, we explored how ruby_llm simplifies working with AI providers for <a href="https://jetrockets.com/blog/building-intelligent-ai-agents-with-function-calling-in-ruby">function calling</a> and <a href="https://jetrockets.com/blog/building-a-resilient-ai-client-in-ruby-with-stoplight-and-ruby_llm">resilient architectures</a>. Now we’ll leverage ruby_llm’s embedding capabilities to build semantic search with PostgreSQL’s pgvector extension—creating a system that finds “churn analysis” when users search for “why customers leave.”</p>

<h2 id="what-are-vector-embeddings">What Are Vector Embeddings?</h2>

<p>Think of vector embeddings as a way to translate text into the language of mathematics—or as some describe it, “searching by vibes” rather than exact keywords. Each piece of text becomes an array of numbers (a “vector”) where similar meanings produce similar numbers. The embedding model has learned, through training on billions of texts, that certain concepts cluster together in this mathematical space.</p>

<p>We’ll use OpenAI’s <code class="language-plaintext highlighter-rouge">text-embedding-3-small</code> (1,536 dimensions) for its solid performance-to-cost ratio, but ruby_llm also supports other models: Gemini’s <code class="language-plaintext highlighter-rouge">text-embedding-004</code> (768 dimensions), Voyage AI’s models, or even local alternatives like <code class="language-plaintext highlighter-rouge">all-MiniLM-L6-v2</code> via Ollama for privacy-sensitive applications. The choice depends on your budget, latency requirements, and whether you prefer cloud or self-hosted solutions.</p>

<p>For example:</p>
<ul>
  <li>“customer churn analysis” → <code class="language-plaintext highlighter-rouge">[0.234, -0.891, 0.456, ...]</code></li>
  <li>“user retention study” → <code class="language-plaintext highlighter-rouge">[0.221, -0.883, 0.449, ...]</code> (very close!)</li>
  <li>“quarterly revenue report” → <code class="language-plaintext highlighter-rouge">[-0.678, 0.234, -0.123, ...]</code> (very different)</li>
</ul>

<p>When a user searches for “why are customers leaving”, the system converts this query into a vector and finds documents with similar vectors—automatically surfacing reports about “churn factors” and “cancellation reasons” without requiring exact keyword matches.</p>

<h2 id="setting-up-ruby_llm-for-embeddings">Setting Up ruby_llm for Embeddings</h2>

<p>Just as we used ruby_llm for function calling in our previous article, we can use it for embeddings too. The gem provides a unified interface with consistent error handling and automatic retries:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/ruby_llm.rb</span>
<span class="nb">require</span> <span class="s2">"ruby_llm"</span>

<span class="no">RubyLLM</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">openai_api_key</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">credentials</span><span class="p">.</span><span class="nf">dig</span><span class="p">(</span><span class="ss">:openai</span><span class="p">,</span> <span class="ss">:api_key</span><span class="p">)</span>
<span class="k">end</span>

<span class="c1"># Create a reusable client instance</span>
<span class="no">LLM_CLIENT</span> <span class="o">=</span> <span class="no">RubyLLM</span>
</code></pre></div></div>

<p>Now create a wrapper that handles the embedding API calls:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/embedding_service.rb</span>
<span class="k">class</span> <span class="nc">EmbeddingService</span>
  <span class="no">MODEL</span> <span class="o">=</span> <span class="s2">"text-embedding-3-small"</span>
  <span class="no">DIMENSIONS</span> <span class="o">=</span> <span class="mi">1536</span>

  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">embed</span><span class="p">(</span><span class="n">texts</span><span class="p">)</span>
    <span class="n">texts</span> <span class="o">=</span> <span class="no">Array</span><span class="p">(</span><span class="n">texts</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">[]</span> <span class="k">if</span> <span class="n">texts</span><span class="p">.</span><span class="nf">empty?</span>

    <span class="c1"># ruby_llm handles the API call automatically</span>
    <span class="n">result</span> <span class="o">=</span> <span class="no">LLM_CLIENT</span><span class="p">.</span><span class="nf">embed</span><span class="p">(</span><span class="n">texts</span><span class="p">,</span> <span class="ss">model: </span><span class="no">MODEL</span><span class="p">,</span> <span class="ss">dimensions: </span><span class="no">DIMENSIONS</span><span class="p">)</span>
    <span class="n">result</span><span class="p">.</span><span class="nf">vectors</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>That’s it! Using ruby_llm gives us the same benefits we saw in the function calling article: provider abstraction, automatic error handling, and consistent behavior across different AI services.</p>

<h2 id="setting-up-pgvector-for-storage">Setting Up pgvector for Storage</h2>

<p>Why pgvector? If you’re already running PostgreSQL (and many Rails apps are), pgvector lets you store and search vectors without adding new infrastructure. No separate vector database to maintain, no data synchronization issues, and transactions work normally—your embeddings live right next to your application data. The <a href="https://github.com/pgvector/pgvector">pgvector extension</a> adds specialized vector types and HNSW indexing for sub-100ms similarity searches across millions of vectors.</p>

<p>Before storing embeddings, enable the extension:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/create_documents.rb</span>
<span class="k">class</span> <span class="nc">CreateDocuments</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">8.0</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">enable_extension</span> <span class="s2">"vector"</span>

    <span class="n">create_table</span> <span class="ss">:documents</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">text</span> <span class="ss">:content</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
    <span class="k">end</span>

    <span class="n">create_table</span> <span class="ss">:document_chunks</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:document</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">integer</span> <span class="ss">:chunk_index</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">text</span> <span class="ss">:content</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">vector</span> <span class="ss">:embedding</span><span class="p">,</span> <span class="ss">limit: </span><span class="mi">1536</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
    <span class="k">end</span>

    <span class="c1"># HNSW index for fast nearest-neighbor search</span>
    <span class="n">add_index</span> <span class="ss">:document_chunks</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">,</span> <span class="ss">using: :hnsw</span><span class="p">,</span> <span class="ss">opclass: :vector_l2_ops</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The HNSW (Hierarchical Navigable Small World) index creates a graph structure that allows fast approximate nearest-neighbor search—essential for sub-100ms queries across thousands of vectors.</p>

<h2 id="basic-pattern-chunking-documents">Basic Pattern: Chunking Documents</h2>

<p>Here’s the challenge: documents can be very long, but embedding models work best on focused text segments (500-2000 characters). Embed an entire 50-page document and you’ll get a diluted embedding that doesn’t capture nuances. The solution is chunking with overlap. The overlap (typically 10-20%) prevents losing context at boundaries:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/document.rb</span>
<span class="k">class</span> <span class="nc">Document</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="no">CHUNK_SIZE</span> <span class="o">=</span> <span class="mi">1_000</span>
  <span class="no">OVERLAP_SIZE</span> <span class="o">=</span> <span class="mi">200</span>

  <span class="n">has_many</span> <span class="ss">:document_chunks</span><span class="p">,</span> <span class="ss">dependent: :destroy</span>

  <span class="k">def</span> <span class="nf">generate_chunks!</span>
    <span class="n">step</span> <span class="o">=</span> <span class="no">CHUNK_SIZE</span> <span class="o">-</span> <span class="no">OVERLAP_SIZE</span>
    <span class="n">chunks</span> <span class="o">=</span> <span class="p">[]</span>

    <span class="c1"># Split content into overlapping chunks</span>
    <span class="mi">0</span><span class="p">.</span><span class="nf">step</span><span class="p">(</span><span class="n">content</span><span class="p">.</span><span class="nf">length</span> <span class="o">-</span> <span class="mi">1</span><span class="p">,</span> <span class="n">step</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">offset</span><span class="o">|</span>
      <span class="n">chunk</span> <span class="o">=</span> <span class="n">content</span><span class="p">[</span><span class="n">offset</span><span class="p">,</span> <span class="no">CHUNK_SIZE</span><span class="p">]</span>
      <span class="n">chunks</span> <span class="o">&lt;&lt;</span> <span class="n">chunk</span> <span class="k">if</span> <span class="n">chunk</span><span class="p">.</span><span class="nf">present?</span>
    <span class="k">end</span>

    <span class="k">return</span> <span class="k">if</span> <span class="n">chunks</span><span class="p">.</span><span class="nf">empty?</span>

    <span class="c1"># Generate embeddings for all chunks in one call</span>
    <span class="n">embeddings</span> <span class="o">=</span> <span class="no">EmbeddingService</span><span class="p">.</span><span class="nf">embed</span><span class="p">(</span><span class="n">chunks</span><span class="p">)</span>

    <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span><span class="p">.</span><span class="nf">transaction</span> <span class="k">do</span>
      <span class="n">document_chunks</span><span class="p">.</span><span class="nf">destroy_all</span>

      <span class="n">chunks</span><span class="p">.</span><span class="nf">zip</span><span class="p">(</span><span class="n">embeddings</span><span class="p">).</span><span class="nf">each_with_index</span> <span class="k">do</span> <span class="o">|</span><span class="p">(</span><span class="n">chunk</span><span class="p">,</span> <span class="n">embedding</span><span class="p">),</span> <span class="n">index</span><span class="o">|</span>
        <span class="n">document_chunks</span><span class="p">.</span><span class="nf">create!</span><span class="p">(</span>
          <span class="ss">chunk_index: </span><span class="n">index</span><span class="p">,</span>
          <span class="ss">content: </span><span class="n">chunk</span><span class="p">,</span>
          <span class="ss">embedding: </span><span class="n">embedding</span>
        <span class="p">)</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Notice how we generate embeddings for <em>all</em> chunks in a single call. Instead of making 50 separate API requests for a 50-chunk document, we make just one. The ruby_llm gem handles the batching automatically, just like it does with function calling.</p>

<p>The character-based approach above works, but cutting mid-sentence degrades semantic meaning. A better strategy splits on sentence boundaries using <code class="language-plaintext highlighter-rouge">text.scan(/[^.!?]+[.!?](?:\s+|$)/)</code>, accumulating sentences until reaching the size limit. This preserves complete thoughts while maintaining consistent chunk sizes—particularly valuable for technical documentation where sentence context matters.</p>

<h2 id="advanced-pattern-hybrid-scoring-semantic--temporal">Advanced Pattern: Hybrid Scoring (Semantic + Temporal)</h2>

<p>Pure semantic search has a problem: outdated documents with perfect semantic matches outrank recent documents with good matches. When users search for “current market size”, they might get a 2023 report instead of the fresh 2025 one. The solution is hybrid scoring—combine semantic similarity (70%) with recency (30%):</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/document_chunk.rb</span>
<span class="k">class</span> <span class="nc">DocumentChunk</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:document</span>
  <span class="n">has_neighbors</span> <span class="ss">:embedding</span><span class="p">,</span> <span class="ss">dimensions: </span><span class="mi">1536</span>

  <span class="nb">attr_accessor</span> <span class="ss">:search_score</span>

  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">search_by_semantics</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">user</span><span class="p">:,</span> <span class="ss">limit: </span><span class="mi">20</span><span class="p">,</span> <span class="ss">threshold: </span><span class="mf">0.8</span><span class="p">)</span>
    <span class="c1"># Convert query to embedding using ruby_llm</span>
    <span class="n">query_embedding</span> <span class="o">=</span> <span class="no">EmbeddingService</span><span class="p">.</span><span class="nf">embed</span><span class="p">(</span><span class="n">query</span><span class="p">).</span><span class="nf">first</span>

    <span class="c1"># Get more candidates than needed for scoring</span>
    <span class="n">neighbors</span> <span class="o">=</span> <span class="n">joins</span><span class="p">(</span><span class="ss">:document</span><span class="p">)</span>
                  <span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">documents: </span><span class="p">{</span> <span class="ss">user: </span><span class="n">user</span> <span class="p">})</span>
                  <span class="p">.</span><span class="nf">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">query_embedding</span><span class="p">,</span> <span class="ss">distance: </span><span class="s2">"cosine"</span><span class="p">)</span>
                  <span class="p">.</span><span class="nf">limit</span><span class="p">(</span><span class="n">limit</span> <span class="o">*</span> <span class="mi">5</span><span class="p">)</span>

    <span class="c1"># Calculate hybrid scores</span>
    <span class="n">scored_neighbors</span> <span class="o">=</span> <span class="n">neighbors</span><span class="p">.</span><span class="nf">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">chunk</span><span class="o">|</span>
      <span class="n">semantic_similarity</span> <span class="o">=</span> <span class="mi">1</span> <span class="o">-</span> <span class="p">(</span><span class="n">chunk</span><span class="p">.</span><span class="nf">neighbor_distance</span> <span class="o">/</span> <span class="mf">2.0</span><span class="p">)</span>
      <span class="n">recency_score</span> <span class="o">=</span> <span class="n">calculate_recency_score</span><span class="p">(</span><span class="n">chunk</span><span class="p">.</span><span class="nf">created_at</span><span class="p">)</span>

      <span class="c1"># 70% semantic, 30% recency</span>
      <span class="n">chunk</span><span class="p">.</span><span class="nf">search_score</span> <span class="o">=</span> <span class="p">(</span><span class="n">semantic_similarity</span> <span class="o">*</span> <span class="mf">0.7</span><span class="p">)</span> <span class="o">+</span> <span class="p">(</span><span class="n">recency_score</span> <span class="o">*</span> <span class="mf">0.3</span><span class="p">)</span>
      <span class="n">chunk</span>
    <span class="k">end</span>

    <span class="c1"># Filter and return top results</span>
    <span class="n">scored_neighbors</span>
      <span class="p">.</span><span class="nf">select</span> <span class="p">{</span> <span class="o">|</span><span class="n">c</span><span class="o">|</span> <span class="n">c</span><span class="p">.</span><span class="nf">neighbor_distance</span> <span class="o">&lt;</span> <span class="n">threshold</span> <span class="p">}</span>
      <span class="p">.</span><span class="nf">sort_by</span> <span class="p">{</span> <span class="o">|</span><span class="n">c</span><span class="o">|</span> <span class="o">-</span><span class="n">c</span><span class="p">.</span><span class="nf">search_score</span> <span class="p">}</span>
      <span class="p">.</span><span class="nf">first</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">calculate_recency_score</span><span class="p">(</span><span class="n">created_at</span><span class="p">)</span>
    <span class="n">age_in_days</span> <span class="o">=</span> <span class="p">(</span><span class="no">Time</span><span class="p">.</span><span class="nf">current</span> <span class="o">-</span> <span class="n">created_at</span><span class="p">)</span> <span class="o">/</span> <span class="mi">1</span><span class="p">.</span><span class="nf">day</span>
    <span class="k">return</span> <span class="mf">1.0</span> <span class="k">if</span> <span class="n">age_in_days</span> <span class="o">&lt;=</span> <span class="mi">30</span>  <span class="c1"># Recent: full score</span>
    <span class="k">return</span> <span class="mf">0.7</span> <span class="k">if</span> <span class="n">age_in_days</span> <span class="o">&gt;</span> <span class="mi">365</span>  <span class="c1"># Old: 30% penalty</span>
    <span class="mf">1.0</span> <span class="o">-</span> <span class="p">((</span><span class="n">age_in_days</span> <span class="o">-</span> <span class="mi">30</span><span class="p">)</span> <span class="o">/</span> <span class="mf">335.0</span><span class="p">)</span> <span class="o">*</span> <span class="mf">0.3</span>  <span class="c1"># Linear decay</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The key insight: we fetch <code class="language-plaintext highlighter-rouge">limit * 5</code> candidates first, then score and filter. Why? A chunk might be the 50th-best semantic match but jump into the top 5 after adding recency. By casting a wider net initially, we don’t miss valuable recent documents.</p>

<h2 id="putting-it-all-together">Putting It All Together</h2>

<p>Now let’s see the complete flow from upload to search:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">DocumentsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">create</span>
    <span class="n">document</span> <span class="o">=</span> <span class="n">current_user</span><span class="p">.</span><span class="nf">documents</span><span class="p">.</span><span class="nf">create!</span><span class="p">(</span>
      <span class="ss">title: </span><span class="n">params</span><span class="p">[</span><span class="ss">:title</span><span class="p">],</span>
      <span class="ss">content: </span><span class="n">params</span><span class="p">[</span><span class="ss">:content</span><span class="p">]</span>  <span class="c1"># or extract from uploaded file</span>
    <span class="p">)</span>

    <span class="c1"># Generate chunks and embeddings asynchronously</span>
    <span class="no">GenerateEmbeddingsJob</span><span class="p">.</span><span class="nf">perform_later</span><span class="p">(</span><span class="n">document</span><span class="p">.</span><span class="nf">id</span><span class="p">)</span>

    <span class="n">render</span> <span class="ss">json: </span><span class="p">{</span> <span class="ss">id: </span><span class="n">document</span><span class="p">.</span><span class="nf">id</span><span class="p">,</span> <span class="ss">status: </span><span class="s2">"processing"</span> <span class="p">}</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">search</span>
    <span class="n">results</span> <span class="o">=</span> <span class="no">DocumentChunk</span><span class="p">.</span><span class="nf">search_by_semantics</span><span class="p">(</span>
      <span class="n">params</span><span class="p">[</span><span class="ss">:query</span><span class="p">],</span>
      <span class="ss">user: </span><span class="n">current_user</span><span class="p">,</span>
      <span class="ss">limit: </span><span class="mi">10</span><span class="p">,</span>
      <span class="ss">threshold: </span><span class="mf">0.75</span>
    <span class="p">)</span>

    <span class="n">render</span> <span class="ss">json: </span><span class="p">{</span>
      <span class="ss">query: </span><span class="n">params</span><span class="p">[</span><span class="ss">:query</span><span class="p">],</span>
      <span class="ss">results: </span><span class="n">results</span><span class="p">.</span><span class="nf">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">chunk</span><span class="o">|</span>
        <span class="p">{</span>
          <span class="ss">document_title: </span><span class="n">chunk</span><span class="p">.</span><span class="nf">document</span><span class="p">.</span><span class="nf">title</span><span class="p">,</span>
          <span class="ss">excerpt: </span><span class="n">chunk</span><span class="p">.</span><span class="nf">content</span><span class="p">[</span><span class="mi">0</span><span class="o">..</span><span class="mi">300</span><span class="p">],</span>
          <span class="ss">relevance_score: </span><span class="n">chunk</span><span class="p">.</span><span class="nf">search_score</span><span class="p">.</span><span class="nf">round</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>
        <span class="p">}</span>
      <span class="k">end</span>
    <span class="p">}</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The architecture is completely asynchronous where it matters. Document uploads don’t block waiting for embeddings—users get immediate feedback, and embeddings are generated in the background. Meanwhile, searches are fast (typically under 100ms) because pgvector’s HNSW index does the heavy lifting.</p>

<h2 id="advantages-of-this-approach">Advantages of This Approach</h2>

<p>The benefits go far beyond “better search.” The system demonstrates <strong>semantic understanding</strong>—users can search for “why customers switch providers” and find reports titled “Competitive Migration Patterns,” even though those exact words don’t appear in the query.</p>

<p>From an engineering perspective, the architecture is production-ready: <strong>context preservation</strong> through overlapping chunks means no information is lost at boundaries, <strong>temporal awareness</strong> via hybrid scoring ensures recent insights aren’t buried, and <strong>ruby_llm</strong> provides the same advantages we explored in previous articles—automatic retry logic, consistent error handling, and clean abstractions for AI interactions.</p>

<h2 id="when-to-use-semantic-search">When to Use Semantic Search</h2>

<p>Semantic search shines when users need to find documents by meaning rather than exact keywords—scenarios like customer support knowledge bases, research archives, or legal document discovery where synonyms and conceptual similarity matter. It’s especially valuable powering AI features: chatbots citing documents, assistants surfacing relevant research, or agents (using ruby_llm’s function calling) that search intelligently.</p>

<p>However, skip it for small document sets (&lt;100 documents) with well-structured content, cases requiring exact phrase matching (legal contracts), or real-time updated content where the 1-2 second embedding latency is problematic. Traditional keyword search with proper indexing often suffices for simpler use cases.</p>

<h2 id="best-practices">Best Practices</h2>

<p>Through implementing semantic search systems, several best practices have emerged:</p>

<p><strong>Always Use Overlapping Chunks:</strong> The 200-character overlap prevents context loss at boundaries. Resist the temptation to eliminate overlap to save on storage—it’s a false economy that degrades search quality.</p>

<p><strong>Batch Your Embeddings:</strong> Generate embeddings for all chunks in a single call to <code class="language-plaintext highlighter-rouge">EmbeddingService.embed(chunks)</code>. Process 20-chunk documents with one request instead of 20, reducing latency by 95% and API costs proportionally.</p>

<p><strong>Tune Thresholds Per Use Case:</strong> The 0.8 default threshold works for most cases, but use 0.85 for mission-critical searches (where precision matters) and 0.75 for exploratory searches (where recall matters). Monitor your analytics and adjust accordingly.</p>

<p><strong>Async Everything That Can Be Async:</strong> Never block user requests waiting for embeddings. Generate them in background jobs and show a “processing” indicator. Users can continue working while documents are indexed.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Transitioning from keyword-based to semantic search transforms how users interact with your data, and as we’ve seen, it doesn’t require adopting entirely new infrastructure. By keeping our architecture resilient with PostgreSQL and pgvector, we avoided the overhead of maintaining a separate vector database. We then used ruby_llm to abstract the complexity of interacting with AI providers, ensuring our API calls are batched and fault-tolerant.</p>

<p>Finally, we solved the real-world UX challenges of AI search by implementing overlapping text chunks to preserve context, and hybrid scoring to balance semantic accuracy with temporal relevance. The magic of modern AI tools in the Rails ecosystem is that they allow us to build highly sophisticated features—like “searching by vibes”—while relying on the same pragmatic, robust engineering principles we use every day.</p>

<h2 id="additional-resources">Additional Resources</h2>

<ul>
  <li>📄 <a href="https://simonwillison.net/2023/Oct/23/embeddings/">Embeddings: What they are and why they matter</a> by Simon Willison</li>
  <li>📄 <a href="https://vickiboykis.com/what_are_embeddings/">What are embeddings?</a> by Vicki Boykis</li>
  <li>📁 <a href="https://github.com/pgvector/pgvector">pgvector GitHub Repository</a></li>
  <li>📁 <a href="https://github.com/ankane/neighbor">neighbor gem for Rails</a></li>
  <li>📁 <a href="https://github.com/crmne/ruby_llm">ruby_llm gem on GitHub</a></li>
</ul>

<p>Image: “Word Search” by Morgan Frederick. <a href="https://openverse.org/image/79fc1531-5557-43e8-8314-0ef15b6c36b9">Open Verse, Creative Commons 2.0</a></p>]]></content><author><name>João Gilberto Saraiva</name></author><category term="misc" /><category term="programming" /><category term="ruby" /><category term="ai" /><summary type="html"><![CDATA[Creating semantic search with vector embeddings in Ruby using the ruby_llm gem and PostgreSQL's pgvector extension.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://github.com/user-attachments/assets/22ee97d1-ed97-4db9-8bbb-700d9c94f83d" /><media:content medium="image" url="https://github.com/user-attachments/assets/22ee97d1-ed97-4db9-8bbb-700d9c94f83d" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="pt"><title type="html">The Due Diligence Checklist: a compass for engineering autonomy</title><link href="https://0jonjo.github.io/blog/2026/duo-diligence-checklist/" rel="alternate" type="text/html" title="The Due Diligence Checklist: a compass for engineering autonomy" /><published>2026-01-31T00:00:00+00:00</published><updated>2026-01-31T00:00:00+00:00</updated><id>https://0jonjo.github.io/blog/2026/duo-diligence-checklist</id><content type="html" xml:base="https://0jonjo.github.io/blog/2026/duo-diligence-checklist/"><![CDATA[<p>Over the last few years, I have been synthesizing a personal checklist for software development. It is a fusion of direct feedback from Tech Leads, observations of teams, and operational playbooks — especially from <a href="https://www.linkedin.com/company/jetrockets/">JetRockets</a> — combined with my own background as a professor and academic researcher. This combination of experiences allowed me to solidify a mini framework</p>

<p>Originating in law and business, “Due Diligence” refers to the comprehensive appraisal of a business or situation prior to signing a contract. It is the rigorous investigation required to ensure that all parties know exactly what they are getting into, minimizing risks and surprises. In an era where AI can plan, code, and review faster than ever, our responsibility to verify increases. We must be prepared to take full ownership of the results and evaluate precisely what we are delivering.</p>

<p>In Software Engineering, we can apply this concept to the daily “contracts” we make: the code we ship to production and the questions we ask our colleagues. It is about applying rigor, context, and documentation to the acts of coding and asking.</p>

<p>To ensure that we are not just “task-completers,” but intentional engineers, I propose applying this personal audit before every task.</p>

<h2 id="phase-1-the-build-checklist-coding-with-context">Phase 1: The Build Checklist (Coding with Context)</h2>

<p>Before writing a single line of code, understand exactly what you are getting into.</p>

<h4 id="the-why">The “Why”</h4>

<p>[ ] Business Goal: Do I truly understand the user problem or business value this task resolves? If I can’t explain why a feature exists, I am not ready to code it.</p>

<h4 id="software-archaeology">Software Archaeology</h4>

<p>[ ] Pattern Matching: Have I searched for similar features already implemented in the project? Reusing existing patterns ensures consistency.</p>

<p>[ ] External References: If no internal pattern exists, did I research reliable external sources and adapt them to our specific context?</p>

<p>[ ] Architectural Decisions: Have I studied why the current code is written this way? I need to understand the legacy choices before I can introduce new ones.</p>

<h4 id="the-blueprint">The Blueprint</h4>

<p>[ ] Integration: How will the solution plug into the existing project?</p>

<p>[ ] Harmony: Will the approach clash with the codebase’s style? Most of the time goal is for the code to look like it has always belonged there.</p>

<p>💡 The Innovation Caveat: Sometimes, the goal is to break the pattern. Companies hire engineers to think and act, not merely to reproduce—that is how technical diversity and evolution happen. However, swimming against the current requires stronger muscles. If you deviate from the established architecture, your “Due Diligence” must be doubled. Be prepared to defend why the new approach is necessary and superior.</p>

<h4 id="the-self-audit">The Self-Audit</h4>

<p>[ ] Goal Check: Does the solution actually solve the initial problem?</p>

<p>[ ] Quality Control: Is the code clean, documented, and compliant with project standards?</p>

<p>[ ] Monitoring &amp; Safety: How will I detect possible problems in production? Do I have a plan to revert changes if necessary?</p>

<h2 id="phase-2-the-unstuck-checklist-asking-with-purpose">Phase 2: The “Unstuck” Checklist (Asking with Purpose)</h2>

<p>Sometimes, despite our best efforts, we hit a wall. Let’s reflect a little before reaching out for help.</p>

<h4 id="the-solo-mission-before-asking">The Solo Mission (Before Asking)</h4>

<p>[ ] Consult the Documentation: Have I actually read the manual and READMEs?</p>

<p>[ ] The External Search: Have I checked Stack Overflow or forums for similar errors?</p>

<p>[ ] The Git History: Have I run a git blame to see if a recent change caused the issue?</p>

<h4 id="the-evidence-package-formulating-the-question">The Evidence Package (Formulating the Question)</h4>

<p>When reaching out to a peer, you shouldn’t just ask for the solution; you must present your findings.</p>

<p>Contextualize: “It’s not working” is not a question. I must explain the specific gap between expectation and reality. Document the Journey:</p>

<ul>
  <li>“I tried X, hoping for Y, but got Z.”</li>
  <li>“I looked at the docs for library A, but they seem outdated.”</li>
</ul>

<p>By documenting the effort, we prove respect for the team’s time. You are not asking them to do the work; you are asking them to unblock the specific obstacle you couldn’t clear yourself.</p>

<h2 id="the-result-a-compass-not-a-cage">The Result: A Compass, Not a Cage</h2>

<p>In the reality of our daily work—filled with calls, tight deadlines, and urgent bug fixes—I don’t religiously check every box for every single commit. That would be paralyzing.</p>

<p>Instead, I keep this checklist within eyesight. It serves as my anchor.</p>

<p>When everything is flowing, I might skip a step. But when I feel stuck, when the solution isn’t clear, or when I simply run out of ideas, I return to these lists. They force me to pause, breathe, and realign with the process. The idea follows a simple principle: autonomy isn’t about being perfect all the time; it’s about knowing where to look when you get lost.</p>

<p><img width="1024" height="683" alt="image" src="https://github.com/user-attachments/assets/3d0f48df-5cea-4130-8dcc-81d4cf234e89" /></p>

<p>Image: “Compass” by Johannes Ko. <a href="https://openverse.org/image/6dbecb6a-a18a-4f0e-ad33-b8d13da98753">Open Verse, Creative Commons 2.0</a></p>]]></content><author><name>João Gilberto Saraiva</name></author><category term="misc" /><summary type="html"><![CDATA[A practical framework to move from 'task-completer' to intentional engineer. A personal checklist for coding with context and asking better questions in the AI era]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://github.com/user-attachments/assets/3d0f48df-5cea-4130-8dcc-81d4cf234e89" /><media:content medium="image" url="https://github.com/user-attachments/assets/3d0f48df-5cea-4130-8dcc-81d4cf234e89" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">A City Called Christmas</title><link href="https://0jonjo.github.io/blog/2025/city-christmas/" rel="alternate" type="text/html" title="A City Called Christmas" /><published>2025-12-24T00:00:00+00:00</published><updated>2025-12-24T00:00:00+00:00</updated><id>https://0jonjo.github.io/blog/2025/city-christmas</id><content type="html" xml:base="https://0jonjo.github.io/blog/2025/city-christmas/"><![CDATA[<p>Tomorrow will be Christmas in many parts of the world, but there is a city where it has been Christmas every day since 1599. This is Natal, my hometown, a name that means exactly “Christmas” in Portuguese.</p>

<p>It boasts beautiful beaches, dunes, wind, marvelous food, and a strategic position as one of the closest points in the Americas to Africa and Europe. The area was originally inhabited by the Potiguar indigenous people. Then, during the Age of Discovery, the Portuguese established a fort and a city. After some decades, it passed into Dutch hands before returning to the Portuguese until Brazil’s independence. During World War II, this was considered one of the four most strategic places in the world, hosting the largest airbase for the Allies. However, today is not a day to talk about wars.</p>

<p>Over time, this city has embraced diverse Christmas traditions. The end of the year is the height of Summer in South America, so forget winter clothes and snow; imagine beaches and parties with happy people. But some traditions remain. Natal has a Christmas tree made of light that is 20 meters taller than the Statue of Liberty in New York. It illuminates the night with dynamic, ever-changing colors in a city that has been Christmas every day for the last four centuries.</p>

<!-- Feel free to change the width and height to your desired video size. -->

<div class="embed-container">
  <iframe src="https://www.youtube.com/embed/pUjHjIWPj9Y" width="700" height="480" frameborder="0" allowfullscreen="">
  </iframe>
</div>]]></content><author><name>João Gilberto Saraiva</name></author><category term="misc" /><category term="history" /><summary type="html"><![CDATA[An evocative stroll through Natal’s past and present, where light and tradition turn ordinary days into celebration]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://viagem.cnnbrasil.com.br/wp-content/uploads/sites/5/2025/12/arvore-de-mirassol-em-Natal.jpeg?w=1200&amp;h=900&amp;crop=1" /><media:content medium="image" url="https://viagem.cnnbrasil.com.br/wp-content/uploads/sites/5/2025/12/arvore-de-mirassol-em-Natal.jpeg?w=1200&amp;h=900&amp;crop=1" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Cat vs. Dog: A Machine Learning Experiment</title><link href="https://0jonjo.github.io/blog/2025/cat-dog-machine-learning/" rel="alternate" type="text/html" title="Cat vs. Dog: A Machine Learning Experiment" /><published>2025-12-07T00:00:00+00:00</published><updated>2025-12-07T00:00:00+00:00</updated><id>https://0jonjo.github.io/blog/2025/cat-dog-machine-learning</id><content type="html" xml:base="https://0jonjo.github.io/blog/2025/cat-dog-machine-learning/"><![CDATA[<p>“Cat vs. Dog” is the “Hello World” of Computer Vision. But solving it is one thing; understanding the mathematical and statistical foundations behind the solution is another.</p>

<p>For my final project in IMD3002 - Supervised Machine Learning at <a href="https://pes.imd.ufrn.br/pes/index">Artificial Intelligence program at UFRN</a>, instructed by Prof. João Carlos Xavier Junior, I didn’t just trained models. We build a complete, rigorous scientific framework to test the fundamentals of Machine Learning.</p>

<p>Here is how I broke down the workflow into a series of sequential experiments.</p>

<h3 id="the-objective">The Objective</h3>

<p>The goal was to classify images of specific breeds (Miniature Pinscher/English Setter vs. Birman/Ragdoll) by exercising every fundamental stage of a classic ML pipeline: from raw pixels to statistical validation.</p>

<h3 id="step-1-feature-extraction-beyond-pixels">Step 1: Feature Extraction (Beyond Pixels)</h3>

<p>Raw images are noisy and high-dimensional. To make them digestible for classical algorithms, I implemented two distinct visual descriptors:</p>

<ul>
  <li>HOG (Histogram of Oriented Gradients): To capture the shape and edge structures.</li>
  <li>LBP (Local Binary Patterns): To capture the texture of the fur.</li>
</ul>

<h3 id="step-2-dimensionality-reduction">Step 2: Dimensionality Reduction</h3>

<p>With high-dimensional feature vectors, the “Curse of Dimensionality” becomes a real threat. I applied PCA (Principal Component Analysis) to project the data into a lower-dimensional space, balancing computational efficiency with information retention.</p>

<h3 id="step-3-model-training--tuning">Step 3: Model Training &amp; Tuning</h3>

<p>I implemented and compared five distinct classes of algorithms to see how they handled the visual data:</p>

<ul>
  <li>k-NN: Instance-based learning.</li>
  <li>Naive Bayes: Probabilistic modeling.</li>
  <li>Decision Trees: Rule-based learning.</li>
  <li>MLP (Multi-Layer Perceptron): Neural Networks.</li>
  <li>Ensembles: Random Forest, AdaBoost, Voting, and Stacking.</li>
</ul>

<p>Each model underwent rigorous hyperparameter tuning (GridSearch) to ensure we were comparing the best versions of each.</p>

<h4 id="step-4-statistical-evaluation">Step 4: Statistical Evaluation</h4>

<p>This is where many projects stop, but scientific rigor requires more than just comparing average accuracy. I applied the Friedman Test to determine if the differences in performance were statistically significant and the Nemenyi Post-hoc Test to pinpoint exactly which models outperformed the others.</p>

<h3 id="the-verdict">The Verdict</h3>

<p>The statistical analysis revealed that for this specific dataset, an MLP combined with LBP features offered the best trade-off between predictive power and computational cost, significantly outperforming shape-based approaches (HOG).</p>

<p>You can explore the full step-by-step workflow, organized in sequential Jupyter Notebooks, on my GitHub: <a href="https://github.com/0jonjo/cat_dog_ml">https://github.com/0jonjo/cat_dog_ml</a></p>

<p><img width="1080" height="522" alt="image" src="https://github.com/user-attachments/assets/ecdf1cce-c708-4bf7-966b-a1865aee1480" /></p>

<p>Image: “Ai is… Banner”, Rick Payne and Team. <a href="https://betterimagesofai.org/images?artist=RickPayneandteam&amp;title=Aiis...Banner">Better Images of AI, Creative Commons 4.0</a></p>]]></content><author><name>João Gilberto Saraiva</name></author><category term="misc" /><category term="programming" /><category term="learning" /><category term="ai" /><summary type="html"><![CDATA[A hands‑on pipeline: feature engineering, dimensionality reduction, model tuning and statistical comparison — step‑by‑step notebooks linked]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://github.com/user-attachments/assets/ecdf1cce-c708-4bf7-966b-a1865aee1480" /><media:content medium="image" url="https://github.com/user-attachments/assets/ecdf1cce-c708-4bf7-966b-a1865aee1480" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>