<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yahid Basha</title>
    <description>The latest articles on DEV Community by Yahid Basha (@yahid_basha).</description>
    <link>https://dev.to/yahid_basha</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3898638%2Fcad2d337-e669-4737-bec3-098ed8e806f7.png</url>
      <title>DEV Community: Yahid Basha</title>
      <link>https://dev.to/yahid_basha</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yahid_basha"/>
    <language>en</language>
    <item>
      <title>My model got punished for writing better. I found out three months later, in one query.</title>
      <dc:creator>Yahid Basha</dc:creator>
      <pubDate>Sat, 18 Jul 2026 12:59:22 +0000</pubDate>
      <link>https://dev.to/yahid_basha/my-model-got-punished-for-writing-better-i-found-out-three-months-later-in-one-query-1edg</link>
      <guid>https://dev.to/yahid_basha/my-model-got-punished-for-writing-better-i-found-out-three-months-later-in-one-query-1edg</guid>
      <description>&lt;p&gt;In April I trained a small model to resolve enterprise IT tickets. At 4 AM the night before the Meta x PyTorch hackathon deadline, it started writing about Star Wars. The loss had been flat at exactly zero for thirty minutes. In GRPO that means every completion in a group is earning identical reward, and the cheapest way to do that is one exploit, run eight times per batch. My parsimony reward had an inverted branch. The model used it to write film history instead of fixing servers.&lt;/p&gt;

&lt;p&gt;Here is the embarrassing part. I caught it by reading raw completions by hand, dumped to disk by a global Python list I had bolted into a reward function that same night. That list was my observability stack. I needed to see what the model was writing so badly that I hand rolled the worst telemetry pipeline imaginable at 4 AM.&lt;/p&gt;

&lt;p&gt;Two nights ago I did what I should have done then: re-graded that training run through a live, instrumented evaluation pipeline, with self-hosted SigNoz on the other end. It surfaced a bug I did not know I had, and not the one I went looking for. In the middle of a run I had called successful, my model spent ten straight steps being punished for writing better.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://raw.githubusercontent.com/Yahid-Basha/triage-agent-signoz/main/Evidence/01a%20Loss%20Plots.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgrphoqt4z3jy3f3f47ed.png" alt="GRPO training loss: the killed April run flat at zero beside the healthy final run" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually ran
&lt;/h2&gt;

&lt;p&gt;The system: Qwen2.5-3B trained with GRPO for 200 steps, 8 completions per step, six reward functions grading each one (format, ROUGE-L quality, citation grounding, calibration, parsimony, repetition). 1,600 graded completions, logged to parquet during training. I had not heard of SigNoz before this hackathon and assumed it was an agent monitoring tool. It is a general observability platform, and its own LLM observability guide lists evaluation and fine-tuning monitoring as a core pillar. A reward pipeline is an evaluation system.&lt;/p&gt;

&lt;p&gt;To be precise: no gradients ran. I re-executed the six reward functions, the real April code, on the stored April prompts and completions, with OpenTelemetry wrapping every call. WandB is the experiment log, aggregated curves per step. SigNoz became the flight recorder, every individual completion queryable and alertable by any attribute I could invent. It will not replace WandB for gradients or checkpoints, and I would not try.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build
&lt;/h2&gt;

&lt;p&gt;foundryctl cast stood the stack up on a base M1 with 5GB handed to Docker, from this casting.yaml:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Installation&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;signoz&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deployment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;flavor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;compose&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;docker&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;mcp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before instrumenting anything, a fidelity gate: re-grade one parquet against April's logged scores. Max drift: 2.44e-08. Same code, bit for bit. (The tiny drift that did exist was a float32 fingerprint from April's own pipeline, which I enjoyed more than I should have.)&lt;/p&gt;

&lt;p&gt;One trace per step, one span per completion, one child span per reward, 57 spans a step. Every number rides as an attribute: reward.score, grpo.advantage, completion.confidence, hack.signature. Timestamps are real and current, on purpose: SigNoz drops spans older than its retention window, so backdating to April would have been dishonest and silently broken. training.step becomes the dashboard x axis instead. With a 2.5 second sleep between steps, the run took a little under eight minutes, and six charts I last watched in April drew themselves again live.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://raw.githubusercontent.com/Yahid-Basha/triage-agent-signoz/main/Evidence/04-span-tree-grpo-step.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft6ao1mz4p7e7v2peyx6p.png" alt="SigNoz trace waterfall: one grpo.step root over eight grade_completion spans, each with six reward child spans" width="799" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The collapse
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://raw.githubusercontent.com/Yahid-Basha/triage-agent-signoz/main/Evidence/06-dashboard-full-replay.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fri27gp17fiug7vn9lzb3.png" alt="The GRPO Flight Recorder dashboard, six reward curves redrawn live with the step 18-28 collapse visible" width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Something I never noticed in three months of owning this data: between steps 18 and 28, nearly everything craters at once, then recovers. Filtering traces to that window returned exactly 11 traces, one per step. Reading 88 completions span by span does not scale, so I exported the quality spans to CSV and joined them to the parent spans with a short script. (Joining a parent attribute with a child score in one query beat me on my setup; the export did fine.)&lt;/p&gt;

&lt;p&gt;Step 18: clean, all eight between 0.173 and 0.260. Step 22: eight zeros out of eight.&lt;/p&gt;

&lt;p&gt;First theory: overconfidence. Calibration is 1 minus (confidence minus quality) squared, so with quality at zero it lands at exactly zero only when confidence is 1.0. Then the real attribute on the parent span: completion.confidence was 0. A parser fallback, not a confession. Dead.&lt;/p&gt;

&lt;p&gt;Second theory: truncation. Step 22's completions all end mid-word, one cut off inside &lt;code&gt;"cited_artifacts": ["KB-0&lt;/code&gt;. Except steps 20 and 21 looked like complete, properly closed JSON and still carried five zeros each. I had also just misread one completion while eyeballing, which is when I stopped trusting my eyes.&lt;/p&gt;

&lt;p&gt;So I stopped reading and started parsing. json.loads on all 88, fence stripped. 47 failed. Parse failures next to zero scores, step by step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parse failures: 0  1  5  5  8  5  4  7  4  6  2
zero scores:    0  1  5  5  8  5  4  7  4  6  2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical, all eleven steps, from two independent sources: the SigNoz export and the raw parquets. Every zero in that window is a parse failure. Nothing left over.&lt;/p&gt;

&lt;p&gt;83% of failures were "Invalid control character". The exact bytes at one failure position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;' resolve this issue, follow these steps:&lt;span class="se"&gt;\n\n&lt;/span&gt;1. Use &lt;span class="err"&gt;`&lt;/span&gt;'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two literal newline bytes inside a JSON string. A paragraph break, right before a numbered list.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://raw.githubusercontent.com/Yahid-Basha/triage-agent-signoz/main/Evidence/07-parse-failures-byte-proof.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fah9bi89mj68e7pf6pbp3.png" alt="Parse failures matching zero scores, plus the byte proof" width="800" height="515"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the whole bug. GRPO pushed the model toward longer, better organized answers, and the model obliged: section breaks, numbered steps. Raw newlines inside a JSON string are invalid JSON, and my pipeline zeroes four rewards at once when it cannot parse. The model was penalized, hard, for becoming a better technical writer. Step 18 answers are short single paragraphs and parse clean. Step 22 answers look like documentation and score zero. No curve shows this. You have to read the completion next to its scores. (Steps 27 and 28 add a second mode, genuine truncation. Two bugs, not one.)&lt;/p&gt;

&lt;p&gt;Two more questions the attributes made possible. Confidence above 0.8 with quality below 0.2: 143 completions, 94% stating exactly 0.85. Not overconfidence, a habit: the model says 0.85 the way people say "pretty sure". And grpo.advantage above 1 with quality below 0.15: 23 completions the optimizer rewarded for being the least bad of their group of eight. No WandB view answers either question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alert I needed in April
&lt;/h2&gt;

&lt;p&gt;I also resurrected the April bug itself. The v1 parsimony function (rewards anything under 200 or over 400 characters, zeroes the sane middle) now runs beside the fixed version, and hack.signature goes true when v1 scores 0.9 or higher while real quality sits under 0.10. A trace-based alert on that count, wired to Slack, fired: seven flagged completions across five traces, one step carrying three. It resolved itself ten minutes later once the pass ended. This is a reconstruction against real completions, not the original hacked rollouts, and v1 trips on length alone: a tripwire, not proof of an incident. Still. In April this discovery cost me until 4:30 in the morning. Now it is a Slack notification.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://raw.githubusercontent.com/Yahid-Basha/triage-agent-signoz/main/Evidence/12-alert-slack-message.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1eaodlriuw7f2t2w732n.png" alt="The hack.signature alert firing into Slack" width="800" height="474"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;(The "min" after the count in the message is a SigNoz unit label quirk; it is a plain count.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three independent queries (grounding zeros after step 50, the confidence habit, the optimizer gap) kept converging on the same two tickets, TRAIN-00044 and TRAIN-00046. Whatever those two are supposed to teach, my model never learned it, and no curve ever told me.&lt;/p&gt;

&lt;p&gt;Last check: I pointed Claude Code at SigNoz's MCP server from a fresh session with no access to my notes and asked, in plain English, for the overconfident low quality tickets. It rediscovered the schema, worked out the parent child join on its own, and returned in about 18 minutes: 443 matches across 35 tickets (all runs I ever sent, smoke tests included, so wider than my 143). The same two tickets again.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://raw.githubusercontent.com/Yahid-Basha/triage-agent-signoz/main/Evidence/16-mcp-full-investigation.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc1ow5elur0n9w9ha7wgf.png" alt="Claude Code querying SigNoz over MCP, re-deriving the parent-child join and returning the overconfident low-quality tickets" width="800" height="515"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature, named
&lt;/h2&gt;

&lt;p&gt;The brief asks for the feature I found most useful. Not the trace waterfall. The Query Builder, specifically what it does with attributes it has never seen: reward.score, grpo.advantage, hack.signature are words I made up, and the moment they arrived they were filterable, chartable, and alertable like any built-in field. Every finding in this post is a query over my own vocabulary. The tool that finds your bugs is the one that speaks the terms your system already thinks in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take from this
&lt;/h2&gt;

&lt;p&gt;I fine tune models, which means I live inside aggregate curves. Loss down, mean reward up, ship it. When something breaks mid-run you squint at a chart, or you bolt a list into a reward function and read raw text at 4 AM. That is the state of the art for a lot of RL fine tuning, and it is half blind. The first time I clicked into one completion and saw the exact text, the ticket, the step, and all six scores in one panel, I sat back in my chair. I had never been able to ask that question before. The build was one continuous seven hour overnight sitting. I kept going because every query got me one step closer, and honestly it was fun, which I did not expect from an observability tool.&lt;/p&gt;

&lt;p&gt;If you fine tune with reward functions: your rewards and your model's behavior are telemetry. Treat them that way. And keep your wrong theories in the writeup. Mine both died against a specific number, and the final claim is only trustworthy because they did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Same pipeline, pointed at live inference instead of stored completions: that is my build for the main hackathon starting July 20. &lt;/p&gt;

&lt;p&gt;Repo with pipeline, casting.yaml, and full investigation logs: &lt;a href="https://github.com/Yahid-Basha/triage-agent-signoz" rel="noopener noreferrer"&gt;REPO LINK&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;Environment, trained model, and the original 4 AM story: &lt;a href="https://huggingface.co/spaces/yahid/triage_agent_env" rel="noopener noreferrer"&gt;HF SPACE&lt;/a&gt; - &lt;a href="https://huggingface.co/yahid/triage-agent-qwen3b" rel="noopener noreferrer"&gt;MODEL&lt;/a&gt; - &lt;a href="https://dev.to/yahid_basha/when-my-rl-agent-started-writing-about-star-wars-instead-of-fixing-servers-29p8"&gt;APRIL BLOG&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Note on AI usage
&lt;/h2&gt;

&lt;p&gt;The direction, architecture, SigNoz exploration, screenshots, and the discovery itself are mine. Instrumentation scaffolding and analysis scripts were written with AI (Claude Code) assistance under my direction and reviewed line by line. Every finding was verified against real data, including two of my own hypotheses that turned out wrong and stayed in this post.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>wemakedevs</category>
      <category>observability</category>
      <category>signoz</category>
    </item>
    <item>
      <title>When my RL agent started writing about Star Wars instead of fixing servers</title>
      <dc:creator>Yahid Basha</dc:creator>
      <pubDate>Sun, 26 Apr 2026 10:54:02 +0000</pubDate>
      <link>https://dev.to/yahid_basha/when-my-rl-agent-started-writing-about-star-wars-instead-of-fixing-servers-29p8</link>
      <guid>https://dev.to/yahid_basha/when-my-rl-agent-started-writing-about-star-wars-instead-of-fixing-servers-29p8</guid>
      <description>&lt;p&gt;&lt;em&gt;A Sunday-morning postmortem on teaching a 3B model to do enterprise IT triage with GRPO.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's 1 AM on a Sunday. The Meta × PyTorch OpenEnv Hackathon submission is due at 5 PM. My training logs show a loss curve that's been flat at 0.0 for the last thirty minutes.&lt;/p&gt;

&lt;p&gt;A flat loss in supervised learning means convergence. A flat loss in reinforcement learning usually means something else: your model has found a way to game your reward function, and it is now sitting in a local optimum, smug about it.&lt;/p&gt;

&lt;p&gt;Mine had decided that the easiest way to maximize reward was to write about cinema history.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was actually trying to build
&lt;/h2&gt;

&lt;p&gt;I work on a production RAG system at Verizon — the kind of system that's supposed to surface the right runbook when a NOC engineer types "BGP (Border Gateway Protocol) session won't come up after the Tuesday maintenance window." This is Theme #3.1 — World Modeling for Professional Tasks — applied to a domain where the model must maintain accurate beliefs about a partially observable KB, not exploit shortcuts to fake a resolution. If you've ever built one of these, you know the failure modes are not in retrieval. Retrieval is mostly a solved problem. The failures are behavioral, at the LLM layer:&lt;/p&gt;

&lt;p&gt;The model retrieves the right document and still hallucinates the answer. It cites a KB article that doesn't exist. It sounds confident when it should escalate. It pads the response with filler because shorter answers feel less authoritative. None of these are bugs you can fix with better embeddings.&lt;/p&gt;

&lt;p&gt;For the hackathon, I wanted to attack one slice of this problem with reinforcement learning. The thesis: if you give an LLM the right kind of feedback signal — not labeled examples but a programmatic verifier of what good triage looks like — you can teach a small model to behave well over retrieved context, even if it doesn't have deep domain knowledge baked in.&lt;/p&gt;

&lt;p&gt;I picked Qwen2.5-3B-Instruct. Not because 3B is fashionable, but because the only result that's interesting to an enterprise team is "small model + RL &amp;gt; big model off the shelf, on this specific task, for this specific cost." If I can't show that, the project doesn't matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first attempt, and TRL issue #5536
&lt;/h2&gt;

&lt;p&gt;The original design was ambitious: a multi-turn agent that decides which tools to call. The action space had seven tools — &lt;code&gt;search_kb&lt;/code&gt;, &lt;code&gt;search_tickets&lt;/code&gt;, &lt;code&gt;search_incidents&lt;/code&gt;, &lt;code&gt;get_article&lt;/code&gt;, &lt;code&gt;get_ticket&lt;/code&gt;, &lt;code&gt;get_incident&lt;/code&gt;, and &lt;code&gt;submit_resolution&lt;/code&gt;. The agent would search, fetch, search again, eventually submit a resolution. GRPO would teach it the right query strategies.&lt;/p&gt;

&lt;p&gt;I burned about four hours on this before I realized the environment factory in TRL wasn't injecting tool schemas into the rollout context properly. The model was sampling actions blind. There's an open issue (#5536) for it. I tried three workarounds. None of them produced a clean rollout.&lt;/p&gt;

&lt;p&gt;Around 4 AM I noticed the loss had flatlined and the rewards looked suspiciously stable. I pulled a few sample completions to inspect them.&lt;/p&gt;

&lt;p&gt;The model was writing about Star Wars.&lt;/p&gt;

&lt;p&gt;Specifically, it was writing about the history of cinema, the production of &lt;em&gt;A New Hope&lt;/em&gt;, and at one point, a fairly accurate paragraph about George Lucas's relationship with 20th Century Fox. The &lt;code&gt;submit_resolution&lt;/code&gt; JSON at the end was empty. The &lt;code&gt;resolution&lt;/code&gt; string was movie trivia.&lt;/p&gt;

&lt;p&gt;Why? Because I had a parsimony reward — "give a concise answer, don't ramble" — that I had implemented incorrectly. The function rewarded outputs &lt;em&gt;under&lt;/em&gt; 200 characters and &lt;em&gt;over&lt;/em&gt; 400. (I wanted U-shaped to discourage stub answers and discourage padding, but my logic was inverted in one branch.) Long, irrelevant filler hit the high-length lobe of the reward and got credited as if it were a complete answer.&lt;/p&gt;

&lt;p&gt;This is the canonical failure mode of RL. The OpenEnv documentation has a whole section about it. I'd read that section. I still wrote the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pivot: single-turn, oracle-grounded
&lt;/h2&gt;

&lt;p&gt;It was 4:30 AM and I had two options. Spend the morning fighting TRL and debugging multi-turn rollouts, or simplify the environment until I had something that trains.&lt;/p&gt;

&lt;p&gt;I picked the simplification. The new design dropped multi-turn entirely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The prompt contains the ticket plus retrieved articles — some gold, some distractors. I do the retrieval before the rollout.&lt;/li&gt;
&lt;li&gt;The model reads the context and emits exactly one &lt;code&gt;submit_resolution&lt;/code&gt; action containing four fields: a resolution string, a list of citation IDs, a confidence score, and an escalation boolean.&lt;/li&gt;
&lt;li&gt;The reward grades that single response on six independent dimensions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest framing of this trade-off: I'm no longer training a model to be a good searcher. I'm training it to be a good &lt;em&gt;reader&lt;/em&gt;. Given a ticket and five candidate articles, can it identify which one is relevant, write a grounded answer, cite the right IDs, and tell me how confident it is? That's a narrower capability than the original goal. It's also exactly the capability that fails in production RAG systems, so it's still worth doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six rewards, none of them load-bearing alone
&lt;/h2&gt;

&lt;p&gt;I built the reward function as six independent components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;reward_funcs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;r_format_graduated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# JSON schema compliance, no shortcuts
&lt;/span&gt;    &lt;span class="n"&gt;r_resolution_quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# ROUGE-L against gold resolution text
&lt;/span&gt;    &lt;span class="n"&gt;r_citation_grounding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# cite only IDs that appear in context
&lt;/span&gt;    &lt;span class="n"&gt;r_calibration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;# confidence ~ correctness (Brier-style)
&lt;/span&gt;    &lt;span class="n"&gt;r_parsimony&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;# answer shorter than the ticket itself
&lt;/span&gt;    &lt;span class="n"&gt;r_repetition_penalty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# punish loops and copy-paste
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The argument for multiple independent rewards is straightforward: with one scalar, your model finds the cheapest way to maximize it, which is almost never what you want. With six, gaming any single component costs you on the others. The Star Wars incident was a reminder of why this matters — that whole behavior was possible because parsimony was the only signal in town for that branch of the reward.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;r_repetition_penalty&lt;/code&gt; is purely defensive. I added it after the Star Wars incident as a guard against the next reward-hacking failure mode I could imagine — a model that finds it can boost grounding by repeating the same valid citation thirty times. It's the only training reward without a server-side equivalent. The environment's evaluation reward function has five components; training has six because I didn't trust the model not to find a new exploit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 200 steps of GRPO actually changed
&lt;/h2&gt;

&lt;p&gt;I trained for 200 steps on an A100. After the run, I pulled the completions parquets from the HF model repo and aggregated the per-step reward means. Two windows: steps 1–10 (cold start, before any meaningful gradient) and steps 150–200 (post-convergence).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reward&lt;/th&gt;
&lt;th&gt;Steps 1–10&lt;/th&gt;
&lt;th&gt;Steps 150–200&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Calibration&lt;/td&gt;
&lt;td&gt;0.528&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.977&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+0.449&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parsimony&lt;/td&gt;
&lt;td&gt;0.246&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.940&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+0.694&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Citation Grounding&lt;/td&gt;
&lt;td&gt;0.756&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.862&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+0.106&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolution Quality&lt;/td&gt;
&lt;td&gt;0.162&lt;/td&gt;
&lt;td&gt;0.181&lt;/td&gt;
&lt;td&gt;+0.019&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Format Adherence&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.992&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repetition Penalty&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.990&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of these moved a lot. Three didn't.&lt;/p&gt;

&lt;p&gt;Format and repetition were already pinned at the ceiling from the first step. Qwen2.5-3B-Instruct is well-aligned out of the box — it knows JSON, and the base model wasn't producing the kind of pathological loops that the repetition penalty was designed to catch. Those rewards weren't doing anything; they were ballast. (The repetition penalty was still cheap insurance, and it's the kind of thing you don't notice working until the day it does.)&lt;/p&gt;

&lt;p&gt;Calibration nearly doubled. Parsimony quadrupled. Citation grounding climbed from "okay" to "good." These are the rewards that track behavioral properties — properties of &lt;em&gt;how&lt;/em&gt; the model speaks, not &lt;em&gt;what&lt;/em&gt; it knows. The model learned to express less confidence when its answer was wrong (calibration), to stop padding responses (parsimony), and to draw citations from the actual provided context instead of inventing them (grounding).&lt;/p&gt;

&lt;p&gt;Then there's the one number that barely moved. &lt;code&gt;r_resolution_quality&lt;/code&gt; went from 0.16 to 0.18. I want to be honest about that one because it's the most interesting result on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the reward curves
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fspaces%2Fyahid%2Ftriage_agent_env%2Fresolve%2Fmain%2Fassets%2Fplots%2Ftraining_reward_curve.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fspaces%2Fyahid%2Ftriage_agent_env%2Fresolve%2Fmain%2Fassets%2Fplots%2Ftraining_reward_curve.png" alt="GRPO training reward curves — 200 steps, Qwen2.5-3B-Instruct" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Six reward signals over 200 GRPO steps. Each line is the mean reward &lt;br&gt;
across the 8 completions sampled per step.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One thing that surprises people seeing RL training curves for the first time: &lt;br&gt;
the loss isn't what you watch. In GRPO, the loss oscillates around zero throughout &lt;br&gt;
training — that's expected behavior for the &lt;code&gt;dr_grpo&lt;/code&gt; objective. What you watch &lt;br&gt;
instead are the reward curves, and you want them to go &lt;em&gt;up&lt;/em&gt; and stabilize.&lt;/p&gt;

&lt;p&gt;The six curves here fall into four groups that each tell a different story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Format and repetition (flat at 1.0 from step 1)&lt;/strong&gt; — the base model already knew the JSON schema and didn't loop citations. GRPO had nothing to teach here. Flat lines at the ceiling are a good sign, not stagnation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calibration and parsimony (S-curve convergence, steps 1–30)&lt;/strong&gt; — these are the headline results. Calibration climbs from 0.53 to 0.97 in roughly thirty steps. Parsimony goes from 0.25 to 0.94 over the same window. Both show a classic S-curve: slow start, fast middle, then plateau. These are behavioral properties the base model had the latent capacity for but hadn't been incentivized to use. The gradient found them quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Citation grounding (noisy upward trend)&lt;/strong&gt; — the most visually dramatic curve. It swings between 0.0 and 1.0 throughout all 200 steps, and the variance doesn't obviously shrink. This is not a training failure — it's a harder reward to optimize. Citation grounding depends on each specific ticket's context: sometimes the model identifies the right article, sometimes it picks a distractor. The noise is honest. The mean trends upward (0.756 → 0.862), which is what matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resolution quality (the line that doesn't move)&lt;/strong&gt; — the orange line stays near 0.18 for the entire run. This is the most important curve to understand correctly. It's not flat because training failed. It's flat because GRPO cannot add knowledge the base model doesn't have. Writing the correct BGP (Border Gateway Protocol) reset procedure requires knowing the correct BGP (Border Gateway Protocol) reset procedure — that's a pretraining question, not a fine-tuning question. The ceiling here tells you where RL ends and domain pretraining begins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling that RL can't push through
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;r_resolution_quality&lt;/code&gt; is ROUGE-L overlap between the model's resolution text and the gold answer. It measures something like "did you actually write the right fix?"&lt;/p&gt;

&lt;p&gt;Calibration is a style property. The model can learn it by adjusting how it expresses uncertainty in language it already produces. Parsimony is also style — say less. Citation grounding is behavioral — pick from this list, don't invent IDs. RL is good at all three.&lt;/p&gt;

&lt;p&gt;But generating the &lt;em&gt;factually correct&lt;/em&gt; fix for a BGP (Border Gateway Protocol) session that won't come up requires knowing the actual answer. RL doesn't add knowledge. It can only shape behavior over what the base model already has.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;r_resolution_quality&lt;/code&gt; ≈ 0.18 isn't really a training failure. It's the ceiling that GRPO alone hits on a 3B base that doesn't have deep enterprise networking knowledge baked into pretraining. The next steps to break that ceiling are retrieval-augmented generation at inference time, domain-adapted continued pretraining, or both. Neither is RL.&lt;/p&gt;

&lt;p&gt;I think this is the most useful thing I can communicate from this project: a clean separation between what RL fine-tuning fixes (style, calibration, format adherence, faithfulness to provided context) and what it doesn't (factual knowledge gaps). If you're a team thinking about deploying a small model behind a RAG system, the first list is achievable in a weekend with a few hundred GRPO steps. The second list is a much bigger investment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for production RAG
&lt;/h2&gt;

&lt;p&gt;The pitch isn't that I trained a model that hits some benchmark. It's that the most common LLM-layer failures in production RAG systems — hallucinated citations, miscalibrated confidence, padded answers, format drift — are tractable with RL on a small base model, with a verifier that scores the four behaviors independently.&lt;/p&gt;

&lt;p&gt;You don't need to swap your 7B for a 70B to fix these. You need to teach the model you already have to behave correctly over retrieved context.&lt;/p&gt;

&lt;p&gt;Calibration improved by 85% relative — from 0.528 to 0.977. In production terms, that's the difference between an agent that confidently misroutes a P1 incident at 3 AM and one that escalates honestly when it doesn't know the answer. That's not a benchmark number. That's a safety property.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;A few things I'd change if I were starting over:&lt;/p&gt;

&lt;p&gt;The dataset is too small. 50 training tickets across networking, security, cloud, and infra means each subdomain only gets ten or twelve examples. A larger, more diverse ticket pool would probably push the behavioral rewards higher and might lift resolution quality a bit through better domain coverage.&lt;/p&gt;

&lt;p&gt;I'd add a process reward, not just outcome rewards. Right now the model is graded only on the final &lt;code&gt;submit_resolution&lt;/code&gt;. Scoring intermediate reasoning steps — even with a lightweight LLM-as-judge as one signal among many — would give a denser gradient.&lt;/p&gt;

&lt;p&gt;I'd run the 72B baseline comparison properly. I tried, on Sunday afternoon, to run the trained 3B and the off-the-shelf 72B side by side against the environment's server-side rewards, hit a CUDA driver mismatch on the eval container, and ran out of time. The training-reward comparison is real; the small-model-with-RL &amp;gt; big-model-without claim is partially evidenced. I'd want a clean head-to-head on equal footing before saying it cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;




&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Environment&lt;/strong&gt;: &lt;a href="https://huggingface.co/spaces/yahid/triage_agent_env" rel="noopener noreferrer"&gt;yahid/triage_agent_env on Hugging Face Spaces&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trained model&lt;/strong&gt;: &lt;a href="https://huggingface.co/yahid/triage-agent-qwen3b" rel="noopener noreferrer"&gt;yahid/triage-agent-qwen3b&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Training notebook (Colab smoke test)&lt;/strong&gt;: &lt;a href="https://huggingface.co/spaces/yahid/triage_agent_env/blob/main/training/train_grpo_colab_openenvHackathon_finale.ipynb" rel="noopener noreferrer"&gt;uploaded on HF&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repository&lt;/strong&gt;: same as the HF Space&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This was built for the Meta × PyTorch OpenEnv Hackathon, Bangalore finale, April 2026. The environment, training script, and reward functions are open source on the HF Space.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>devops</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
