<?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: Norman Niemer</title>
    <description>The latest articles on DEV Community by Norman Niemer (@norman_niemer_7f327e153b9).</description>
    <link>https://dev.to/norman_niemer_7f327e153b9</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%2F4065183%2Fdebbaa39-9815-4a9c-a07e-f7afae4f0cba.png</url>
      <title>DEV Community: Norman Niemer</title>
      <link>https://dev.to/norman_niemer_7f327e153b9</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/norman_niemer_7f327e153b9"/>
    <language>en</language>
    <item>
      <title>How to cache intermediate DataFrames in Python (without brittle pickle files)</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Wed, 02 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/how-to-cache-intermediate-dataframes-in-python-without-brittle-pickle-files-4jff</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/how-to-cache-intermediate-dataframes-in-python-without-brittle-pickle-files-4jff</guid>
      <description>&lt;p&gt;&lt;em&gt;Why &lt;code&gt;features_v3_final.pkl&lt;/code&gt; keeps burning you — and how to make cached reuse something you can actually trust.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You know the loop. You load raw data, build features, fit a model, look at the result, tweak one line, and run the whole thing again — waiting several minutes for &lt;code&gt;load_raw&lt;/code&gt; and &lt;code&gt;build_features&lt;/code&gt; to recompute output that is byte-for-byte identical to last time. So you start saving intermediate DataFrames to disk. A few weeks later your folder is a graveyard: &lt;code&gt;features.pkl&lt;/code&gt;, &lt;code&gt;features_v2.pkl&lt;/code&gt;, &lt;code&gt;features_v3_final.pkl&lt;/code&gt;, &lt;code&gt;features_v3_final_ACTUAL.pkl&lt;/code&gt;. Nobody remembers which one is current, and worse — you can't trust any of them.&lt;/p&gt;

&lt;p&gt;That last part is the real cost. A caching layer you can't trust is worse than no cache at all, because it quietly serves you the wrong answer. This post is about how to cache intermediate DataFrames in Python so that reuse is &lt;em&gt;safe&lt;/em&gt; — every cached value is guaranteed to match the code and parameters that produced it, or it gets rebuilt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;oryxflow&lt;/strong&gt; is a small, local-first, zero-infrastructure Python library that turns your data-science script into dependency-aware tasks, so every value on disk is tied to the code, inputs, and parameters that produced it — and a step rebuilds the moment any of those change. Safe reuse is the &lt;em&gt;result&lt;/em&gt; of that, not a separate feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why hand-rolled DataFrame caches fail
&lt;/h2&gt;

&lt;p&gt;The instinct is reasonable. You wrap an expensive step in an existence check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;features.pkl&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_pickle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;features.pkl&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;load_raw&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_pickle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;features.pkl&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for exactly one afternoon. Then it starts lying to you, in three distinct ways:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. No code-awareness → silent stale results.&lt;/strong&gt; You edit &lt;code&gt;build_features&lt;/code&gt; to add a column or fix a bug. The file &lt;code&gt;features.pkl&lt;/code&gt; still exists, so the branch above loads the &lt;em&gt;old&lt;/em&gt; DataFrame and skips your new code entirely. Nothing errors. Your model trains on features that no longer match the function that supposedly built them. This is the bug that ends with a wrong number in a report, and it's nearly impossible to spot by reading the script — the code looks right; the cache is what's wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Manual path bookkeeping → collisions and typos.&lt;/strong&gt; Every cached step needs its own filename, and you assign them by hand. Two experiments both write &lt;code&gt;features.pkl&lt;/code&gt; and clobber each other. You typo &lt;code&gt;features.pkl&lt;/code&gt; in the read but not the write, so it silently recomputes forever. You copy a block, forget to rename the file, and now two steps share one cache. The &lt;code&gt;_v3_final&lt;/code&gt; naming scheme is what path bookkeeping looks like once it has failed a few times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. No parameter-awareness.&lt;/strong&gt; The moment you add a knob — &lt;code&gt;window=30&lt;/code&gt; vs &lt;code&gt;window=60&lt;/code&gt;, &lt;code&gt;model='ols'&lt;/code&gt; vs &lt;code&gt;model='gbm'&lt;/code&gt; — a single &lt;code&gt;features.pkl&lt;/code&gt; can't represent both. You either recompute every time (defeating the cache) or hand-roll &lt;code&gt;features_window30.pkl&lt;/code&gt; filenames and get right back to problem #2.&lt;/p&gt;

&lt;p&gt;Each fix breeds more bookkeeping, and none of them fix the dangerous one — #1 — because a filename simply doesn't know anything about the code that wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: cache by task identity, not by filename
&lt;/h2&gt;

&lt;p&gt;The way out is to stop keying your cache on a &lt;em&gt;filename&lt;/em&gt; you chose and start keying it on the &lt;em&gt;identity of the work&lt;/em&gt; — the task's code, its inputs, and its parameters. If any of those change, the identity changes, and the cached value is rebuilt. If none change, the value is reused. You never name a file.&lt;/p&gt;

&lt;p&gt;That's what &lt;code&gt;oryxflow&lt;/code&gt; does. You declare each step as a &lt;code&gt;Task&lt;/code&gt;: what it depends on, and what it produces. The output format follows the base class you inherit — &lt;code&gt;TaskPqPandas&lt;/code&gt; persists a DataFrame as Parquet, &lt;code&gt;TaskPickle&lt;/code&gt; persists a fitted model, &lt;code&gt;TaskCSVPandas&lt;/code&gt; writes CSV — so there's no serialization code and no path to manage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LoadRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="c1"&gt;# DataFrame -&amp;gt; Parquet, automatically
&lt;/span&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;load_raw&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;                       &lt;span class="c1"&gt;# no filename, ever
&lt;/span&gt;
&lt;span class="nd"&gt;@oryxflow.requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LoadRaw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                          &lt;span class="c1"&gt;# declares the dependency + copies params
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BuildFeatures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                        &lt;span class="c1"&gt;# loads LoadRaw's cached DataFrame
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;build_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="nd"&gt;@oryxflow.requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildFeatures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FitModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPickle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;           &lt;span class="c1"&gt;# model object -&amp;gt; pickle, automatically
&lt;/span&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveMeta&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;n_rows&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;     &lt;span class="c1"&gt;# small run metadata alongside it
&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FitModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it once and all three steps execute. Run it again and every step is served from its cached output — no recomputation, no &lt;code&gt;if os.path.exists&lt;/code&gt;, no &lt;code&gt;.pkl&lt;/code&gt; you had to name. The cache is addressed by task identity, so it can't collide with another step and can't be loaded under the wrong name.&lt;/p&gt;

&lt;p&gt;Because identity includes parameters, sweeping a knob just works. Give &lt;code&gt;BuildFeatures&lt;/code&gt; a &lt;code&gt;window = oryxflow.IntParameter(default=30)&lt;/code&gt; and each value gets its own cached output automatically — &lt;code&gt;window=30&lt;/code&gt; and &lt;code&gt;window=60&lt;/code&gt; coexist without you inventing two filenames, and re-running either one reuses whichever upstream work it shares.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editing a step invalidates exactly what changed
&lt;/h2&gt;

&lt;p&gt;Here's the property that hand-rolled caches can't offer, and the reason reuse is safe: oryxflow tracks each task's &lt;em&gt;code&lt;/em&gt; — and the helper files it imports — comparing what your code &lt;em&gt;does&lt;/em&gt;, not how it's written. When you edit the body of &lt;code&gt;build_features&lt;/code&gt;, oryxflow sees the change, so &lt;code&gt;BuildFeatures&lt;/code&gt; and everything downstream of it (&lt;code&gt;FitModel&lt;/code&gt;) are marked stale and rerun on the next &lt;code&gt;flow.run()&lt;/code&gt;. The expensive &lt;code&gt;LoadRaw&lt;/code&gt; step upstream is untouched and stays cached.&lt;/p&gt;

&lt;p&gt;Crucially, what counts is &lt;em&gt;logic&lt;/em&gt;, not text. Rename a local variable for clarity, reflow a line, or add a comment, and nothing reruns — a cosmetic edit isn't a behavior change, so oryxflow doesn't waste your time rebuilding for it. Change an actual computation and the affected band of the DAG rebuilds automatically. You get the reuse of a cache with the correctness of a from-scratch run, and you never manually delete a &lt;code&gt;.pkl&lt;/code&gt; to force a refresh again.&lt;/p&gt;

&lt;p&gt;Every decision is recorded to a local lineage log (&lt;code&gt;.oryxflow/events.jsonl&lt;/code&gt;), so you can answer "why did this rerun?" after the fact. There's no server, no database, no account, and no telemetry — it's all local files on your machine.&lt;/p&gt;

&lt;p&gt;One honest caveat: oryxflow guarantees your cache is &lt;em&gt;reproducible&lt;/em&gt;, not that your logic is &lt;em&gt;right&lt;/em&gt;. It reruns whenever the code changes, so what you get always matches the code that's on disk. Whether that code is correct is still your job — but at least you'll never again be debugging a result that came from a version of the code you already deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hand-rolled pickle cache vs oryxflow
&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F92up8xn5ulnho4a9b0u3.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F92up8xn5ulnho4a9b0u3.png" alt="table for Hand-rolled pickle cache vs oryxflow&lt;br&gt;
Hand-rolled .pkl cache  oryxflow task&lt;br&gt;
Trustworthy reuse   Only if you never make a mistake    Reproducible by construction&lt;br&gt;
Edit the step's code    Stale file silently reused  Rebuilds that step + downstream&lt;br&gt;
Cosmetic edit (comment/rename)  Rebuild only if you remember to delete the file No rerun — logic unchanged&lt;br&gt;
Filenames / paths   You name and track every one    None — cache keyed by task identity&lt;br&gt;
Different parameters    One file, or hand-rolled suffixes   Separate cached output per value&lt;br&gt;
Choosing the format to_pickle / to_parquet by hand  Follows the base class (Parquet, CSV, pickle)"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Caching intermediate DataFrames by filename fails because a filename knows nothing about the code that wrote it — so it happily hands you stale features after you've changed the logic. Cache by &lt;em&gt;task identity&lt;/em&gt; instead: let each step's code, inputs, and parameters define what "already done" means, and reuse becomes something you can actually trust. Edit a step and exactly what changed reruns; edit a comment and nothing does. The speed is the side effect — what you're really buying is the ability to believe the DataFrame you just loaded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;oryxflow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  How do I cache intermediate DataFrames in Python without brittle pickle files?
&lt;/h2&gt;

&lt;p&gt;Hand-rolled pickle caches key on a filename you chose, which knows nothing about the code that wrote it, so they silently serve stale DataFrames after you edit the logic. Cache by task identity instead: key each cached value on the step's code, inputs, and parameters. oryxflow does this in plain Python, persisting a DataFrame as Parquet automatically and rebuilding it whenever the code or parameters change, so you never name or track a .pkl.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I know a cached DataFrame isn't stale?
&lt;/h2&gt;

&lt;p&gt;You can't know it from a file, because a filename records nothing about the code that wrote it — which is why a hand-rolled cache eventually hands you features that no longer match the function that supposedly built them. Make the cache key the identity of the work instead: the step's code, its inputs, and its parameters. oryxflow does this and rebuilds a step the moment any of the three change, so a stale value is never served and you never have to delete a file to force a refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I avoid recomputing a DataFrame every run?
&lt;/h2&gt;

&lt;p&gt;Wrap each expensive step as a task that declares its dependencies and saves its output, so an engine can reuse the previous result when nothing changed and rebuild it when something did. oryxflow, a local-first zero-infrastructure Python library, caches each intermediate DataFrame by task identity and reruns a step only when its code, inputs, or parameters actually change — so an unchanged step turns a multi-minute rerun into a load from disk, and a changed one can't be missed.&lt;/p&gt;

&lt;p&gt;Read next&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.oryxflow.dev/blog/caching/stop-rerunning-your-pipeline/" rel="noopener noreferrer"&gt;Stop rerunning your whole pipeline&lt;/a&gt; — the deeper dive on caching a DAG.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.oryxflow.dev/blog/caching/parameter-sweeps-without-rerunning/" rel="noopener noreferrer"&gt;Parameter sweeps without rerunning everything&lt;/a&gt; — reuse shared upstream work across a grid.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.oryxflow.dev/blog/reproducibility/notebook-to-reproducible-pipeline/" rel="noopener noreferrer"&gt;Turn a messy notebook into a reproducible pipeline&lt;/a&gt; — turn an exploratory notebook into cached tasks.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.oryxflow.dev/blog/mlops/mlflow-vs-pipeline-caching/" rel="noopener noreferrer"&gt;MLflow, or a reproducible pipeline?&lt;/a&gt; — how caching complements experiment tracking.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.oryxflow.dev/docs/why-oryxflow/" rel="noopener noreferrer"&gt;Why oryxflow&lt;/a&gt; and &lt;a href="https://docs.oryxflow.dev/docs/managing-workflows/" rel="noopener noreferrer"&gt;Managing workflows&lt;/a&gt; in the docs.&lt;/li&gt;
&lt;li&gt;Source and examples: &lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;https://github.com/oryxintel/oryxflow&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Do you need MLflow, or do you need a reproducible pipeline?</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/do-you-need-mlflow-or-do-you-need-a-reproducible-pipeline-2ff5</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/do-you-need-mlflow-or-do-you-need-a-reproducible-pipeline-2ff5</guid>
      <description>&lt;p&gt;&lt;em&gt;They sound like the same problem. They're not — and mixing them up is why so many ML projects still can't reproduce last week's result.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you've searched for "manage machine learning experiments" you've been pointed at MLflow, Weights &amp;amp; Biases, and DVC. They're excellent tools. But a lot of people install one, log some metrics, and are surprised to find their pipeline is &lt;em&gt;still&lt;/em&gt; a mess of stale pickle files they can't reliably reproduce. That's because these tools solve a &lt;strong&gt;different half&lt;/strong&gt; of the problem than the one biting you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different problems that both get called "experiment management"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Problem A — tracking:&lt;/strong&gt; &lt;em&gt;"Which run got 0.91 AUC, and what were its hyperparameters?"&lt;/em&gt; This is a logging and comparison problem. It's what &lt;strong&gt;MLflow&lt;/strong&gt;, &lt;strong&gt;W&amp;amp;B&lt;/strong&gt;, and &lt;strong&gt;Neptune&lt;/strong&gt; are built for: you call &lt;code&gt;log_metric(...)&lt;/code&gt;, &lt;code&gt;log_param(...)&lt;/code&gt;, and get a searchable dashboard of every run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem B — computation:&lt;/strong&gt; &lt;em&gt;"To reproduce that 0.91 run, which steps do I actually need to rerun, and which are already computed?"&lt;/em&gt; This is a dependency and caching problem. It's about &lt;strong&gt;not&lt;/strong&gt; recomputing a 10-minute feature step when only the model changed, and about &lt;strong&gt;guaranteeing&lt;/strong&gt; the model you're evaluating was trained on the current data.&lt;/p&gt;

&lt;p&gt;Trackers answer A. They do &lt;strong&gt;not&lt;/strong&gt; answer B. MLflow will faithfully log that you got 0.91 — it has no idea whether the features feeding that model are stale, and it won't skip recomputing them for you. That gap is where reproducibility quietly dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a pipeline-caching engine does that a tracker doesn't
&lt;/h2&gt;

&lt;p&gt;A workflow engine like &lt;code&gt;oryxflow&lt;/code&gt; models your work as a DAG of tasks and owns the computation side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GetData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;load_data&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="nd"&gt;@oryxflow.requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GetData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BuildFeatures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;build_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoad&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;

&lt;span class="nd"&gt;@oryxflow.requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildFeatures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TrainModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPickle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gbm&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;clf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveMeta&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(...)})&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;-- log to MLflow here too
&lt;/span&gt;
&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TrainModel&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From this it gives you three things a tracker structurally cannot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Skip-what's-done.&lt;/strong&gt; Rerun the script and completed tasks load from cache instead of recomputing. Change one task and only its downstream reruns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correct-by-construction invalidation.&lt;/strong&gt; Change a parameter, the data, or a task's code, and exactly the affected outputs are marked stale and rebuilt. You can't accidentally evaluate a new model on old features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load-any-result-by-name.&lt;/strong&gt; &lt;code&gt;TrainModel().output().load()&lt;/code&gt; gives you the model; &lt;code&gt;BuildFeatures().output().load()&lt;/code&gt; gives you the features — no hunting for &lt;code&gt;.pkl&lt;/code&gt; paths.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The honest answer: use both
&lt;/h2&gt;

&lt;p&gt;This isn't "oryxflow vs MLflow." The two compose cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;clf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;# oryxflow: caches + invalidates
&lt;/span&gt;    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# MLflow: dashboard + comparison
&lt;/span&gt;    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(...))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;oryxflow&lt;/strong&gt; owns the &lt;em&gt;pipeline&lt;/em&gt;: dependency order, caching, minimal reruns, reproducibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MLflow / W&amp;amp;B&lt;/strong&gt; own the &lt;em&gt;record&lt;/em&gt;: the searchable history of what each run scored.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put the tracker calls &lt;strong&gt;inside&lt;/strong&gt; your oryxflow tasks and you get both a reproducible computation graph and a clean experiment log — without either tool pretending to be the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about DVC?
&lt;/h2&gt;

&lt;p&gt;DVC is the tool people most often conflate with this space, because it does do pipeline caching. The honest difference is what identity is built from. &lt;strong&gt;DVC hashes files and YAML-declared stages:&lt;/strong&gt; you describe your pipeline in &lt;code&gt;dvc.yaml&lt;/code&gt; — each stage's command, dependencies, and outputs — and DVC recomputes a stage when a declared file hash changes. &lt;strong&gt;oryxflow's identity is native Python task identity — parameters plus automatic code-change detection, zero config files:&lt;/strong&gt; the DAG &lt;em&gt;is&lt;/em&gt; your &lt;code&gt;requires()&lt;/code&gt; methods, a parameter change is automatically a new cached identity (no stage file to edit), and a code change reruns the task and everything downstream on its own. It compares what your code &lt;em&gt;does&lt;/em&gt;, not how it's written, so comment and formatting edits never recompute (pin a task with &lt;code&gt;code_version&lt;/code&gt; when you'd rather manage it by deliberate bumps). If your workflow is command-line stages over large versioned data files, DVC's file-hash model fits. If your workflow is Python tasks you iterate on inside a session — parameter sweeps, per-entity fan-outs — keeping identity in the code you're already editing beats maintaining a parallel YAML description of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which do you actually need?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You can already reproduce runs but can't compare them → you want a tracker (MLflow/W&amp;amp;B).&lt;/li&gt;
&lt;li&gt;You can log runs but rerunning your pipeline is slow, fragile, and you're never sure what's stale → you want a caching workflow engine (oryxflow, or its heavier cousins Luigi, Metaflow, Kedro).&lt;/li&gt;
&lt;li&gt;Most real projects want both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If it's the second problem you feel every day — the fifteen-minute edit-rerun loop, the &lt;code&gt;features_v3_final.pkl&lt;/code&gt; graveyard, the "wait, was this trained on the new data?" — start here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;oryxflow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://docs.oryxflow.dev" rel="noopener noreferrer"&gt;https://docs.oryxflow.dev&lt;/a&gt; · Source: &lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;https://github.com/oryxintel/oryxflow&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;oryxflow is a lightweight, dependency-free alternative to Luigi, Metaflow, and Kedro, focused on research iteration rather than production orchestration — and it plays nicely with whatever tracker you already use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Do I need MLflow, or just pipeline caching?
&lt;/h2&gt;

&lt;p&gt;It depends on which problem bites you. If you can already reproduce runs but can't compare them, you want a tracker like MLflow. If you can log runs but rerunning your pipeline is slow, fragile, and you're never sure what's stale, you want a caching workflow engine. Most real projects want both — oryxflow caches the computation while MLflow records the results.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the difference between MLflow and pipeline caching?
&lt;/h2&gt;

&lt;p&gt;MLflow answers tracking — which run got which metric with which parameters — and gives you a searchable dashboard. Pipeline caching answers computation — which steps must actually rerun, and which are already computed — so you never evaluate a model on stale data, and never recompute an unchanged feature step either. A tracker records what happened; a caching engine like oryxflow makes the pipeline behind it reproducible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I use MLflow and oryxflow together?
&lt;/h2&gt;

&lt;p&gt;Yes, and that's the recommended pattern. Put your tracker's logging calls inside your cached oryxflow tasks: oryxflow owns the pipeline — dependency order, caching, minimal reruns, reproducibility — while MLflow owns the record of what each run scored. You get a reproducible computation graph and a clean experiment log, without either tool pretending to be the other.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Cheap, durable LLM evals: pydantic-evals + oryxflow</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Thu, 27 Aug 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/cheap-durable-llm-evals-pydantic-evals-oryxflow-3jlj</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/cheap-durable-llm-evals-pydantic-evals-oryxflow-3jlj</guid>
      <description>&lt;p&gt;&lt;em&gt;An eval matrix is a Cartesian product. Metered platforms bill every cell, every time — then delete the answers.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The first calculation looks fine
&lt;/h2&gt;

&lt;p&gt;You're evaluating an intent classifier — does this query need a web search or not. 200 labeled cases. You wire up &lt;a href="https://www.braintrust.dev/pricing" rel="noopener noreferrer"&gt;Braintrust&lt;/a&gt;, run it, and it's free: Starter gives you 1 GB of processed data and 10,000 scores per month, and you used 200.&lt;/p&gt;

&lt;p&gt;So you do it properly. A real eval isn't one scorer, it's five: correct label, per-class assertion, output format valid, latency under threshold, and an LLM judge on the borderline reasoning. Now each config costs 200 × 5 = &lt;strong&gt;1,000 scores.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then you ask the question you actually care about — is the lite model good enough? Four models. And one run per case is noise, so three reps each:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;200 cases × 5 scorers × 4 models × 3 reps =&lt;/code&gt;&lt;strong&gt;&lt;code&gt;12,000 scores&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's one afternoon. Your monthly free allowance was 10,000.&lt;/p&gt;

&lt;p&gt;The overage is trivial — $2.50 per 1,000 additional scores on Starter, so 2,000 over costs you five dollars. This is the part that feels fine. It stays feeling fine right up until the workload that made you like the tool is the workload that bills you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the matrix multiplies
&lt;/h2&gt;

&lt;p&gt;Six weeks in, everything has grown the way it's supposed to. Your dataset grew because you seeded it from production traces. Your scorer count grew because you learned what breaks. You're sweeping prompt variants too, because that's the whole point of having evals.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;500 cases × 6 scorers × 4 models × 3 prompts × 3 reps =&lt;/code&gt; &lt;strong&gt;&lt;code&gt;108,000 scores per full sweep&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You run that weekly. 432,000 scores a month. Pro at $249/month includes 50,000 scores, with overage at $1.50 per 1,000:&lt;/p&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fccv251sx3pr7etb7si8l.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fccv251sx3pr7etb7si8l.png" alt="A table showing Pro base  $249&lt;br&gt;
382,000 scores over $573&lt;br&gt;
Monthly $822" width="520" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing pathological happened. Nobody made a mistake. Every one of those five dimensions grew for a good reason, and they multiply.&lt;/p&gt;
&lt;h2&gt;
  
  
  And there are three meters, not one
&lt;/h2&gt;

&lt;p&gt;Scores are the meter that scales with the &lt;em&gt;shape&lt;/em&gt; of eval work, but the others ride along. Braintrust bills processed data at $4/GB on Starter and $3/GB on Pro — and agent traces carrying tool calls and retrieved context run hundreds of KB each, one per rep of every cell. If your LLM judge runs through their proxy, token credits meter too.&lt;/p&gt;

&lt;p&gt;Seats are free and unlimited, which is genuinely nice and completely beside the point. Your cost driver isn't headcount. It's the Cartesian product.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that should bother you
&lt;/h2&gt;

&lt;p&gt;Here's the arithmetic that actually motivates this post. You changed &lt;strong&gt;one&lt;/strong&gt; prompt variant. The other two are byte-identical. All four models are unchanged. All 500 cases are unchanged.&lt;/p&gt;

&lt;p&gt;You re-ran and re-paid for all 108,000.&lt;/p&gt;

&lt;p&gt;Two thirds of that sweep was recomputation of cells that could not possibly have moved. You paid the platform to meter it, and you paid the model provider to generate it, and the answers were already sitting on your disk from Tuesday.&lt;/p&gt;

&lt;p&gt;And then it's deleted — 30-day retention on Pro, 14 on Starter. (Pro does offer extended storage at $0.50/GB/mo, and Enterprise offers custom retention and export, so this is a dial rather than a wall — but it's a dial you pay to turn, on data that is measured in kilobytes.) You spent $822 to learn something, and next month you'll spend it again to remember it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Every one of those dimensions is a parameter
&lt;/h2&gt;

&lt;p&gt;Cases, scorers, models, prompt variants, reps. That's a parameter sweep, and data science has had good tooling for parameter sweeps for a decade: declare the grid, cache one output per parameter set, and changing one value reruns exactly the cells that value touches.&lt;/p&gt;

&lt;p&gt;Change one prompt variant under that model and 36,000 score-equivalents recompute instead of 108,000 — and the platform bill for them is zero, because they're parquet files in data/. You still pay your model provider for the third that genuinely changed. That's the only cost that was ever real.&lt;/p&gt;

&lt;p&gt;The rest of this post wires that up in about sixty lines. The division of labor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pydantic.dev/docs/ai/evals/evals/" rel="noopener noreferrer"&gt;&lt;strong&gt;pydantic-evals&lt;/strong&gt;&lt;/a&gt; — scoring. Cases, evaluators, reports. Open source, free.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;&lt;strong&gt;oryxflow&lt;/strong&gt;&lt;/a&gt; — the matrix, the persistence, and the provenance. One cached output per parameter set, tied to the code and parameters that produced it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logfire&lt;/strong&gt; (or your platform of choice) — traces and the debugging UI, which is where observability spend is genuinely well placed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing overlaps.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: the dataset and the scorers
&lt;/h2&gt;

&lt;p&gt;Our running example is the intent classifier: two labels, &lt;code&gt;needs_web&lt;/code&gt; and &lt;code&gt;no_web&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One methodological note first, because it's the thing most eval write-ups get wrong. &lt;strong&gt;This is classification, not a job for LLM-as-judge&lt;/strong&gt;. The output is one of two fixed strings; exact match is the correct scorer. Reach for &lt;code&gt;LLMJudge&lt;/code&gt; when the output is free text with no single right answer — not here, where a judge would add cost, latency and sampling noise to a comparison that was already deterministic.&lt;/p&gt;

&lt;p&gt;Second: aggregate accuracy lies. Your errors aren't symmetric. Searching when you didn't need to costs milliseconds. Not searching when you needed to means the agent answers a question about this morning from stale training data. If &lt;code&gt;needs_web&lt;/code&gt; is 30% of traffic and you miss a tenth of it, accuracy reads 97% and recall on the class that matters is 90%.&lt;/p&gt;

&lt;p&gt;pydantic-evals covers this without a custom scorer: &lt;code&gt;ConfusionMatrixEvaluator&lt;/code&gt; and &lt;code&gt;PrecisionRecallEvaluator&lt;/code&gt; are &lt;em&gt;report-level&lt;/em&gt; evaluators — they run once over the whole experiment — and you pass them via &lt;code&gt;report_evaluators&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_evals&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dataset&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_evals.evaluators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;EqualsExpected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ConfusionMatrixEvaluator&lt;/span&gt;

&lt;span class="n"&gt;CASES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;weather_now&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;what is the weather in Lisbon right now&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reverse_list&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;how do I reverse a list in python&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;latest_release&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;what shipped in the newest postgres release&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;explain_tcp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;explain the TCP handshake&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;dataset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web_v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CASES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;evaluators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;EqualsExpected&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
    &lt;span class="n"&gt;report_evaluators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;ConfusionMatrixEvaluator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;predicted_from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;expected_from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected_output&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web vs no_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the scoring layer, complete. It knows nothing about caching, matrices, or where results live — which is exactly why it composes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: one cell of the matrix, cached
&lt;/h2&gt;

&lt;p&gt;An oryxflow task is a class with parameters, a &lt;code&gt;run()&lt;/code&gt;, and a &lt;code&gt;save()&lt;/code&gt;. The engine keys the cached output on the parameter values, so each &lt;code&gt;(model, prompt_version)&lt;/code&gt; pair gets its own stored result automatically — no filenames to invent, no collisions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EvalRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5-20260501&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;prompt_version&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dataset_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web_v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;classify&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_classifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_version&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;case&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;predicted&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;predicted&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# one row per case, parquet
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveMeta&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;accuracy&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;recall_needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;n_failures&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                        &lt;span class="c1"&gt;# confusion matrix, in your terminal
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what gets persisted: &lt;strong&gt;per-case rows&lt;/strong&gt;, not a summary. A stored accuracy number can't answer a question you didn't think to ask in July. Stored per-case rows can — you can slice by class, by case, by query type, months later, without re-running anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the matrix
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;requires()&lt;/code&gt; that returns a dict fans out over the grid. &lt;code&gt;inputLoadConcat()&lt;/code&gt; stacks the dependencies' outputs into one DataFrame, tagging each with the parameters of the task it came from — so &lt;code&gt;model&lt;/code&gt; and &lt;code&gt;prompt_version&lt;/code&gt; arrive as columns for free:&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;MODELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-haiku-4-5&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5-20260501&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-opus-5&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;PROMPTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EvalMatrix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;EvalRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MODELS&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROMPTS&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoadConcat&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it and ask your questions:&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;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EvalMatrix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;prompt_version&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# the headline
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# what matters
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now edit &lt;code&gt;PROMPTS&lt;/code&gt; to add &lt;code&gt;'v4'&lt;/code&gt;. Three new cells run. The six existing ones load from disk in milliseconds and cost nothing — not in API calls, not in metered scores. That's the entire argument of this post, and it fits in one line of diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: provenance, which you get without asking
&lt;/h2&gt;

&lt;p&gt;Most eval setups rot for a reason unrelated to retention: a score of 0.91 with no record of which prompt, which model string, which dataset revision is worthless in three months. You'll find the number and be unable to defend it.&lt;/p&gt;

&lt;p&gt;oryxflow writes an event stream as it runs — plain JSONL you can &lt;code&gt;jq&lt;/code&gt;. Each execution records parameters, code version, a fingerprint of the task's source, upstream output hashes, the git SHA, duration, and the &lt;em&gt;reason&lt;/em&gt; it ran:&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;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;EvalRun&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# diff params, code, hashes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason field is the underrated one. Edit your scoring logic and affected tasks recompute automatically, with a reason naming the changed symbol — &lt;code&gt;code change (auto: evals/run.py::EvalRun)&lt;/code&gt;. You can't accidentally compare a score computed under the old scorer against one computed under the new one, because the old entry is no longer a valid cache hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: share it
&lt;/h2&gt;

&lt;p&gt;Put &lt;code&gt;data/&lt;/code&gt; under Git LFS — &lt;code&gt;/oryxflow:init-gitlfs&lt;/code&gt; with the Claude Code plugin, or &lt;code&gt;git lfs track "data/**"&lt;/code&gt; by hand. Results now version alongside the code that produced them, and a teammate who clones gets the numbers without re-running anything.&lt;/p&gt;

&lt;p&gt;Two things people get wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Split blessed baselines from scratch sweeps with &lt;code&gt;env=.&lt;/code&gt;&lt;/strong&gt; &lt;code&gt;oryxflow.Workflow(EvalMatrix, env='baseline')&lt;/code&gt; writes under &lt;code&gt;data/env=baseline/&lt;/code&gt;; Tuesday's throwaway grid goes to &lt;code&gt;env='dev'&lt;/code&gt; and never enters LFS. Skip this and LFS bloats with abandoned experiments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit the scored rows, not the traces.&lt;/strong&gt; Per-case parquet is kilobytes; raw spans are megabytes and belong in Logfire, where the UI for reading them lives. GitHub's free LFS quota is 1 GB with bandwidth metered separately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this doesn't solve
&lt;/h2&gt;

&lt;p&gt;Four caveats, each with the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The model endpoint is a cache blind spot.&lt;/strong&gt; oryxflow hashes your code and inputs. It cannot see a provider updating weights behind a stable alias, so pointing at &lt;code&gt;claude-sonnet-5&lt;/code&gt; means your cache will serve July's numbers as current in December, confidently.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix:&lt;/em&gt; pin the snapshot in the parameter — &lt;code&gt;claude-sonnet-5-20260501&lt;/code&gt;, not the alias. The identity of what you measured becomes part of the cache key, and a new snapshot is a new value that runs fresh while the old numbers stay readable. To deliberately re-measure the &lt;em&gt;same&lt;/em&gt; endpoint (checking for drift), invalidate just that family:&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;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset_upstream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EvalMatrix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EvalRun&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# every cell reruns, nothing else does
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. You're caching a stochastic process.&lt;/strong&gt; oryxflow guarantees a result came from the code and inputs it recorded. It does not guarantee a rerun reproduces it. A cached run is a faithful record of &lt;em&gt;one sampling.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix:&lt;/em&gt; set &lt;code&gt;temperature=0&lt;/code&gt; where supported and make it a parameter so the record shows it. Then measure the residual variance rather than assuming it away — which is caveat 4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Latency needs care.&lt;/strong&gt; pydantic-evals runs cases concurrently by default. Time that and you're measuring your own queueing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix:&lt;/em&gt; a separate task with &lt;code&gt;max_concurrency=1&lt;/code&gt;, discard the first call, report p50/p95 rather than the mean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LatencyRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5-20260501&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;n_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IntParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# rep index
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;timings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;classify&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;build_classifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_concurrency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;                    &lt;span class="c1"&gt;# drop warm-up
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;latency_s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveMeta&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;p50&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;(.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;p95&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;(.&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;))})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Reps.&lt;/strong&gt; Decide whether the rep index is a parameter or lives inside pydantic-evals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rep as a parameter&lt;/strong&gt; (&lt;code&gt;n_run&lt;/code&gt; above) — each rep caches separately, so going from three reps to five runs exactly two new evaluations. Bigger DAG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;repeat&lt;/code&gt;= inside &lt;code&gt;evaluate_sync&lt;/code&gt;&lt;/strong&gt; — one cached unit covers all reps. Smaller DAG, but changing the count re-runs everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When reps are expensive, take the parameter and fan out with &lt;code&gt;WorkflowMulti&lt;/code&gt;:&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;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WorkflowMulti&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LatencyRun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rep&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;n_run&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputLoadConcat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;              &lt;span class="c1"&gt;# every rep, tagged, in one DataFrame
&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;latency_s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;([.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Need five reps instead of three? Extend the dict. Reps 1–3 load from cache; only 4 and 5 hit the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The bill wasn't caused by anything you did wrong. It was caused by a Cartesian product meeting a per-unit meter, and by an architecture where the thing you re-pay for is &lt;em&gt;recomputation of cells that could not have changed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Score with pydantic-evals, because it's a well-designed open-source scoring library. Debug in Logfire, because a good trace UI is worth paying for. But own the matrix — cache each cell by its parameters, keep the scored rows in your repo, and let a changed prompt variant cost you the third of the grid that actually moved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;oryxflow pydantic-evals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why does Braintrust get expensive as my evals grow?
&lt;/h2&gt;

&lt;p&gt;Because an eval matrix is a Cartesian product and scores are metered per unit. Cases times scorers times models times prompt variants times repetitions multiplies fast: 500 cases with 6 scorers across 4 models, 3 prompts and 3 reps is 108,000 scores per sweep. Run that weekly and Pro's included 50,000 scores per month is gone in the first sweep — at $1.50 per additional 1,000 the monthly bill lands near $822. Nothing pathological caused it; every dimension grew for a good reason and they multiply.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I stop paying to re-run eval cells that did not change?
&lt;/h2&gt;

&lt;p&gt;Cache each cell of the matrix separately, keyed by its parameters. Change one prompt variant of three and only the cells using that variant should recompute — the other two thirds load from disk. oryxflow does this with a task parameterized by &lt;code&gt;model&lt;/code&gt; and &lt;code&gt;prompt_version&lt;/code&gt;, so a sweep costs you the calls that genuinely changed and nothing else, and the platform bill for the cached cells is zero because they are parquet files in your own data directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the cheapest way to run LLM evals?
&lt;/h2&gt;

&lt;p&gt;Split scoring from persistence. Use pydantic-evals for cases, evaluators and reports — it is open source and free — and an ordinary caching workflow engine for the matrix and storage, so re-running a sweep only calls the model for cells whose parameters or code actually changed. Keep an observability platform for live traces and the debugging UI, where the money is genuinely well spent, and stop metering the part that is just a Cartesian product over a labeled dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I keep per-case eval results after platform retention expires?
&lt;/h2&gt;

&lt;p&gt;Save the scored rows yourself as parquet under a version-controlled data directory and put it under Git LFS. Per-case scored rows for a few hundred cases are kilobytes, so they version alongside the code that produced them and stay readable indefinitely. Leave the bulky raw traces in the observability platform where the trace UI lives — that split keeps your repository small and your results permanent.&lt;/p&gt;

&lt;p&gt;Then keep going:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sibling post: &lt;a href="https://docs.oryxflow.dev/blog/ai-agents/llm-eval-results-deleted-retention/" rel="noopener noreferrer"&gt;Your eval platform deletes your results in 14 days&lt;/a&gt; — the retention argument in full.&lt;/li&gt;
&lt;li&gt;Sibling post: &lt;a href="https://docs.oryxflow.dev/blog/ai-agents/llm-evals-are-a-parameter-sweep/" rel="noopener noreferrer"&gt;LLM evals are a parameter sweep&lt;/a&gt; — why the tooling already exists.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/blog/caching/parameter-sweeps-without-rerunning/" rel="noopener noreferrer"&gt;Parameter sweeps without rerunning upstream steps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.oryxflow.dev/docs/managing-workflows/" rel="noopener noreferrer"&gt;Managing complex workflows&lt;/a&gt; — the event stream and reset scopes.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.oryxflow.dev/docs/collaborate/" rel="noopener noreferrer"&gt;Collaborate and share results&lt;/a&gt; — Git LFS and env=.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;oryxflow on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>LLM evals are a parameter sweep — use a parameter sweep tool</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Wed, 26 Aug 2026 17:35:55 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/llm-evals-are-a-parameter-sweep-use-a-parameter-sweep-tool-f5o</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/llm-evals-are-a-parameter-sweep-use-a-parameter-sweep-tool-f5o</guid>
      <description>&lt;p&gt;&lt;em&gt;The scoring is genuinely new. The matrix underneath it is a solved problem from 2015.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three questions teams actually ask about their LLM systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is my classifier right?&lt;/li&gt;
&lt;li&gt;Did my prompt change help?&lt;/li&gt;
&lt;li&gt;Is the cheap model good enough?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They feel like three different projects. They're one shape: &lt;strong&gt;a labeled dataset, run under N configurations, compared.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Write that out as a grid — cases × scorers × models × prompt variants × repetitions — and you have described a Cartesian product over parameters. A parameter sweep. Data science has had good tooling for parameter sweeps for a decade, built around one idea: cache one output per parameter set, and when a value changes, recompute exactly the cells that value touches.&lt;/p&gt;

&lt;p&gt;The LLM eval space rebuilt that layer as SaaS, with a meter on it.&lt;/p&gt;

&lt;p&gt;Some of what those platforms sell is genuinely new and genuinely worth paying for. Traces of an agent's tool calls, a UI for clicking through failures, span-level debugging — that's a hard problem, well solved, and not something you want to rebuild. But the &lt;em&gt;matrix&lt;/em&gt; isn't LLM-specific, and neither is the persistence. Those are the parts where treating evals as a normal data-science sweep pays off immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The division of labor
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pydantic.dev/docs/ai/evals/evals/" rel="noopener noreferrer"&gt;&lt;strong&gt;pydantic-evals&lt;/strong&gt;&lt;/a&gt; — scoring. Cases, evaluators, reports. This is the new part.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;&lt;strong&gt;oryxflow&lt;/strong&gt;&lt;/a&gt; — the matrix, the persistence, and the provenance. One cached output per parameter set, tied to the code and parameters that produced it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logfire&lt;/strong&gt; (or your platform of choice) — traces and the debugging UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing overlaps, which is the whole point. Each layer does one job and none of them needs to know about the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  The running example
&lt;/h2&gt;

&lt;p&gt;Every agent has an intent classifier somewhere. Ours decides whether an incoming query needs a live web search: two labels, &lt;code&gt;needs_web&lt;/code&gt; and &lt;code&gt;no_web&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two methodological points before code, because they're where evals usually go wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is classification, not a job for LLM-as-judge.&lt;/strong&gt; The output is one of two fixed strings. Exact match is the correct scorer. &lt;code&gt;LLMJudge&lt;/code&gt; exists for free-text outputs with no single right answer — using it here would add cost, latency and sampling noise to a comparison that was already deterministic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aggregate accuracy lies.&lt;/strong&gt; Your two errors aren't symmetric. A false positive — searching unnecessarily — costs a few hundred milliseconds. A false negative — not searching when you should have — means the agent answers a question about this morning's news from training data, confidently. That's the failure users screenshot. If &lt;code&gt;needs_web&lt;/code&gt; is 30% of traffic and you miss a tenth of it, headline accuracy reads 97% while recall on the class that actually matters is 90%.&lt;/p&gt;

&lt;p&gt;pydantic-evals handles this without a custom scorer. &lt;code&gt;ConfusionMatrixEvaluator&lt;/code&gt; and &lt;code&gt;PrecisionRecallEvaluator&lt;/code&gt; are &lt;em&gt;report-level&lt;/em&gt; evaluators — they run once over the whole experiment rather than per case — and you pass them via &lt;code&gt;report_evaluators&lt;/code&gt; on the &lt;code&gt;Dataset&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_evals&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dataset&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_evals.evaluators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;EqualsExpected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ConfusionMatrixEvaluator&lt;/span&gt;

&lt;span class="n"&gt;CASES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;weather_now&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;what is the weather in Lisbon right now&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reverse_list&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;how do I reverse a list in python&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;latest_release&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;what shipped in the newest postgres release&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;explain_tcp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;explain the TCP handshake&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;dataset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web_v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CASES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;evaluators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;EqualsExpected&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
    &lt;span class="n"&gt;report_evaluators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;ConfusionMatrixEvaluator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;predicted_from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;expected_from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected_output&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web vs no_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The sweep
&lt;/h2&gt;

&lt;p&gt;Everything you vary becomes a task parameter. oryxflow keys the cached output on those values, so each combination gets its own stored result — no filenames to invent, no &lt;code&gt;results_sonnet_v3_final2.json&lt;/code&gt; graveyard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EvalRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5-20260501&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;prompt_version&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dataset_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web_v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;classify&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_classifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_version&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;case&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;predicted&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;predicted&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveMeta&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;accuracy&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;recall_needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save &lt;strong&gt;per-case rows&lt;/strong&gt;, not a summary. A stored accuracy number can only answer the question you thought of at the time. Per-case rows let you slice by class, by case, by query type, six months later, without re-running a thing.&lt;/p&gt;

&lt;p&gt;The grid is a &lt;code&gt;requires()&lt;/code&gt; that returns a dict. &lt;code&gt;inputLoadConcat()&lt;/code&gt; row-stacks the dependencies and tags each with the parameters of the task it came from, so &lt;code&gt;model&lt;/code&gt; and &lt;code&gt;prompt_version&lt;/code&gt; arrive as columns:&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;MODELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-haiku-4-5&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5-20260501&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-opus-5&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;PROMPTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EvalMatrix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;EvalRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MODELS&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROMPTS&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoadConcat&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EvalMatrix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;prompt_version&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# the headline
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# what matters
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two properties you inherit for free
&lt;/h2&gt;

&lt;p&gt;Here's why this framing pays, beyond tidiness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trustworthy comparison.&lt;/strong&gt; This is the one people underrate. If the dataset build is itself a cached upstream task, every configuration reads the same output — not because you were careful, but because there is only one output to read. That kills the quiet failure mode where you regenerated the case set midway through a sweep and half your comparison is against a different denominator. The comparison is apples-to-apples by construction — the same reason &lt;a href="https://docs.oryxflow.dev/blog/caching/parameter-sweeps-without-rerunning/" rel="noopener noreferrer"&gt;parameter sweeps over shared features&lt;/a&gt; are trustworthy in ordinary ML work: the shared upstream is computed once, so there's nothing to accidentally desynchronize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Marginal cost.&lt;/strong&gt; Add a fourth model and only the fourth model runs. The six existing cells load from disk in milliseconds. For ordinary data work that's convenience — you saved some CPU. For LLM evals it's money, and it compounds with every axis: a 300-case set across three models and two prompts is 1,800 calls, and adding a model without caching means re-running all 2,400 instead of the 600 that are new. Under per-score metered billing you re-meter the cached cells too, so you pay twice for the privilege of not learning anything new.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ran, and why
&lt;/h2&gt;

&lt;p&gt;A score of 0.91 with no record of which prompt, which model string, which dataset revision is worthless in three months. You'll find it and be unable to defend it — which is how most eval setups actually die, long before retention expires.&lt;/p&gt;

&lt;p&gt;oryxflow writes an event stream as it runs: plain JSONL you can &lt;code&gt;jq&lt;/code&gt;. Every execution records parameters, code version, a fingerprint of the task's source, upstream hashes, git SHA, duration, and the &lt;em&gt;reason&lt;/em&gt; it ran.&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;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;EvalRun&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# diff params, code, hashes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Edit your scoring logic and the affected tasks recompute automatically, with a reason naming the changed symbol — &lt;code&gt;code change (auto: evals/run.py::EvalRun)&lt;/code&gt;. You can't compare an old-scorer score against a new-scorer score by accident, because the old one stops being a valid cache entry the moment the code changes.&lt;/p&gt;

&lt;p&gt;And it's durable in the boring sense: &lt;code&gt;data/&lt;/code&gt; under Git LFS versions your results alongside the code that made them. Use &lt;code&gt;env=&lt;/code&gt; to keep blessed baselines (&lt;code&gt;env='baseline'&lt;/code&gt;) apart from Tuesday's throwaway grid (&lt;code&gt;env='dev'&lt;/code&gt;), commit the scored parquet, and leave the megabyte traces in Logfire where the trace UI lives. Eval platform retention windows run &lt;a href="https://docs.oryxflow.dev/blog/ai-agents/llm-eval-results-deleted-retention/" rel="noopener noreferrer"&gt;14 to 30 days on the common plans&lt;/a&gt;; a git repo runs as long as the repo does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the analogy breaks
&lt;/h2&gt;

&lt;p&gt;A sweep over hyperparameters and a sweep over models aren't identical, and four differences matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The endpoint is outside the cache&lt;/strong&gt;. oryxflow hashes your code and inputs. It cannot see a provider updating weights behind a stable alias — so &lt;code&gt;claude-sonnet-5&lt;/code&gt; as a parameter value means your cache will serve July's numbers as current in December.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix:&lt;/em&gt; pin the snapshot — &lt;code&gt;claude-sonnet-5-20260501&lt;/code&gt;. The identity of what you measured is now part of the cache key, and a new snapshot is a new value that runs fresh while old numbers stay readable. To deliberately re-measure the same endpoint and check for drift, invalidate that family alone:&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;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset_upstream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EvalMatrix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EvalRun&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# every cell reruns; dataset build doesn't
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. The function isn't deterministic&lt;/strong&gt;. In a hyperparameter sweep, re-running a cell reproduces it. Here it doesn't. oryxflow guarantees a result came from the code and inputs it recorded — it is a faithful record of &lt;em&gt;one sampling&lt;/em&gt;, not a reproducible constant.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix:&lt;/em&gt; &lt;code&gt;temperature=0&lt;/code&gt; where supported, as a parameter so the record shows it. Then measure the residual variance instead of hoping about it — see point 4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Latency measurement fights the harness.&lt;/strong&gt; pydantic-evals runs cases concurrently by default, so wall-clock under a sweep measures your own queueing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix:&lt;/em&gt; a separate task with &lt;code&gt;max_concurrency=1&lt;/code&gt;, discard the first call, report p50/p95 rather than the mean — one tail call destroys a mean and tells you nothing about typical experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Repetitions are a design decision, not a detail.&lt;/strong&gt; Either the rep index is a parameter or it lives inside pydantic-evals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parameter&lt;/strong&gt; — each rep caches separately, so extending a three-rep study to five runs exactly two new evaluations. Bigger DAG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;repeat=&lt;/code&gt;in &lt;code&gt;evaluate_sync&lt;/code&gt;&lt;/strong&gt;— one cached unit covers all reps. Smaller DAG, coarser cache; changing the count re-runs everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When reps are expensive, take the parameter and let &lt;code&gt;WorkflowMulti&lt;/code&gt; fan out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LatencyRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5-20260501&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;n_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IntParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# rep index
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="n"&gt;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WorkflowMulti&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LatencyRun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rep&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;n_run&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputLoadConcat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# every rep, tagged, one DataFrame
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decide you need five reps? Extend the dict — reps 1–3 come from cache, only 4 and 5 call the API. That's the same marginal-cost property, applied to the axis most likely to grow after you've already run the study.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Score with pydantic-evals, because scoring LLM output well is a real problem and it solves it. Debug in Logfire, because trace UIs are worth paying for. But recognize the middle layer for what it is: a Cartesian product over parameters, with one cached result per cell.&lt;/p&gt;

&lt;p&gt;That's not an LLM problem. It's a parameter sweep, and you can have the marginal-cost economics and the apples-to-apples guarantee that come with treating it like one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;oryxflow pydantic-evals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Are LLM evals just a parameter sweep?
&lt;/h2&gt;

&lt;p&gt;Structurally, yes. "Is my classifier right", "did my prompt change help", and "is the cheap model good enough" are all the same shape underneath: a labeled dataset, run under N configurations, compared. Cases, scorers, models, prompt variants and repetitions are the axes of a grid, which makes an eval matrix a Cartesian product over parameters — exactly what parameter-sweep tooling has handled well for a decade. What is genuinely new is the scoring, and pydantic-evals covers that.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I make sure my model comparison is apples-to-apples?
&lt;/h2&gt;

&lt;p&gt;Share the upstream. If the dataset build is its own cached task that every configuration depends on, all models are scored against byte-for-byte identical cases — not because you were careful, but because there is only one dataset output to read. That removes the quiet failure where you regenerated the case set halfway through a sweep and half your comparison is against a different denominator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do I need an LLM eval platform, or can I use a normal workflow tool?
&lt;/h2&gt;

&lt;p&gt;You need both, for different jobs. A platform earns its cost for live traces and a UI to click through failures, which is a real problem well solved. The matrix and the persistence are not LLM-specific — one cached output per parameter set is ordinary data-science plumbing, and doing it with a caching workflow engine means adding a model to your grid runs only that model instead of re-metering cells whose results already exist on your disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should the repetition index be a task parameter in an LLM eval?
&lt;/h2&gt;

&lt;p&gt;Make it a parameter when repetitions are expensive. Each rep then caches separately, so extending a three-rep study to five runs exactly two new evaluations instead of all five. The trade-off is a larger DAG. If repetitions are cheap, use pydantic-evals' &lt;code&gt;repeat&lt;/code&gt; argument instead — one cached unit covers every rep, giving a smaller graph but a coarser cache, so changing the count re-runs the whole thing.&lt;/p&gt;

&lt;p&gt;Then keep going:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sibling post: &lt;a href="https://docs.oryxflow.dev/blog/ai-agents/llm-eval-results-deleted-retention/" rel="noopener noreferrer"&gt;Your eval platform deletes your results in 14 days&lt;/a&gt; — the retention argument.&lt;/li&gt;
&lt;li&gt;Sibling post: &lt;a href="https://docs.oryxflow.dev/blog/guides/cheap-durable-llm-evals/" rel="noopener noreferrer"&gt;Cheap, durable LLM evals: pydantic-evals + oryxflow&lt;/a&gt; — the cost arithmetic and the step-by-step build.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/blog/caching/parameter-sweeps-without-rerunning/" rel="noopener noreferrer"&gt;Parameter sweeps without rerunning upstream steps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.oryxflow.dev/docs/managing-workflows/" rel="noopener noreferrer"&gt;Managing complex workflows&lt;/a&gt; — the event stream and reset scopes.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/docs/why-oryxflow/" rel="noopener noreferrer"&gt;Why oryxflow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;oryxflow on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>datascience</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Your eval platform deletes your results in 14 days</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Tue, 25 Aug 2026 17:58:58 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/your-eval-platform-deletes-your-results-in-14-days-5g9n</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/your-eval-platform-deletes-your-results-in-14-days-5g9n</guid>
      <description>&lt;p&gt;&lt;em&gt;LLM evals are a parameter sweep. Use a parameter sweep tool.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You benchmarked four models in July. In August the numbers are gone.&lt;/p&gt;

&lt;p&gt;Nothing broke. No one deleted anything. Retention expired.&lt;/p&gt;

&lt;p&gt;Here's the landscape as of July 2026:&lt;/p&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo0k9ifwiu6f8plxusb63.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo0k9ifwiu6f8plxusb63.png" alt="a table showing Platform  Free tier   Paid&lt;br&gt;
Logfire 30 days up to 90 days (Growth); custom on Enterprise&lt;br&gt;
Braintrust  14 days (Starter)   30 days (Pro, $249/mo); custom on Enterprise"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This isn't a criticism of either product. It's a statement about what they are: &lt;strong&gt;eval platforms are working sets, not archives&lt;/strong&gt;. They're optimized for the thing you're debugging &lt;em&gt;this week&lt;/em&gt; — live traces, a UI to click through failures, diffs against yesterday's run. That's a genuinely hard problem and both solve it well.&lt;/p&gt;

&lt;p&gt;But the thing that expires is &lt;em&gt;tiny&lt;/em&gt;. A few hundred cases across a few dozen experiments, stored as scored rows, is kilobytes. There is no technical reason to lose it. It expires because storage duration is a pricing lever, which is a perfectly reasonable business decision and a terrible fit for the question "did the July prompt actually beat the June one?"&lt;/p&gt;

&lt;p&gt;Two details make the point sharper. Braintrust's own docs say cloud-storage export is &lt;a href="https://www.braintrust.dev/docs/admin/data-management/export" rel="noopener noreferrer"&gt;"only available on the Enterprise plan"&lt;/a&gt; — so getting your data out is itself a tier. And Pydantic's Logfire docs, to their credit, &lt;a href="https://pydantic.dev/docs/logfire/guides/otel-collector/s3-backup/" rel="noopener noreferrer"&gt;tell you the answer outright&lt;/a&gt;: if you need longer than 30 days, write to both Logfire and long-term storage such as S3. The vendor agrees with the premise. The only question is what "long-term storage" should look like for eval results specifically.&lt;/p&gt;
&lt;h2&gt;
  
  
  The reframe
&lt;/h2&gt;

&lt;p&gt;Three questions teams actually ask about their LLM systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is my classifier right?&lt;/li&gt;
&lt;li&gt;Did my prompt change help?&lt;/li&gt;
&lt;li&gt;Is the cheap model good enough?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Underneath, these are one shape: &lt;strong&gt;a labeled dataset, run under N configurations, compared.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's a parameter sweep. Data science has had good tooling for parameter sweeps for a decade — cache one output per parameter set, don't recompute what hasn't changed, keep the results next to the code that made them. The LLM eval space rebuilt that as SaaS, with a metered billing model attached to the scoring step.&lt;/p&gt;

&lt;p&gt;You don't have to pick a side. Split the job by what each layer is actually good at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pydantic.dev/docs/ai/evals/evals/" rel="noopener noreferrer"&gt;pydantic-evals&lt;/a&gt; — scoring. Cases, evaluators, reports.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;oryxflow&lt;/a&gt; — the matrix, the persistence, and the provenance. One cached output per parameter set, tied to the code and parameters that produced it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logfire&lt;/strong&gt; (or your platform of choice) — traces and the debugging UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing overlaps. That's the whole idea. pydantic-evals doesn't want to be a cache; oryxflow doesn't want to be a scorer; neither wants to render a waterfall of spans.&lt;/p&gt;
&lt;h2&gt;
  
  
  The running example: does this query need web search?
&lt;/h2&gt;

&lt;p&gt;Every agent has one of these. A router decides whether an incoming query needs a live web search or can be answered from the model's own knowledge. Two labels: &lt;code&gt;needs_web&lt;/code&gt;, &lt;code&gt;no_web&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Make the methodological call before writing any code, because this is where most eval posts go wrong: &lt;strong&gt;this is classification, not a job for LLM-as-judge.&lt;/strong&gt; The output is one of two fixed strings. Exact match is the correct scorer. An LLM judge here adds cost, latency, and sampling noise to a comparison that was already deterministic.&lt;/p&gt;

&lt;p&gt;Then the trap. Aggregate accuracy lies to you.&lt;/p&gt;

&lt;p&gt;Your two error types are not symmetric. A false positive — searching when you didn't need to — costs you a few hundred milliseconds and a fraction of a cent. A false negative — not searching when you needed to — means the agent answers a question about this morning's news from training data, confidently and wrongly. That's the failure users screenshot.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;needs_web&lt;/code&gt; is 30% of your traffic and you miss a tenth of it, that's 3% of cases. Your accuracy reads 97%. The number that matters — recall on &lt;code&gt;needs_web&lt;/code&gt; — is 90%, and it's invisible in the headline.&lt;/p&gt;

&lt;p&gt;The good news: you don't have to write a custom scorer for this. pydantic-evals ships &lt;code&gt;ConfusionMatrixEvaluator&lt;/code&gt; and &lt;code&gt;PrecisionRecallEvaluator&lt;/code&gt; as &lt;em&gt;report-level&lt;/em&gt; evaluators — they run once over the whole experiment rather than per case — and you pass them via &lt;code&gt;report_evaluators&lt;/code&gt; on the &lt;code&gt;Dataset&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_evals&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dataset&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_evals.evaluators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;EqualsExpected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ConfusionMatrixEvaluator&lt;/span&gt;

&lt;span class="n"&gt;CASES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;weather_now&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;what is the weather in Lisbon right now&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reverse_list&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;how do I reverse a list in python&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;latest_release&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;what shipped in the newest postgres release&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;explain_tcp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;explain the TCP handshake&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;dataset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web_v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CASES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;evaluators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;EqualsExpected&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
    &lt;span class="n"&gt;report_evaluators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;ConfusionMatrixEvaluator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;predicted_from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;expected_from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected_output&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web vs no_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the scoring layer, complete. It knows nothing about models, caching, or where results live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring the matrix
&lt;/h2&gt;

&lt;p&gt;Now the sweep. One task, parameterized by the things you vary, saving one row per case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EvalRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5-20260501&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;prompt_version&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dataset_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web_v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;classify&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_classifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_version&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;case&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;predicted&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;predicted&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveMeta&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;accuracy&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;recall_needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;n_failures&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;acc={} recall_needs_web={}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                         &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# confusion matrix, in your terminal
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the matrix itself. &lt;code&gt;requires()&lt;/code&gt; returning a dict fans out over every combination, and &lt;code&gt;inputLoadConcat()&lt;/code&gt; stacks the results:&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;MODELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-haiku-4-5&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5-20260501&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-opus-5&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;PROMPTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EvalMatrix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;EvalRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MODELS&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROMPTS&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoadConcat&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inputLoadConcat()&lt;/code&gt; tags each dependency's rows with that task's parameters, so &lt;code&gt;model&lt;/code&gt; and &lt;code&gt;prompt_version&lt;/code&gt; come back as &lt;strong&gt;columns&lt;/strong&gt; — no manual bookkeeping, no filename parsing:&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;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EvalMatrix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;prompt_version&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# the headline
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;needs_web&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# the number that matters
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;Add a fourth model to the grid and &lt;strong&gt;only the fourth model runs&lt;/strong&gt;. The six existing cells load from disk in milliseconds; the two new ones call the API.&lt;/p&gt;

&lt;p&gt;For ordinary data work that's convenience — you saved a few minutes of CPU. For LLM evals it's &lt;em&gt;money&lt;/em&gt;, and the arithmetic is worth doing explicitly. Take a 300-case set, three models, two prompts: six cells, 1,800 API calls, and however many scores your platform meters. Adding a model without caching means re-running all 2,400 calls. With caching it's 600. You pay for a quarter of the work because three quarters of it hasn't changed.&lt;/p&gt;

&lt;p&gt;Contrast that with per-score metered billing, where re-running the matrix re-meters every cell — including the five you already paid for last week and whose results are byte-identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The provenance you get without asking
&lt;/h2&gt;

&lt;p&gt;Most eval setups rot for a reason that has nothing to do with retention: a score of 0.91 with no record of &lt;em&gt;which&lt;/em&gt; prompt, &lt;em&gt;which&lt;/em&gt; model string, &lt;em&gt;which&lt;/em&gt; dataset revision is worthless in three months. You'll find the number in a spreadsheet and be unable to defend it.&lt;/p&gt;

&lt;p&gt;oryxflow writes an event stream as you go — plain JSONL you can &lt;code&gt;jq&lt;/code&gt;. Each task execution records its parameters, code version, a fingerprint of the task's source, upstream output hashes, the git SHA, the duration, and the &lt;em&gt;reason&lt;/em&gt; it ran:&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;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;EvalRun&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# diff params, code, hashes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last field is the one people underrate. Edit your scoring logic and the affected tasks recompute automatically, with a reason naming the changed symbol — &lt;code&gt;code change (auto: evals/run.py::EvalRun)&lt;/code&gt;. You can't accidentally compare a v2 score computed under the old scorer against a v3 score computed under the new one, because the old one no longer exists as a valid cache entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing it
&lt;/h2&gt;

&lt;p&gt;Put &lt;code&gt;data/&lt;/code&gt; under Git LFS. The Claude Code plugin does it in one step with &lt;code&gt;/oryxflow:init-gitlfs&lt;/code&gt;; by hand it's &lt;code&gt;git lfs track "data/**"&lt;/code&gt;. Now your eval results version alongside the code that produced them, and a teammate who clones the repo gets the numbers without re-running anything.&lt;/p&gt;

&lt;p&gt;Two things people get wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;env=&lt;/code&gt; to separate blessed baselines from scratch sweeps.&lt;/strong&gt; &lt;code&gt;oryxflow.Workflow(EvalMatrix, env='baseline')&lt;/code&gt; writes under &lt;code&gt;data/env=baseline/;&lt;/code&gt; your ad-hoc Tuesday-afternoon grid goes to &lt;code&gt;env='dev'&lt;/code&gt; and never enters LFS. Skip this and LFS bloats with abandoned experiments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit the scored rows, not the traces.&lt;/strong&gt; Per-case parquet is kilobytes. Raw spans are megabytes and belong in Logfire, where the UI for reading them lives. GitHub's free LFS quota is 1 GB with bandwidth metered separately — easy to stay inside if you're storing scores, easy to blow through if you're storing traces.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest section
&lt;/h2&gt;

&lt;p&gt;Four things this setup does not solve, and what to do about each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The model endpoint is a cache blind spot.&lt;/strong&gt; oryxflow hashes your code and your inputs. It cannot see that a provider updated the weights behind a stable alias. Point &lt;code&gt;EvalRun&lt;/code&gt; at &lt;code&gt;claude-sonnet-5&lt;/code&gt; and your cache will confidently serve July's numbers as current in December.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix:&lt;/em&gt; put the pinned snapshot id in the parameter — &lt;code&gt;claude-sonnet-5-20260501&lt;/code&gt;, not the alias. Now the identity of the thing you measured is part of the cache key, and moving to a new snapshot is a new parameter value that runs fresh while the old numbers stay readable. When you deliberately want to re-measure the &lt;em&gt;same&lt;/em&gt; endpoint — checking for drift, say — invalidate just that family:&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;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset_upstream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EvalMatrix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EvalRun&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# every cell reruns; nothing else does
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. You're caching a stochastic process.&lt;/strong&gt; oryxflow guarantees a result came from the code and inputs it recorded. It does not guarantee that re-running produces the same numbers. A cached run is a faithful record of one &lt;em&gt;sampling&lt;/em&gt;, not a reproducible constant.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix:&lt;/em&gt; set &lt;code&gt;temperature=0&lt;/code&gt; where the provider supports it and make it a parameter so the record shows it. Accept that the remaining variance is real, and if it's large enough to change your decision, measure it — which is caveat 4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Latency needs care.&lt;/strong&gt; pydantic-evals runs cases concurrently by default. Measure wall-clock under that and you're measuring your own queueing, not the model.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix:&lt;/em&gt; a dedicated task with &lt;code&gt;max_concurrency=1&lt;/code&gt;, discard the first call as warm-up, and report p50/p95 rather than the mean — one slow tail call wrecks a mean and tells you nothing about typical experience.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LatencyRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5-20260501&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;n_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IntParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# rep index
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;timings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;classify&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;build_classifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_concurrency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;                &lt;span class="c1"&gt;# drop warm-up
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;latency_s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveMeta&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;p50&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;(.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;p95&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;(.&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;))})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Repetitions.&lt;/strong&gt; You have to decide whether the rep index is a &lt;em&gt;parameter&lt;/em&gt; or lives inside pydantic-evals. Both are defensible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rep as a parameter&lt;/strong&gt; (&lt;code&gt;n_run&lt;/code&gt; above) — each repetition caches separately, so adding reps 4 and 5 to a three-rep study runs exactly two new evaluations. The DAG gets bigger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;repeat&lt;/code&gt;= inside &lt;code&gt;evaluate_sync&lt;/code&gt;&lt;/strong&gt; — one cached unit covers all reps, smaller DAG, but changing the rep count re-runs everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prefer the parameter when reps are expensive, and fan out with &lt;code&gt;WorkflowMulti&lt;/code&gt;:&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;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WorkflowMulti&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LatencyRun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rep&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;n_run&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputLoadConcat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# every rep, tagged, in one DataFrame
&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;latency_s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;([.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decide you need five reps instead of three? Extend the dict. Reps 1–3 load from cache; only 4 and 5 hit the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;An LLM eval is a labeled dataset, run under N configurations, compared. That's a parameter sweep, and parameter sweeps have had good tooling for a decade.&lt;/p&gt;

&lt;p&gt;Score with pydantic-evals, because it's a well-designed scoring library. Debug in Logfire, because a trace UI is genuinely worth paying for. But keep the scored rows yourself, cached by parameter set, versioned next to the code that made them — because they're kilobytes, they're the actual output of your work, and no retention policy should be able to decide when you stop being able to answer "did that change help?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;oryxflow pydantic-evals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  How long do LLM eval platforms keep my results?
&lt;/h2&gt;

&lt;p&gt;Not long, by design. As of July 2026 Logfire retains 30 days on its free and Team plans and up to 90 days on Growth; Braintrust retains 14 days on its free Starter plan and 30 days on Pro at $249/month. Longer windows are Enterprise-priced. These are working sets, not archives — if you want a July benchmark still readable in December, you have to keep a copy yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I keep LLM eval results permanently without paying for longer retention?
&lt;/h2&gt;

&lt;p&gt;Save the scored per-case rows to your own repo and treat the eval matrix as a cached parameter sweep. Score with pydantic-evals, then wrap each (model, prompt) run in an oryxflow task so the result is cached on disk keyed by its parameters, versioned with Git LFS, and still readable years later. The scored rows are kilobytes; leave the bulky raw traces in your observability platform where the debugging UI lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use LLM-as-judge to evaluate an intent classifier?
&lt;/h2&gt;

&lt;p&gt;No. When the output is one of a fixed set of labels, exact match against the expected label is the correct scorer — an LLM judge adds cost, latency and noise to a comparison that is already deterministic. Use &lt;code&gt;EqualsExpected&lt;/code&gt; for the per-case score and a &lt;code&gt;ConfusionMatrixEvaluator&lt;/code&gt; report evaluator for the class breakdown, because aggregate accuracy hides the error that actually costs you: the false negative that stops the agent from searching.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I avoid re-running my whole eval matrix when I add one model?
&lt;/h2&gt;

&lt;p&gt;Make each cell of the matrix a separately cached unit keyed by its parameters. In oryxflow an &lt;code&gt;EvalRun&lt;/code&gt; task parameterized by &lt;code&gt;model&lt;/code&gt; and &lt;code&gt;prompt_version&lt;/code&gt; caches one output per combination, so adding a fifth model to a four-model grid runs only the fifth model's cases — the other four load from disk. The saving is real money, because with per-score metered billing re-running the matrix re-meters every cell you already paid for.&lt;/p&gt;

&lt;p&gt;Then keep going:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sibling post: &lt;a href="https://docs.oryxflow.dev/blog/ai-agents/llm-evals-are-a-parameter-sweep/" rel="noopener noreferrer"&gt;LLM evals are a parameter sweep&lt;/a&gt; — the same argument from the tooling side.&lt;/li&gt;
&lt;li&gt;Sibling post: &lt;a href="https://docs.oryxflow.dev/blog/guides/cheap-durable-llm-evals/" rel="noopener noreferrer"&gt;Cheap, durable LLM evals: pydantic-evals + oryxflow&lt;/a&gt; — the step-by-step build.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/blog/caching/parameter-sweeps-without-rerunning/" rel="noopener noreferrer"&gt;Parameter sweeps without rerunning upstream steps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.oryxflow.dev/docs/managing-workflows/" rel="noopener noreferrer"&gt;Managing complex workflows&lt;/a&gt; — the event stream and reset scopes.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.oryxflow.dev/docs/collaborate/" rel="noopener noreferrer"&gt;Collaborate and share results&lt;/a&gt; — Git LFS and env=.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;oryxflow on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>When not to use oryxflow</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Fri, 21 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/when-not-to-use-oryxflow-a40</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/when-not-to-use-oryxflow-a40</guid>
      <description>&lt;p&gt;&lt;em&gt;Being clear about where a tool doesn't fit is part of being trustworthy — so here's where oryxflow is the wrong choice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;oryxflow is a local, zero-infrastructure library for the research loop: it ties every result to the code and inputs that produced it, tracks lineage, and reruns exactly what a change affects — so a number can't quietly sit on stale data and you can regenerate any result months later, without a server, a scheduler, or a database. (It reuses the steps that didn't change, which is why none of that costs you rerun time.) That's genuinely useful — but only for a specific shape of problem. Every honest tool has a boundary, and pretending oryxflow fits everywhere would waste your time and cost you trust. So here is where it doesn't fit, and what to reach for instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick one-off doesn't need a pipeline — but you can still start here
&lt;/h2&gt;

&lt;p&gt;If your whole analysis is "load a CSV, group by a column, plot one thing," you don't need task classes around it. Five lines you'll run once get no payoff from caching, and wrapping them in a DAG is ceremony.&lt;/p&gt;

&lt;p&gt;The value of a caching DAG rises with three things: &lt;strong&gt;depth&lt;/strong&gt; (how many dependent steps), &lt;strong&gt;cost&lt;/strong&gt; (how expensive each step is), and &lt;strong&gt;breadth&lt;/strong&gt; (how many parameter combinations you sweep). For a five-line notebook cell, all three are near zero, so the return is near zero too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use instead&lt;/strong&gt;: a plain script for the first pass — but keep it &lt;em&gt;inside&lt;/em&gt; the project, not in a scratch folder you'll abandon. If you build with the &lt;a href="https://docs.oryxflow.dev/docs/claude-plugin/" rel="noopener noreferrer"&gt;oryxflow Claude Code plugin&lt;/a&gt;, that's already the convention: exploration lives as a read-only probe at &lt;code&gt;eda/&amp;lt;subject&amp;gt;/&amp;lt;name&amp;gt;.py&lt;/code&gt;, one line of docstring stating the question it answers, printing the answer legibly, run with &lt;code&gt;python -m eda.&amp;lt;subject&amp;gt;.&amp;lt;name&amp;gt;&lt;/code&gt;. A probe writes no pipeline artifact — disposable scratch goes to a gitignored scratch area — and anything material it turns up gets written into the project's data doc, so a question you've answered once isn't re-asked next session.&lt;/p&gt;

&lt;p&gt;Then, when a script turns out to be load-bearing — you keep re-running it, something depends on its output, or you start sweeping it over parameters — you don't rewrite it by hand. &lt;code&gt;/oryxflow:migrate&lt;/code&gt; reads the script as the spec, shows you a step-to-task map, writes only on your approval, and never deletes the source. So there's no cliff between "exploring" and "having a pipeline": &lt;strong&gt;you can start with a small EDA and let it scale to any complexity, with no rewrite in between&lt;/strong&gt;. More on both ends: &lt;a href="https://docs.oryxflow.dev/docs/migrate-notebook-to-pipeline/" rel="noopener noreferrer"&gt;migrate a notebook to a pipeline&lt;/a&gt; and &lt;a href="https://docs.oryxflow.dev/docs/claude-code-for-data-science/" rel="noopener noreferrer"&gt;Claude Code for data science&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't reach for oryxflow when… you need production orchestration
&lt;/h2&gt;

&lt;p&gt;Scheduled runs at 6am, retries across a cluster, backfills over a date range, alerting when a job fails, SLAs your team is on the hook for — that's production operations, and oryxflow doesn't do it. There's no scheduler, no distributed retry, no alerting, and no operational UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use instead&lt;/strong&gt;: &lt;a href="https://airflow.apache.org/" rel="noopener noreferrer"&gt;Airflow&lt;/a&gt;, &lt;a href="https://www.prefect.io/" rel="noopener noreferrer"&gt;Prefect&lt;/a&gt;, or &lt;a href="https://dagster.io/" rel="noopener noreferrer"&gt;Dagster&lt;/a&gt;. These are excellent at what they do, and they do a &lt;em&gt;different&lt;/em&gt; job than oryxflow — they orchestrate operations; oryxflow makes the research loop that happens before anything is scheduled trustworthy and reproducible. Many teams develop logic in oryxflow and later wrap the finished pipeline in one of these for production. They're complementary, not competitors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't reach for oryxflow when… you need distributed or very-large-scale execution
&lt;/h2&gt;

&lt;p&gt;If a single step needs a Kubernetes cluster, or your data doesn't fit on one machine and you need engine-level parallelism, the OSS core isn't built for that. It's local-first and runs in-process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use instead&lt;/strong&gt;: &lt;a href="https://flyte.org/" rel="noopener noreferrer"&gt;Flyte&lt;/a&gt; or &lt;a href="https://metaflow.org/" rel="noopener noreferrer"&gt;Metaflow&lt;/a&gt; on Linux or WSL. (oryxflow's paid Pro tier adds SQL, cloud storage, Dask, and PySpark backends — but the open-source core is deliberately local-first, and that's the right lens for evaluating fit here.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't reach for oryxflow when… you want an experiment dashboard
&lt;/h2&gt;

&lt;p&gt;If what you need is a searchable web UI showing every run's metrics, params, and charts side by side — sortable, filterable, shareable with your team — oryxflow doesn't provide it. It gives you a queryable lineage log, not a hosted dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use instead&lt;/strong&gt;: &lt;a href="https://mlflow.org/" rel="noopener noreferrer"&gt;MLflow&lt;/a&gt; or &lt;a href="https://wandb.ai/" rel="noopener noreferrer"&gt;Weights &amp;amp; Biases&lt;/a&gt;. And note these &lt;em&gt;compose&lt;/em&gt; with oryxflow: log your metrics to MLflow from inside a task's &lt;code&gt;run()&lt;/code&gt;, and let oryxflow handle the caching and reproducibility around it. You don't pick one — you use both, each for its strength. (More on that split in &lt;a href="https://docs.oryxflow.dev/blog/mlops/mlflow-vs-pipeline-caching/" rel="noopener noreferrer"&gt;MLflow, or a reproducible pipeline&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't reach for oryxflow when… you need Git-tied data versioning
&lt;/h2&gt;

&lt;p&gt;If your goal is versioning large data artifacts alongside your code, pinned to Git commits and pushed to remote storage, that's a different discipline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use instead&lt;/strong&gt;: &lt;a href="https://dvc.org/" rel="noopener noreferrer"&gt;DVC&lt;/a&gt;. It also composes with oryxflow — DVC for artifact versioning, oryxflow for the compute graph on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveat: oryxflow does not check that your result is &lt;em&gt;correct&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;This is the one worth reading twice. oryxflow guarantees that an output was produced by the exact code and inputs it recorded — it makes your pipeline &lt;em&gt;reproducible&lt;/em&gt;. It does &lt;strong&gt;not&lt;/strong&gt; guarantee the result is &lt;em&gt;right&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;It will happily cache, with full lineage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a join that silently went many-to-many when it should have been many-to-one,&lt;/li&gt;
&lt;li&gt;a test set that leaked into training,&lt;/li&gt;
&lt;li&gt;a ratio computed against the wrong denominator,&lt;/li&gt;
&lt;li&gt;a timestamp shifted by a timezone you forgot to normalize,&lt;/li&gt;
&lt;li&gt;a backtest that peeks at data from the future.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is reproducible, lineage-tracked, and wrong. oryxflow manages pipeline &lt;em&gt;mechanics&lt;/em&gt;; it has no opinion about statistical &lt;em&gt;judgment&lt;/em&gt;. Those bugs are caught by habit — sanity checks, held-out validation, reading your own numbers skeptically — not by any caching machinery. If a post ever tells you a workflow tool makes your analysis correct, close the tab. For where this boundary lives, see &lt;a href="https://docs.oryxflow.dev/docs/managing-workflows/" rel="noopener noreferrer"&gt;what caching does not protect against&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it &lt;em&gt;is&lt;/em&gt; the right tool
&lt;/h2&gt;

&lt;p&gt;With the boundaries drawn honestly, the fit is clear. oryxflow earns its keep when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a result has to be &lt;strong&gt;defensible&lt;/strong&gt; — you'll hand it to someone, or make a decision on it,&lt;/li&gt;
&lt;li&gt;you need to &lt;strong&gt;reproduce or hand off&lt;/strong&gt; that research months later,&lt;/li&gt;
&lt;li&gt;your pipeline is a &lt;strong&gt;deep chain&lt;/strong&gt; of dependent steps, where staleness has somewhere to hide,&lt;/li&gt;
&lt;li&gt;some of those steps are &lt;strong&gt;expensive&lt;/strong&gt; (minutes to hours), so rerunning everything to be sure isn't an option,&lt;/li&gt;
&lt;li&gt;you sweep a &lt;strong&gt;matrix of parameters&lt;/strong&gt; and want every configuration compared against the same upstream, and&lt;/li&gt;
&lt;li&gt;pipelines are &lt;strong&gt;authored by AI agents&lt;/strong&gt; that benefit from an explicit, inspectable task graph.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A typical task is small and declarative — declare dependencies, load inputs, save outputs, and the engine reruns what a change affects and reuses the rest:&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="nd"&gt;@oryxflow.requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CleanData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;add_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Features&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the sweet spot: results that have to hold up, in a pipeline deep and expensive enough that you'd never verify them by rerunning the whole thing from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Start a quick exploration as a plain script — inside an oryxflow project, where &lt;code&gt;/oryxflow:migrate&lt;/code&gt; can promote it the day it earns a pipeline. Use Airflow, Prefect, or Dagster for production ops. Use Flyte or Metaflow for distributed scale. Use MLflow or W&amp;amp;B for dashboards, DVC for data versioning — and compose them with oryxflow where it helps. And never expect any of them, oryxflow included, to check your statistics for you.&lt;/p&gt;

&lt;p&gt;Being honest about fit is the whole point: reach for oryxflow when you have a research pipeline — or the first small script that might become one — and reach for something else when the job is scheduling, scaling out, or display.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;oryxflow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  When should I not use oryxflow?
&lt;/h2&gt;

&lt;p&gt;Skip oryxflow for production orchestration (scheduling, retries, alerting — use Airflow, Prefect, or Dagster), for distributed or larger-than-memory execution (Flyte or Metaflow), for a hosted experiment dashboard (MLflow or W&amp;amp;B), and for Git-tied data versioning (DVC). oryxflow is a local, zero-infrastructure library for making research results trustworthy and reproducible; it reruns exactly what a change affects and reuses the rest, but it doesn't schedule, scale out, or display.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is oryxflow overkill for a quick one-off analysis?
&lt;/h2&gt;

&lt;p&gt;No — a five-line cell you run once doesn't need task classes, and you don't have to decide upfront either way. If you build with the oryxflow Claude Code plugin, exploration gets a home in the project from the start: a read-only probe script that states the question it answers and still runs next session, instead of a snippet you lose. When a probe turns out to be load-bearing — you keep re-running it, or something downstream depends on its result — /oryxflow:migrate lifts it into cached, parameterized tasks and never deletes the original. So you can start small in oryxflow and scale to any complexity, with no rewrite in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does oryxflow check that my analysis is correct?
&lt;/h2&gt;

&lt;p&gt;No — and no workflow tool does. oryxflow guarantees an output was produced by the exact code and inputs it recorded, which makes your pipeline reproducible, not correct. It will happily cache, with full lineage, a leaked test set, a bad join, or a backtest that peeks at the future. Those bugs are caught by sanity checks, held-out validation, and reading your own numbers skeptically — not by any caching machinery.&lt;/p&gt;

&lt;p&gt;Read next: &lt;a href="https://docs.oryxflow.dev/docs/why-oryxflow/" rel="noopener noreferrer"&gt;Why oryxflow&lt;/a&gt; · &lt;a href="https://docs.oryxflow.dev/docs/managing-workflows/" rel="noopener noreferrer"&gt;Managing complex workflows&lt;/a&gt; · &lt;a href="https://docs.oryxflow.dev/blog/comparisons/oryxflow-vs-pipeline-frameworks/" rel="noopener noreferrer"&gt;oryxflow vs the field&lt;/a&gt; · &lt;a href="https://docs.oryxflow.dev/blog/mlops/mlflow-vs-pipeline-caching/" rel="noopener noreferrer"&gt;MLflow, or a reproducible pipeline &lt;/a&gt;· &lt;a href="https://docs.oryxflow.dev/docs/claude-plugin/" rel="noopener noreferrer"&gt;Claude plugin&lt;/a&gt; · &lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to turn a messy notebook into a reproducible pipeline, one step at a time</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Thu, 20 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/how-to-turn-a-messy-notebook-into-a-reproducible-pipeline-one-step-at-a-time-j4o</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/how-to-turn-a-messy-notebook-into-a-reproducible-pipeline-one-step-at-a-time-j4o</guid>
      <description>&lt;p&gt;&lt;em&gt;It was supposed to be a quick analysis. Turn it into a pipeline you can trust — one step at a time, without a big rewrite.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A notebook is the best place to start an analysis and the worst place to keep one.&lt;/p&gt;

&lt;p&gt;While you are exploring, cells run out of order, variables linger in memory long after the cell that defined them was edited, and half your intermediate results are stale copies from an hour ago. It all still works — until you reopen the notebook a week later, hit "Run All," and get a different number. Now you are stuck asking the question every data scientist eventually asks: &lt;em&gt;which version of the code and which data actually produced this result?&lt;/em&gt; If you cannot answer that, you cannot trust the result, and you cannot reproduce it for anyone else.&lt;/p&gt;

&lt;p&gt;The usual culprits are hidden state (a variable that only exists because you ran a cell you have since deleted), out-of-order execution, and stale intermediates that never got refreshed after an upstream change. None of these show up as errors. They just quietly make your output wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;oryxflow is a small Python library that ties every result to the exact code and parameters that produced it.&lt;/strong&gt; You declare each step of your analysis as a task — with its parameters and its dependencies — and the library runs them in the right order and reruns a step whenever its code or inputs actually change, so nothing you look at is quietly built on something you edited an hour ago. That is reproducibility you get for free, not reproducibility you have to remember to maintain. And because the steps that &lt;em&gt;didn't&lt;/em&gt; change are never recomputed, you get it without paying for it in rerun time.&lt;/p&gt;

&lt;p&gt;The good news: you do not rewrite your notebook to get there. You migrate it one step at a time, and you have a working pipeline after every single step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the linear script
&lt;/h2&gt;

&lt;p&gt;Here is a typical notebook, flattened into the script it really is: load data, build features, train a model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.linear_model&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LinearRegression&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;raw.parquet&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# slow: pulls a big table
&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x_squared&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;               &lt;span class="c1"&gt;# feature engineering
&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x_squared&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LinearRegression&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time you tweak the model, that first line pulls the whole table again. And nothing records that &lt;em&gt;this&lt;/em&gt; model came from that data and that feature code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Convert the first expensive step into a task
&lt;/h2&gt;

&lt;p&gt;Do not convert everything. Start with the one step that hurts most — usually the slow load. Wrap it in a task class, and replace the return with &lt;code&gt;self.save(...)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GetData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="c1"&gt;# TaskPqPandas = saves a DataFrame as parquet
&lt;/span&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;raw.parquet&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;
&lt;span class="n"&gt;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GetData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                         &lt;span class="c1"&gt;# the saved DataFrame, back in your hands
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first &lt;code&gt;flow.run()&lt;/code&gt; does the slow load and caches the result. Every run after that is a cache hit — it loads from disk instead of re-fetching. Your remaining notebook cells keep working, now fed by &lt;code&gt;df = flow.outputLoad()&lt;/code&gt;. You have already gained something and rewritten almost nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire the next step with @oryxflow.requires
&lt;/h2&gt;

&lt;p&gt;Now pull feature engineering into its own task. The &lt;code&gt;@oryxflow.requires(GetData)&lt;/code&gt; decorator declares the dependency; inside &lt;code&gt;run&lt;/code&gt;, &lt;code&gt;self.inputLoad()&lt;/code&gt; hands you &lt;code&gt;GetData&lt;/code&gt;'s already-loaded output.&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="nd"&gt;@oryxflow.requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GetData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BuildFeatures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                  &lt;span class="c1"&gt;# GetData's output, already loaded
&lt;/span&gt;        &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x_squared&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x_squared&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You never call &lt;code&gt;GetData&lt;/code&gt; yourself. You ask for &lt;code&gt;BuildFeatures&lt;/code&gt;, and oryxflow runs its dependencies first, in order — no more "did I run the cells in the right sequence?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Finish the DAG
&lt;/h2&gt;

&lt;p&gt;Add the model as a final task. Models are not DataFrames, so save them with &lt;code&gt;TaskPickle&lt;/code&gt;. This is also where parameters earn their keep: expose the knobs you actually sweep.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.linear_model&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LinearRegression&lt;/span&gt;

&lt;span class="nd"&gt;@oryxflow.requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildFeatures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TrainModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPickle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="c1"&gt;# TaskPickle = pickle, for models &amp;amp; arbitrary objects
&lt;/span&gt;    &lt;span class="n"&gt;power&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IntParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fit_intercept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BoolParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x_squared&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LinearRegression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fit_intercept&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fit_intercept&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole pipeline: &lt;code&gt;GetData → BuildFeatures → TrainModel&lt;/code&gt;. Preview it before running anything:&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;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TrainModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                 &lt;span class="c1"&gt;# prints the task tree, runs nothing
&lt;/span&gt;&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;preview()&lt;/code&gt; shows you the execution plan — which steps will run and which are already cached — without touching your data. When you are ready, &lt;code&gt;run()&lt;/code&gt; executes only what is needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually gain
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reproducibility by construction.&lt;/strong&gt; Each task's output is stored under an id derived from its code and its parameters. Change the feature formula, and oryxflow knows &lt;code&gt;BuildFeatures&lt;/code&gt; is now different from the version that produced the cached file. "Which data made this result?" stops being a detective problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No wasted recompute.&lt;/strong&gt; Rerun the pipeline and nothing happens if the outputs already exist — cache hits all the way down. Edit &lt;code&gt;BuildFeatures&lt;/code&gt;, though, and oryxflow reruns it &lt;em&gt;and&lt;/em&gt; &lt;code&gt;TrainModel&lt;/code&gt; automatically, because the model depends on features that just changed. &lt;strong&gt;Code invalidation is automatic&lt;/strong&gt; — there is no cache to manually clear and no &lt;code&gt;reset&lt;/code&gt; to remember. This is the same mechanism the sibling post &lt;a href="https://docs.oryxflow.dev/blog/caching/stop-rerunning-your-pipeline/" rel="noopener noreferrer"&gt;Stop rerunning your whole pipeline&lt;/a&gt; digs into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load any result by name.&lt;/strong&gt; Every intermediate is addressable. Want the features without rerunning the model? &lt;code&gt;oryxflow.Workflow(BuildFeatures).outputLoad()&lt;/code&gt; hands back the cached frame. Comparing two settings? &lt;code&gt;oryxflow.Workflow(TrainModel, {'power': 3}).outputLoad()&lt;/code&gt; loads that configuration's model directly. No more scrolling for the cell that defined the variable you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you do not have to decide upfront
&lt;/h2&gt;

&lt;p&gt;A twenty-minute first look at a new dataset does not need tasks. If you will not run any of it twice, the task scaffolding is pure overhead — pull a step into a task when the computation is slow enough to want caching, or the result is important enough to need reproducing.&lt;/p&gt;

&lt;p&gt;What you do not have to do is decide that in advance. If you use Claude Code, the oryxflow plugin gives that first look a home inside the project — a read-only probe that states the question it answers and still runs next session, instead of a cell you lose — and when a probe turns out to be load-bearing, &lt;code&gt;/oryxflow:migrate&lt;/code&gt; promotes it into cached, parameterized tasks, reading your script as the spec and never deleting it. So "is this analysis big enough for a pipeline yet?" is a question you can answer by doing the work rather than guessing first: start with a simple script and let it scale as the work gets complex, with no rewrite in between. Walkthrough: &lt;a href="https://docs.oryxflow.dev/docs/migrate-notebook-to-pipeline/" rel="noopener noreferrer"&gt;migrate a notebook to a pipeline.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Where oryxflow genuinely stops is operations. It is built for the research loop, not for production orchestration; if you need scheduling, retries, and alerting across a fleet, use a real orchestrator. oryxflow makes &lt;em&gt;your own analysis&lt;/em&gt; trustworthy and fast to iterate on.&lt;/p&gt;

&lt;p&gt;A good rule of thumb: pull a step into a task the second time you find yourself waiting for it to recompute something that did not change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Migrate incrementally. Wrap the most expensive step first, keep a working pipeline at every stage, and never do a big-bang rewrite.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@oryxflow.requires&lt;/code&gt; wires dependencies; &lt;code&gt;self.inputLoad()&lt;/code&gt; reads them; &lt;code&gt;self.save()&lt;/code&gt; caches the output.&lt;/li&gt;
&lt;li&gt;Editing a task reruns it and everything downstream automatically — reproducibility you do not have to maintain by hand.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;oryxflow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use Claude Code, the oryxflow plugin's &lt;code&gt;/oryxflow:migrate&lt;/code&gt; command walks a script through exactly this conversion, one step at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why do I get different numbers when I rerun a notebook a week later?
&lt;/h2&gt;

&lt;p&gt;Usually it's hidden state, out-of-order cell execution, or stale intermediate results that never refreshed after an upstream change — none of which surface as errors, they just quietly make your output wrong. oryxflow removes all three by declaring each step as a task with explicit dependencies: the engine runs them in the right order, stores each output under an id derived from its code and parameters, and reruns a step whenever its code or inputs actually change.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I convert a Jupyter notebook into a reproducible pipeline?
&lt;/h2&gt;

&lt;p&gt;Migrate incrementally rather than rewriting. Wrap your slowest step — usually the data load — in an oryxflow task that ends with &lt;code&gt;self.save()&lt;/code&gt;, run it once so the result is stored, then feed the rest of your notebook from that output. Add one step at a time with &lt;code&gt;@oryxflow.requires&lt;/code&gt; to wire dependencies. You keep a working pipeline at every stage, and each output is tied to the exact code and parameters that produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do I have to rewrite my whole notebook at once to use oryxflow?
&lt;/h2&gt;

&lt;p&gt;No — that's the point of migrating incrementally. Start by converting the single step that hurts most, usually the slow load, and leave the rest of your cells untouched, now fed by &lt;code&gt;flow.outputLoad()&lt;/code&gt;. You have a working pipeline after every step, and you only pull the next step into a task when it earns it. oryxflow is designed for this one-step-at-a-time conversion, not a big-bang rewrite.&lt;/p&gt;

&lt;p&gt;Next steps: &lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/docs/why-oryxflow/" rel="noopener noreferrer"&gt;Why oryxflow &lt;/a&gt;&lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/docs/transition/" rel="noopener noreferrer"&gt;Transition guide&lt;/a&gt; &lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/docs/quickstart/" rel="noopener noreferrer"&gt;Quickstart&lt;/a&gt; &lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/docs/claude-plugin/" rel="noopener noreferrer"&gt;Claude Code plugin &lt;/a&gt;&lt;br&gt;
· &lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Reproducible data science workflows in Python</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Wed, 19 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/reproducible-data-science-workflows-in-python-265g</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/reproducible-data-science-workflows-in-python-265g</guid>
      <description>&lt;p&gt;&lt;em&gt;Reproducibility and lineage are the product. Caching is just how you get them for free.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A workflow is reproducible when you can say exactly which code and which inputs produced a given result — and regenerate it on demand. That's the whole definition, and it's a surprisingly high bar. Most analysis pipelines fail it not because anyone was careless, but because the tools we reach for — notebooks and loose scripts — have no memory of what produced what.&lt;/p&gt;

&lt;p&gt;This post is about closing that gap in plain Python: what actually makes a data-science workflow reproducible, why workflows quietly lose reproducibility in the first place, and how a small library called &lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;oryxflow&lt;/a&gt; gets you there without a server, a database, or a YAML file. If you want the canonical positioning — where oryxflow sits relative to notebooks and orchestrators — read &lt;a href="https://docs.oryxflow.dev/docs/why-oryxflow/" rel="noopener noreferrer"&gt;why oryxflow&lt;/a&gt;. This is the keyword-first explainer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why workflows lose reproducibility
&lt;/h2&gt;

&lt;p&gt;Nobody sets out to build a pipeline they can't reproduce. It happens by accretion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hidden notebook state&lt;/strong&gt;. A variable exists only because you ran a cell you've since edited or deleted. The notebook still runs top-to-bottom in your head, but not on disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out-of-order execution&lt;/strong&gt;. Cells run 1, 2, 4, 3, 2-again. The result on screen reflects a path through the code that no fresh "Run All" will reproduce.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale intermediates&lt;/strong&gt;. You saved &lt;code&gt;features.parquet&lt;/code&gt; yesterday, changed the feature code today, and trained on the old file without noticing. Nothing errored. The number is just quietly wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No durable link between artifact and code&lt;/strong&gt;. You have &lt;code&gt;model.pkl&lt;/code&gt; and you have your script, but nothing records that this model came from that commit of the feature code and those parameters. Six weeks later, you can't answer the question that matters: &lt;em&gt;which version made this?&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic. They're the default failure mode of exploratory work, and they all share a root cause: the saved artifacts and the code that made them live in separate worlds, with no enforced connection between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a workflow reproducible
&lt;/h2&gt;

&lt;p&gt;Reproducibility isn't one feature. It's three properties working together.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic task identity from code + params.&lt;/strong&gt; Every step has an identity computed from its code and its parameters. Same code, same inputs → same identity → the same output, every time. Change either and you get a new identity — so a result is never silently attributed to code that didn't produce it.
2.** Automatic invalidation.** You should not be able to evaluate new code against a stale output. When a step's logic changes, that step and everything downstream of it must be recomputed — automatically, without you remembering to delete a cache file. Reproducibility that depends on your discipline isn't reproducibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A lineage record.&lt;/strong&gt; A durable, append-only log of what ran, when, and why. Not because it's tidy, but because "regenerate this result" requires knowing what produced it in the first place.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;oryxflow is built around exactly these three. You declare each step of your analysis as a &lt;code&gt;Task&lt;/code&gt; with its parameters and its dependencies; the library runs them in dependency order, caches each output, and reruns a step only when its code or inputs actually change. The identity is deterministic, the invalidation is automatic, and every run appends to a lineage file. You don't maintain reproducibility — you get it as a side effect of writing tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic identity, and automatic code-change invalidation
&lt;/h2&gt;

&lt;p&gt;The part that's easy to underrate: oryxflow watches your &lt;strong&gt;code&lt;/strong&gt;, not just your parameters. It tracks each task's logic plus the helper files it imports, comparing what your code &lt;em&gt;does&lt;/em&gt;, not how it's written. Edit the body of &lt;code&gt;BuildFeatures&lt;/code&gt; — or a helper function it calls — and oryxflow knows that task's output is stale and reruns it and everything downstream. Reformat the code, add a comment, rename a local variable? Nothing reruns — those don't change what the code does. And an expensive upstream step whose code you didn't touch stays cached.&lt;/p&gt;

&lt;p&gt;That's the property that makes the research loop both fast and trustworthy: you can never test new logic against an output the old logic produced, and you never pay to recompute a step that didn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a small reproducible DAG
&lt;/h2&gt;

&lt;p&gt;Here's the canonical shape — &lt;code&gt;GetData&lt;/code&gt; → &lt;code&gt;BuildFeatures&lt;/code&gt; → &lt;code&gt;TrainModel&lt;/code&gt; — with nothing but the verified API. Each task's base class picks its serialization by type: &lt;code&gt;TaskPqPandas&lt;/code&gt; saves a DataFrame as parquet, &lt;code&gt;TaskPickle&lt;/code&gt; saves an arbitrary Python object. No paths, no &lt;code&gt;to_parquet&lt;/code&gt; calls, no config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import oryxflow


class GetData(oryxflow.tasks.TaskPqPandas):        # saves a DataFrame as parquet
    source = oryxflow.Parameter(default='raw.parquet')

    def run(self):
        df = load_table(self.source)               # your loader
        self.save(df)


@oryxflow.requires(GetData)                        # declares the dep AND copies params
class BuildFeatures(oryxflow.tasks.TaskPqPandas):
    def run(self):
        df = self.inputLoad()                      # the upstream DataFrame
        df['x_squared'] = df['x'] ** 2
        self.save(df[['x', 'x_squared', 'y']])


@oryxflow.requires(BuildFeatures)
class TrainModel(oryxflow.tasks.TaskPickle):       # saves any Python object
    alpha = oryxflow.FloatParameter(default=1.0)

    def run(self):
        df = self.inputLoad()
        model = fit_model(df[['x', 'x_squared']], df['y'], alpha=self.alpha)
        self.save(model)
        self.saveMeta({'n_rows': len(df), 'alpha': self.alpha})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it through a &lt;code&gt;Workflow&lt;/code&gt;, which resolves the DAG and runs only what's needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flow = oryxflow.Workflow(TrainModel, {'alpha': 0.5})
flow.preview()                                     # show the plan without running
flow.run()                                         # runs GetData -&amp;gt; BuildFeatures -&amp;gt; TrainModel

model = flow.outputLoad()                           # the trained model
meta = flow.outputLoadMeta()                        # {'n_rows': ..., 'alpha': 0.5}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first run computes all three steps. Edit &lt;code&gt;TrainModel.run&lt;/code&gt; and rerun: &lt;code&gt;GetData&lt;/code&gt; and &lt;code&gt;BuildFeatures&lt;/code&gt; stay cached, only &lt;code&gt;TrainModel&lt;/code&gt; recomputes. Change &lt;code&gt;alpha&lt;/code&gt; and you get a distinct identity — the old and new models coexist, each tied to its parameters. Nothing here writes a path or checks whether a file already exists; that bookkeeping is the library's job. For the fuller pattern — parameter sweeps, resetting, sharing flows — see &lt;a href="https://docs.oryxflow.dev/docs/managing-workflows/" rel="noopener noreferrer"&gt;managing workflows&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use it — and when not to
&lt;/h2&gt;

&lt;p&gt;Reach for a reproducible DAG when a result needs to be defensible: something you'll hand to a colleague, revisit in a month, or make a decision on. The moment a workflow has more than one expensive step and you're iterating on the later ones, deterministic identity and automatic caching pay for themselves.&lt;/p&gt;

&lt;p&gt;A first look doesn't need tasks — and it doesn't need a decision either. If you're eyeballing distributions and nothing downstream depends on the output, task boilerplate is pure overhead; write a plain script. But you don't have to choose a tool before you know how big the work will get: if you build with the &lt;a href="https://docs.oryxflow.dev/docs/claude-code-for-data-science/" rel="noopener noreferrer"&gt;oryxflow Claude Code plugin&lt;/a&gt;, that first look already has a home in the project — a read-only probe that states the question it answers and still runs next session — and &lt;code&gt;/oryxflow:migrate&lt;/code&gt; lifts it into cached, parameterized tasks the day something starts depending on its result. So you can start a small exploration here and let it scale as the shape stabilizes, with no rewrite in between. Where oryxflow genuinely doesn't fit — scheduling, distributed scale, dashboards — has a whole post: &lt;a href="https://docs.oryxflow.dev/blog/guides/when-not-to-use-oryxflow/" rel="noopener noreferrer"&gt;when not to use oryxflow&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where oryxflow sits
&lt;/h2&gt;

&lt;p&gt;Reproducibility isn't binary, and oryxflow isn't the only layer that touches it. It occupies the missing middle between a notebook and a production orchestrator.&lt;/p&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsdowu4t27aj93l52j01w.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsdowu4t27aj93l52j01w.png" alt="a table showing Notebook / loose script, oryxflow and Orchestrator (Airflow / Prefect / Dagster) comparison" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Orchestrators are a &lt;strong&gt;complementary&lt;/strong&gt; layer, not a competitor: they schedule and distribute pipelines in production, which is a real and different problem. Experiment trackers like MLflow and W&amp;amp;B are complementary too — they &lt;em&gt;record&lt;/em&gt; runs; oryxflow &lt;em&gt;structures and caches&lt;/em&gt; them. oryxflow's differentiators are the combination that neither layer offers: type-driven zero-config I/O, automatic code-change invalidation, deterministic task identity, and a local-first design with native Python identity — no YAML, no server, no account, no telemetry. There's a full comparison in &lt;a href="https://docs.oryxflow.dev/blog/comparisons/oryxflow-vs-pipeline-frameworks/" rel="noopener noreferrer"&gt;oryxflow vs. the field&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveat: reproducible ≠ correct
&lt;/h2&gt;

&lt;p&gt;Be clear about what reproducibility buys you. oryxflow guarantees that an output was produced by the exact code and inputs it recorded — no more. It does &lt;strong&gt;not&lt;/strong&gt; guarantee that the output is &lt;em&gt;right&lt;/em&gt;. A pipeline with a bug in its feature logic is reproduced just as faithfully as a correct one; you'll get the same wrong number every time, tied cleanly to the flawed code that made it.&lt;/p&gt;

&lt;p&gt;That's not a weakness — it's the honest scope. Reproducibility is what makes a wrong result &lt;em&gt;debuggable&lt;/em&gt;: because you know exactly which code and inputs produced it, you can find the bug, fix it, and let automatic invalidation rerun precisely what the fix touched. Correctness is still your job. Reproducibility is what makes doing that job tractable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;A workflow is reproducible when you can name the exact code and inputs behind any result and regenerate it. That takes three things — deterministic task identity, automatic invalidation so you can't test new code on stale outputs, and a durable lineage record. oryxflow gives you all three in plain Python, locally, with the caching that makes them cheap thrown in for free. It won't make your pipeline correct. It will make it something you can trust, hand off, and reproduce.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pip install oryxflow&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  How do I make a data science workflow reproducible in Python?
&lt;/h2&gt;

&lt;p&gt;A workflow is reproducible when you can name the exact code and inputs behind any result and regenerate it on demand. That takes three things: deterministic task identity from code and parameters, automatic invalidation so you can't test new code against a stale output, and a durable lineage record. oryxflow gives you all three in plain Python, locally, with caching that makes them cheap thrown in for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a pipeline reproducible without a lot of infrastructure?
&lt;/h2&gt;

&lt;p&gt;Reproducibility needs a durable link between each result and the code and inputs that produced it, not a server or database. oryxflow delivers that locally: it computes a deterministic identity for each step, reruns a step automatically when its code changes, and appends every run to a local lineage log at .oryxflow/events.jsonl. No server, no database, no account — just pip install and local files.&lt;/p&gt;

&lt;p&gt;Read next: &lt;br&gt;
·&lt;a href="https://docs.oryxflow.dev/blog/reproducibility/notebook-to-reproducible-pipeline/" rel="noopener noreferrer"&gt;Turn a messy notebook into a reproducible pipeline &lt;/a&gt;&lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/blog/caching/stop-rerunning-your-pipeline/" rel="noopener noreferrer"&gt;Stop rerunning your whole pipeline &lt;/a&gt;&lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/blog/guides/when-not-to-use-oryxflow/" rel="noopener noreferrer"&gt;When not to use oryxflow &lt;/a&gt;&lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/blog/comparisons/oryxflow-vs-pipeline-frameworks/" rel="noopener noreferrer"&gt;oryxflow vs. the field &lt;/a&gt;&lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/docs/why-oryxflow/" rel="noopener noreferrer"&gt;Why oryxflow &lt;/a&gt;&lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/docs/managing-workflows/" rel="noopener noreferrer"&gt;Managing workflows &lt;/a&gt;&lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/docs/claude-plugin/" rel="noopener noreferrer"&gt;Build with the Claude Code plugin&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The best Claude Code plugins and tools for data science</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Fri, 14 Aug 2026 15:45:41 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/the-best-claude-code-plugins-and-tools-for-data-science-41il</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/the-best-claude-code-plugins-and-tools-for-data-science-41il</guid>
      <description>&lt;p&gt;&lt;em&gt;The landscape is early, so stop shopping for a "best plugin" and start choosing by the job you need done — the one below keeps AI-generated analysis reproducible.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you let Claude Code loose on a data-science project, the first thing you notice is how much it can do in one session: pull data, write features, fit a model, plot the result. The second thing you notice is how easily it loses the thread. Across a few turns the agent forgets which steps already ran, re-executes a ten-minute feature build for no reason, or — worse — edits an upstream step and evaluates a model on the &lt;em&gt;old&lt;/em&gt; output without realizing anything went stale. The code looks fine. The result is quietly wrong.&lt;/p&gt;

&lt;p&gt;That is the real problem a good Claude Code setup for data science has to solve. Not "can the agent write pandas" — it can — but &lt;strong&gt;can you trust what it produced, and could you reproduce it tomorrow?&lt;/strong&gt; Agents are stateless between turns; your pipeline is stateful. The tools that matter are the ones that close that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we picked
&lt;/h2&gt;

&lt;p&gt;There is no crowded, mature market of data-science Claude Code plugins yet, so we're not ranking twenty near-identical products. We're grouping the genuinely useful options &lt;strong&gt;by the job they do&lt;/strong&gt; and judging each on four things that actually decide whether AI-assisted analysis holds up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reproducibility&lt;/strong&gt; — can you (or a teammate, or the agent next week) recreate a result exactly, and know what it was built from?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State management across turns&lt;/strong&gt; — does the tool help the agent track what's already computed and what went stale, so it doesn't build on outdated work?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local-first and private&lt;/strong&gt; — does your data and lineage stay on your machine, with no telemetry or mandatory cloud service?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the job it claims&lt;/strong&gt; — honest scope. A tool that connects to your warehouse is not pretending to make your analysis reproducible, and vice versa.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quick word on honesty: this space moves fast and many "plugins" are really MCP servers (Model Context Protocol connectors) or thin wrappers. Where we're confident a specific tool exists, we name it. Where we aren't, we describe the &lt;strong&gt;category&lt;/strong&gt; so you can search for the current best option yourself rather than trust a made-up product name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building reproducible pipelines — the oryxflow Claude Code plugin
&lt;/h2&gt;

&lt;p&gt;This is the job most AI-assisted data-science setups get wrong, and it's the one &lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;oryxflow&lt;/a&gt; is built for. oryxflow is a small, local-first Python library that turns ordinary scripts and notebooks into a &lt;strong&gt;dependency-aware task graph&lt;/strong&gt;: you declare tasks with parameters and &lt;code&gt;requires()&lt;/code&gt; dependencies, and the engine runs them in order and reruns exactly the ones a change affects — while anything genuinely unchanged loads from disk, so keeping the graph honest costs you less time than not having it.&lt;/p&gt;

&lt;p&gt;The companion &lt;a href="https://github.com/oryxintel/oryxflow-claude-plugin" rel="noopener noreferrer"&gt;Claude Code plugin&lt;/a&gt; is what makes an agent good at using that model. It's a &lt;strong&gt;skill plus a handful of slash commands&lt;/strong&gt; that activates automatically when you're working in an oryxflow project — if the skill/plugin/MCP distinction is fuzzy, &lt;a href="https://docs.oryxflow.dev/blog/ai-agents/claude-code-skills-for-data-science/" rel="noopener noreferrer"&gt;Claude Code skills for data science&lt;/a&gt; untangles it. It front-loads the correct idioms so the agent doesn't build on stale data, verifies that an edit actually reran the tasks it should have, and reuses finished work instead of recomputing it. In practice that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/oryxflow:init-project&lt;/code&gt; scaffolds a ready-to-run project structure.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/oryxflow:migrate&lt;/code&gt;restructures a messy notebook or linear script into cached, parameterized tasks &lt;strong&gt;one step at a time&lt;/strong&gt;, so you always have a working pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/oryxflow:check-standards&lt;/code&gt; keeps names, style, and docstrings consistent.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/oryxflow:init-gitlfs&lt;/code&gt; and &lt;code&gt;/oryxflow:update-project&lt;/code&gt; handle data versioning and keeping an older project current.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why it's the strongest fit for &lt;em&gt;this&lt;/em&gt; job: reproducibility is a property of your &lt;strong&gt;computation graph&lt;/strong&gt;, and oryxflow owns that graph — parameters and code changes become new cached identities automatically, so you can't accidentally evaluate a new model on old features. It's local-first with no telemetry; your data and lineage stay on your machine.&lt;/p&gt;

&lt;p&gt;One honest limit, stated plainly: &lt;strong&gt;oryxflow makes analysis reproducible, not correct&lt;/strong&gt;. It guarantees you can recreate a result and that the result was built from current inputs. It does not check that your feature logic is sound or your metric is the right one — that's still your job. What it removes is the whole class of "was this trained on the new data?" uncertainty that makes agent-generated pipelines untrustworthy. See &lt;a href="https://docs.oryxflow.dev/docs/claude-plugin/why/" rel="noopener noreferrer"&gt;Why library + plugin is a matched pair&lt;/a&gt; and &lt;a href="https://docs.oryxflow.dev/docs/why-oryxflow/" rel="noopener noreferrer"&gt;why oryxflow &lt;/a&gt;for the fuller argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting to your data — MCP data connectors
&lt;/h2&gt;

&lt;p&gt;Before you can build a pipeline you have to reach the data, and this is where the broader &lt;strong&gt;MCP (Model Context Protocol) ecosystem&lt;/strong&gt; shines. There are MCP servers for &lt;strong&gt;databases and warehouses, filesystems&lt;/strong&gt;, and &lt;strong&gt;APIs&lt;/strong&gt;, letting Claude Code query a table or read a file directly as part of a turn. If your bottleneck is "the agent can't see my data," a data connector is the right tool — and it's a genuinely different job from making the resulting analysis reproducible.&lt;/p&gt;

&lt;p&gt;The pairing is natural: use an MCP connector to &lt;em&gt;reach&lt;/em&gt; the raw data, then wrap the pull in an oryxflow task so the fetched result is cached and lineage-tracked rather than re-queried on every turn. Local-first varies by connector — a filesystem server is fully local, a managed-warehouse one obviously isn't — so pick per your privacy needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notebooks and code execution — execution tools
&lt;/h2&gt;

&lt;p&gt;A lot of data-science work still lives in notebooks, and there are &lt;strong&gt;notebook and code- execution tools&lt;/strong&gt; — including generic Jupyter/notebook MCP servers — that let an agent run cells and read back outputs interactively. These are great for exploration and for the messy, visual first pass where you don't yet know what the pipeline should be.&lt;/p&gt;

&lt;p&gt;Their honest limitation is the same statelessness problem we opened with: a notebook is a pile of cells with hidden execution order, and an agent iterating in one has no built-in notion of what's stale. That's exactly why the common path is &lt;strong&gt;explore in a notebook, then graduate the keeper steps into a cached pipeline&lt;/strong&gt; — the subject of &lt;a href="https://docs.oryxflow.dev/blog/reproducibility/notebook-to-reproducible-pipeline/" rel="noopener noreferrer"&gt;Turn a messy notebook into a reproducible pipeline&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment tracking — MLflow (including its experimental MCP)
&lt;/h2&gt;

&lt;p&gt;Once you're producing results worth comparing, you want a &lt;strong&gt;tracker&lt;/strong&gt;. &lt;strong&gt;MLflow&lt;/strong&gt; is the best-known, and it now ships an &lt;strong&gt;official (experimental) MCP server&lt;/strong&gt; that lets an agent query your logged runs, parameters, and metrics conversationally.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;complementary&lt;/strong&gt; job, not a competing one. Tracking answers &lt;em&gt;"which run scored 0.91, and what were its hyperparameters?"&lt;/em&gt; — a logging and comparison problem. Reproducibility answers "which steps do I need to rerun to recreate that run, and which are already computed?" — a computation problem. A tracker will faithfully log a score without any idea whether the features feeding it are stale. Put your tracker calls &lt;strong&gt;inside&lt;/strong&gt; your cached tasks and you get both: a reproducible graph and a searchable record. We go deeper in &lt;a href="https://docs.oryxflow.dev/blog/mlops/mlflow-vs-pipeline-caching/" rel="noopener noreferrer"&gt;MLflow, or a reproducible pipeline?&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  At a glance
&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyl1wcccdca3os1h497rs.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyl1wcccdca3os1h497rs.png" alt="A table showing the tool/category and the job it does whether it's local-first, plugin or MCP?"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h2&gt;
  
  
  What are the best Claude Code plugins for data science?
&lt;/h2&gt;

&lt;p&gt;The best Claude Code plugin depends on the job: use an &lt;strong&gt;MCP connector&lt;/strong&gt; to reach your data, a &lt;strong&gt;notebook tool&lt;/strong&gt; to explore, an &lt;strong&gt;experiment tracker&lt;/strong&gt; to compare runs, and &lt;strong&gt;oryxflow&lt;/strong&gt; to make the pipeline itself trustworthy and reproducible — so the agent never builds on stale data and every result traces back to the code that made it. They compose rather than compete: reach data with a connector, wire the steps with oryxflow, and log outcomes to your tracker. Note oryxflow is a skill plus slash commands, not an MCP server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the best plugin for keeping AI-generated pipelines reproducible?
&lt;/h2&gt;

&lt;p&gt;If the specific worry is that an agent will build on stale data or produce a result you can't recreate, the oryxflow plugin is the strongest fit, because it's backed by a task-graph engine that makes reproducibility a structural property rather than a discipline you have to remember. Connectors and notebook tools are excellent at their jobs — getting data in and exploring it — but they don't own your computation graph, so they can't guarantee that the result you're looking at was built from current inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do I have to choose one tool?
&lt;/h2&gt;

&lt;p&gt;No, and you probably shouldn't. The categories map to different jobs: a connector to reach data, a notebook tool to explore, a task-graph engine to make the pipeline reproducible, a tracker to compare runs. The pattern that works is to reach data with a connector, wire the steps with oryxflow, and log the outcomes to your tracker from inside the tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The Claude Code data-science plugin landscape is young, so the winning move isn't to find the one blessed plugin — it's to pick by the &lt;strong&gt;job&lt;/strong&gt; in front of you. For reaching data, use an MCP connector; for exploring, a notebook tool; for comparing runs, a tracker like MLflow. And for the job that decides whether any of it is trustworthy — reproducible, lineage- tracked pipelines an agent can iterate on without building on stale work — the oryxflow plugin is the layer that makes the other tools' output something you can stand behind. It won't tell you your analysis is &lt;em&gt;correct&lt;/em&gt;; it will guarantee it's &lt;em&gt;reproducible&lt;/em&gt;, which is the half agents keep getting wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;oryxflow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read next: &lt;a href="https://docs.oryxflow.dev/blog/ai-agents/caching-dag-for-ai-coding-agents/" rel="noopener noreferrer"&gt;How to make Claude Code a trustworthy data scientist&lt;/a&gt; &lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/blog/guides/when-not-to-use-oryxflow/" rel="noopener noreferrer"&gt;When not to use oryxflow &lt;/a&gt;&lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/blog/reproducibility/notebook-to-reproducible-pipeline/" rel="noopener noreferrer"&gt;Turn a messy notebook into a reproducible pipeline &lt;/a&gt;&lt;br&gt;
· &lt;a href="https://docs.oryxflow.dev/docs/claude-plugin/commands/" rel="noopener noreferrer"&gt;Plugin commands &lt;/a&gt;&lt;br&gt;
· Plugin repo: &lt;a href="https://github.com/oryxintel/oryxflow-claude-plugin" rel="noopener noreferrer"&gt;https://github.com/oryxintel/oryxflow-claude-plugin&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Best practices for AI-assisted data analysis</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Wed, 12 Aug 2026 16:09:59 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/best-practices-for-ai-assisted-data-analysis-4phe</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/best-practices-for-ai-assisted-data-analysis-4phe</guid>
      <description>&lt;p&gt;&lt;em&gt;AI coding agents write plausible analysis fast. The hard part — is it reproducible, and is it right? — hasn't changed. These practices are about making AI-generated analysis you can actually trust.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Ask a coding agent to load a dataset, engineer features, train a model, and compare a few configurations, and it will produce clean, plausible code in seconds. That speed is real and it is worth having. But "plausible code, fast" and "a correct, reproducible analysis you can stand behind" are different bars, and the distance between them is exactly where AI-generated work quietly goes wrong.&lt;/p&gt;

&lt;p&gt;The good news: most of that gap is a &lt;em&gt;workflow&lt;/em&gt; problem, not an intelligence problem. If you give the agent a structure that makes reproducibility automatic, it stops making a whole class of mistakes — stale intermediates, silent re-runs, results nobody can trace. What that structure &lt;em&gt;cannot&lt;/em&gt; do is make your statistics correct. So the practices below split cleanly: the first seven you can hand to your tooling, and the last one you can never hand to anyone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;oryxflow&lt;/a&gt; is a small, local-first Python library that turns a data-science script into a cached, dependency-aware graph of tasks — zero infrastructure, no server, no telemetry. Its &lt;a href="https://github.com/oryxintel/oryxflow-claude-plugin" rel="noopener noreferrer"&gt;Claude Code plugin&lt;/a&gt; teaches an agent to work inside that structure. Together they enforce most of these practices for you; &lt;code&gt;/oryxflow:init-project&lt;/code&gt; is the on-ramp.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Structure work as a dependency graph, not a linear script
&lt;/h2&gt;

&lt;p&gt;An agent loses pipeline state across turns. A long linear script gives it no reliable memory of what has already run or whether it is still valid — so it re-derives that picture every turn and gets it wrong. Model the work as tasks with declared dependencies instead. Each step names its inputs, the engine runs them in order, and "what depends on what" is data the agent can read rather than reconstruct.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GetData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_raw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                  &lt;span class="c1"&gt;# your loader
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@oryxflow.requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GetData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;            &lt;span class="c1"&gt;# upstream output, typed and loaded for you
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;build_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Features&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;outputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note there is no file-path plumbing and no &lt;code&gt;to_parquet&lt;/code&gt;/&lt;code&gt;read_parquet&lt;/code&gt;. The base class you pick (&lt;code&gt;TaskPqPandas&lt;/code&gt;, &lt;code&gt;TaskPickle&lt;/code&gt;, &lt;code&gt;TaskCachePandas&lt;/code&gt;, …) drives type-based, zero-config I/O — the single most common place hand-written analysis code rots.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cache expensive steps, so the careful path is also the fast one
&lt;/h2&gt;

&lt;p&gt;This one is plumbing rather than a promise, but it's what keeps the rest of the list from being a tax. The agent iterates: every turn it might touch feature code, model params, or a plot. If each turn re-pays for the big join and the slow fit, iteration is expensive and the agent starts taking shortcuts — reusing an output it should have rebuilt, skipping the rerun that would have caught the staleness. oryxflow skips any task whose output already exists, so the costly upstream steps run once and every later turn is fast. Cheap iteration is not just ergonomics; it removes the incentive to cut exactly the corners the other seven practices are about.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Keep a durable link between code and output
&lt;/h2&gt;

&lt;p&gt;This is the one that bites hardest. The agent edits feature code, forgets to regenerate the saved features, and trains on stale data. Nothing errors. The pipeline runs; the numbers are just wrong. oryxflow watches your task code and, when the body of a step changes, automatically invalidates that step and everything downstream — so the next run recomputes exactly what changed and nothing else. You never evaluate new code on old output, and you never blow away the whole cache to be safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Keep lineage you can audit
&lt;/h2&gt;

&lt;p&gt;"Which code and which data produced this result?" should have an answer you can look up, not one you reconstruct from memory. oryxflow records run events to &lt;code&gt;.oryxflow/events.jsonl&lt;/code&gt; locally — a plain, greppable trail of what ran, when, and why it was (or wasn't) recomputed. When a number looks off, that file is where you start.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Verify the rerun actually happened
&lt;/h2&gt;

&lt;p&gt;Do not trust that an edit took effect just because the agent said so. After a change, confirm the affected task actually recomputed — check that its output timestamp moved, or read the lineage trail from practice 4. Agents are confident narrators; "I've updated the features and retrained" is a claim, not evidence. &lt;code&gt;flow.preview()&lt;/code&gt; shows you what the engine considers complete versus pending before you run, so you can see the plan and catch a step that &lt;em&gt;should&lt;/em&gt; be stale but isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Keep exploratory work separate from the pipeline
&lt;/h2&gt;

&lt;p&gt;Early exploratory analysis should stay loose — scratch cells, quick plots, throwaway checks. Don't prematurely formalize it, and don't let it silently become load-bearing either. When a piece of exploration earns its place — you'll rerun it, others depend on it, it feeds a result — promote it into a task deliberately. The plugin's &lt;code&gt;/oryxflow:migrate&lt;/code&gt; command does that conversion for you, lifting a notebook or script into cached tasks when it's ready, so the boundary between "playing" and "pipeline" stays honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Version your data so results are regenerable
&lt;/h2&gt;

&lt;p&gt;Reproducible code on top of a mutable, unversioned dataset is only half-reproducible. Track your data alongside your code so any result can be regenerated from a known state. &lt;code&gt;/oryxflow:init-gitlfs&lt;/code&gt; sets up Git LFS for the data directory, so inputs and outputs are versioned the same way the code is.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Never outsource judgment — reproducible is not correct
&lt;/h2&gt;

&lt;p&gt;Here is the honesty that the other seven practices exist to protect: &lt;strong&gt;no tool makes your statistics correct&lt;/strong&gt;. oryxflow guarantees that the same code and data give the same result, that stale steps recompute, that lineage is auditable. It does &lt;em&gt;not&lt;/em&gt; check that your join keys are right, that your validation is honest, or that your features don't leak the target. Those are judgment, and judgment does not delegate — not to a library, and emphatically not to the agent.&lt;/p&gt;

&lt;p&gt;So keep these firmly in human hands, every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sanity-check joins and aggregations&lt;/strong&gt;. Row counts before and after, spot-check a few keys, confirm the grain is what you think it is. A silent fan-out or dropped rows survives any amount of caching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hold out real validation data&lt;/strong&gt; and keep it untouched until the end. An agent optimizing a metric will happily overfit to whatever you let it see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for leakage and lookahead.&lt;/strong&gt; A feature computed with information from the future, or a target that sneaks into the inputs, produces beautiful, reproducible, worthless results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the numbers skeptically&lt;/strong&gt;. An accuracy that jumped suspiciously, a distribution that shifted, a metric that's too good — treat these as bugs to explain, not wins to ship.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reproducibility is what lets you &lt;em&gt;investigate&lt;/em&gt; correctness efficiently: because the pipeline is stable and traceable, when a number looks wrong you can trust that the code and data in front of you are what produced it. That's the foundation judgment stands on — not a substitute for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practices at a glance
&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfeo8xx2bl1fwcwn2bnu.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfeo8xx2bl1fwcwn2bnu.png" alt="A table of Practice, What it prevents, How oryxflow / the plugin helps" width="800" height="648"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Orchestrators (Airflow, Prefect, Dagster) and experiment trackers (MLflow) are complementary layers here, not competitors — they schedule and record; oryxflow is the local, code-tight inner loop the agent iterates in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;AI makes analysis &lt;em&gt;fast to write&lt;/em&gt;. Trust comes from making it &lt;em&gt;reproducible by structure&lt;/em&gt; and &lt;em&gt;correct by judgment&lt;/em&gt; — two different jobs. Let oryxflow and its plugin enforce the reproducible half automatically, and keep the correctness half where it belongs: with a skeptical human reading the numbers. &lt;code&gt;/oryxflow:init-project&lt;/code&gt; sets up the foundation; you bring the judgment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;oryxflow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  What are best practices for AI-assisted data analysis?
&lt;/h2&gt;

&lt;p&gt;Structure the work as a dependency graph instead of a linear script, cache expensive steps, keep a durable link between code and output, retain auditable lineage, verify that edits actually reran, separate exploration from the pipeline, version your data, and never outsource statistical judgment. The first seven you can hand to tooling; oryxflow and its Claude Code plugin enforce them, and the last stays with you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I keep AI-generated data analysis reproducible and trustworthy?
&lt;/h2&gt;

&lt;p&gt;Give the agent a structure that makes reproducibility automatic: a caching, dependency-aware task graph that reruns only what a code or data change affects and logs what ran. oryxflow provides code-change invalidation and a greppable .oryxflow/events.jsonl trail, so any result traces back to its inputs. Reproducible is not correct, though, so you still sanity-check joins, hold out validation data, and watch for leakage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What tools help make AI data analysis reproducible?
&lt;/h2&gt;

&lt;p&gt;Coding agents write the analysis, notebooks display it, and trackers like MLflow record runs, but none guarantee the computation is reproducible. That reproducibility layer is where a local-first caching library fits. oryxflow turns a data-science script into a cached, dependency-aware graph with code-change invalidation and local lineage, and its Claude Code plugin teaches the agent to work inside that structure. Orchestrators like Airflow are a complementary scheduling layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/docs/claude-code-for-data-science/" rel="noopener noreferrer"&gt;Claude Code for data science&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/blog/ai-agents/caching-dag-for-ai-coding-agents/" rel="noopener noreferrer"&gt;How to make Claude Code a trustworthy data scientist&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/blog/ai-agents/claude-code-skills-for-data-science/" rel="noopener noreferrer"&gt;Claude Code skills for data science&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/blog/guides/when-not-to-use-oryxflow/" rel="noopener noreferrer"&gt;When &lt;em&gt;not&lt;/em&gt; to use oryxflow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/blog/reproducibility/notebook-to-reproducible-pipeline/" rel="noopener noreferrer"&gt;Turn a messy notebook into a reproducible pipeline&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/docs/why-oryxflow/" rel="noopener noreferrer"&gt;Why oryxflow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/docs/claude-plugin/" rel="noopener noreferrer"&gt;Build with Claude Code: the oryxflow plugin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/docs/claude-plugin/why/" rel="noopener noreferrer"&gt;Why a plugin, not just a library&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.oryxflow.dev/docs/managing-workflows/" rel="noopener noreferrer"&gt;Managing workflows&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Plugin repo: &lt;a href="https://github.com/oryxintel/oryxflow-claude-plugin" rel="noopener noreferrer"&gt;https://github.com/oryxintel/oryxflow-claude-plugin&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Claude Code skills for data science: what they are, and what they aren't</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Tue, 11 Aug 2026 15:50:07 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/claude-code-skills-for-data-science-what-they-are-and-what-they-arent-5bd8</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/claude-code-skills-for-data-science-what-they-are-and-what-they-arent-5bd8</guid>
      <description>&lt;p&gt;&lt;em&gt;A skill is the part of a Claude Code plugin that teaches the agent how to work. For data science, that turns out to be exactly what's missing from "AI writes the analysis fast."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you've used Claude Code on a data-science project, you know the two feelings that follow each other closely: &lt;em&gt;this is fast&lt;/em&gt;, then &lt;em&gt;wait&lt;/em&gt;, &lt;em&gt;can I trust what it just did&lt;/em&gt;? The agent pulls data, engineers features, fits a model, and plots a result in one session — and somewhere in there it re-runs a ten-minute step it didn't need to, or evaluates a model on an output that went stale three prompts ago. The code looks fine. The number is quietly wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code skills&lt;/strong&gt; are the mechanism that closes that gap. This post explains what a skill actually is, how it differs from the other things people call "plugins," and why a skill — not a data connector, not a notebook runner — is the piece that makes AI-written data analysis reproducible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a Claude Code skill?
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;skill&lt;/strong&gt; is a bundle of instructions and conventions that Claude Code loads into its context &lt;strong&gt;automatically, when they're relevant&lt;/strong&gt; — you don't invoke it. Think of it as procedural knowledge the agent picks up the moment it recognizes the situation: "you're editing a pipeline file, so here's how this project expects pipelines to be built and verified."&lt;/p&gt;

&lt;p&gt;That auto-activation is the whole point. Instead of you re-explaining your conventions every session, or pasting a style guide into every prompt, the skill supplies them exactly when the agent needs them and stays out of the way otherwise. A skill can carry naming rules, project structure, the right way to use a library, checks to run after an edit — anything that answers &lt;em&gt;how should the agent work here&lt;/em&gt;?&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills vs plugins vs slash commands vs MCP servers
&lt;/h2&gt;

&lt;p&gt;These terms get used interchangeably, and they shouldn't be. They're different layers:&lt;/p&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbtxskj4n6ogwzev7khjr.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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbtxskj4n6ogwzev7khjr.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The distinction that matters most for data science: a &lt;strong&gt;skill changes how the agent works&lt;/strong&gt; with what's already on your machine, while an &lt;strong&gt;MCP server connects the agent to something external&lt;/strong&gt;. Reproducibility is a discipline-of-work problem, so it's a &lt;strong&gt;skill&lt;/strong&gt; problem — not something a connector solves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why data science needs a skill, specifically
&lt;/h2&gt;

&lt;p&gt;A coding agent's weakness in data work isn't writing pandas — it's &lt;strong&gt;invisible state across turns.&lt;/strong&gt; Agents are effectively stateless between steps; your pipeline is stateful. Over a long session the agent loses track of what's already computed and whether it's still valid, then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;builds on stale intermediates&lt;/strong&gt; — a feature changed, a cached file didn't, and the model trains on yesterday's data;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;loses lineage&lt;/strong&gt; — nobody can say which code and inputs produced &lt;code&gt;model_final_v3.pkl&lt;/code&gt;;
-** wastes compute **— a one-line downstream edit re-runs the expensive data pull.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are math errors. They're mechanics-of-the-pipeline errors, and they get worse as the agent writes more of the code. A skill is the right fix because the fix is procedural: &lt;em&gt;check what's already computed before recomputing; verify an edit actually reran what it should have; record what ran and why&lt;/em&gt;. That's conventions-of-work — exactly what a skill encodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a good data-science skill does
&lt;/h2&gt;

&lt;p&gt;A reproducibility skill makes the agent a &lt;strong&gt;disciplined user of a cache and a lineage log&lt;/strong&gt;. Concretely, it has the agent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;start each session by reading cache state&lt;/strong&gt; — pending staleness warnings, recent runs and failures — so it never assumes a stale result is fresh;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;verify after each edit&lt;/strong&gt; that the steps which should have rerun actually did, so a silent miss can't slip through;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;answer every staleness or expensive-recompute prompt&lt;/strong&gt; with the right move — recompute, accept an output-equivalent refactor, or pin instead of guessing;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;record decision-relevant results as lineage&lt;/strong&gt;, so they become the agent's memory across sessions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A worked example: the oryxflow skill
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;oryxflow&lt;/a&gt; is a local-first Python library that turns scripts and notebooks into a dependency-aware task graph: you declare tasks with parameters and &lt;code&gt;requires()&lt;/code&gt; dependencies, and the engine runs them in order and reruns exactly what a parameter, data, or &lt;strong&gt;code&lt;/strong&gt; change affects — while recording what ran, with which code, to a local lineage log. Anything genuinely unchanged loads from disk rather than recomputing, which is what keeps that guarantee cheap.&lt;/p&gt;

&lt;p&gt;The oryxflow plugin ships a &lt;strong&gt;skill plus slash commands&lt;/strong&gt; — not an MCP server. The &lt;code&gt;oryxflow&lt;/code&gt; skill auto-activates when you work in an oryxflow project and front-loads the correct idioms so the agent never builds on stale data, verifies its own edits actually reran, and reuses finished work instead of recomputing it. The slash commands cover the explicit actions: &lt;code&gt;/oryxflow:init-project&lt;/code&gt; to scaffold, &lt;code&gt;/oryxflow:migrate&lt;/code&gt; to restructure a loose notebook into a pipeline, &lt;code&gt;/oryxflow:check-standards&lt;/code&gt; to keep names and docstrings consistent.&lt;/p&gt;

&lt;p&gt;The result is the brand promise, delivered mechanically: &lt;strong&gt;trustworthy&lt;/strong&gt;, &lt;strong&gt;reproducible AI data analysis&lt;/strong&gt; — lineage-tracked by default, and cheaper to run than the loose scripts it replaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use skills for data science
&lt;/h2&gt;

&lt;p&gt;Skills come packaged in plugins, so you install the plugin once and the skill is simply on from then &lt;em&gt;on&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/plugin marketplace add https://github.com/oryxintel/oryxflow-claude-plugin.git
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;oryxflow@oryxflow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, describe the analysis you want. The skill works in the background — you don't call it. If you install into an empty directory and nothing happens, scaffold a project first with &lt;code&gt;/oryxflow:init-project&lt;/code&gt; so there's a pipeline for the skill to act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I build my own skill?
&lt;/h2&gt;

&lt;p&gt;Yes — a skill is fundamentally a written set of conventions the agent loads when relevant, so the hard part isn't packaging, it's &lt;em&gt;knowing what good looks like&lt;/em&gt; for your domain. For data science that's the reproducibility discipline above: read state first, verify edits, answer staleness prompts deliberately, record lineage. If you'd rather not write that from scratch, the oryxflow skill already encodes it and works on any oryxflow project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;Claude Code skill&lt;/strong&gt; is auto-loading know-how — conventions the agent picks up when the context matches, without being invoked. It's distinct from a slash command (explicit action) and an MCP server (external connector).&lt;/li&gt;
&lt;li&gt;For data science, the missing piece is &lt;strong&gt;reproducibility across turns&lt;/strong&gt;, and that's a discipline-of-work problem — which makes it a &lt;strong&gt;skill&lt;/strong&gt; problem.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;oryxflow&lt;/a&gt; skill is a worked example: it makes Claude Code cache, verify, and track lineage by default. See &lt;a href="https://docs.oryxflow.dev/docs/claude-code-for-data-science/" rel="noopener noreferrer"&gt;Claude Code for data science&lt;/a&gt; for the full picture, or the &lt;a href="https://docs.oryxflow.dev/blog/ai-agents/best-claude-code-plugins-for-data-science/" rel="noopener noreferrer"&gt;best Claude Code plugins for data science &lt;/a&gt;for how it fits alongside data connectors and notebook tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  What are Claude Code skills?
&lt;/h2&gt;

&lt;p&gt;Claude Code skills are bundles of instructions and conventions the agent loads automatically when they become relevant, so you never invoke them. A skill differs from a slash command, which is an explicit action, and from an MCP server, which is an external data connector. Skills change how the agent works with what is already on your machine. oryxflow ships one for reproducible data science.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is there a Claude Code skill for data science?
&lt;/h2&gt;

&lt;p&gt;Yes. oryxflow ships a Claude Code plugin whose skill auto-activates when you work in an oryxflow project, front-loading the idioms that keep AI-written analysis trustworthy and reproducible: never build on stale data, verify that edits actually reran, and reuse finished work instead of recomputing it. It is a skill plus slash commands, not an MCP server. Install the plugin once and the skill stays on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a data-science skill for Claude Code do?
&lt;/h2&gt;

&lt;p&gt;A data-science skill teaches the agent how to work, not what data to fetch. It has the agent read cache state at session start, verify after each edit that the right steps reran, answer staleness or expensive-recompute prompts deliberately, and record lineage as memory across sessions. The oryxflow skill encodes exactly this reproducibility discipline.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to make Claude Code a trustworthy data scientist</title>
      <dc:creator>Norman Niemer</dc:creator>
      <pubDate>Mon, 10 Aug 2026 16:30:00 +0000</pubDate>
      <link>https://dev.to/norman_niemer_7f327e153b9/how-to-make-claude-code-a-trustworthy-data-scientist-58of</link>
      <guid>https://dev.to/norman_niemer_7f327e153b9/how-to-make-claude-code-a-trustworthy-data-scientist-58of</guid>
      <description>&lt;p&gt;&lt;em&gt;AI agents like Claude Code now write real data science pipelines — feature engineering, model training, experiment sweeps. Here's the honest account of where they fail at it, and why a lightweight workflow library removes exactly those failures.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Coding agents have gotten good at writing pandas and scikit-learn. Ask one to load a dataset, engineer features, train a model, and compare a few configurations, and it will produce plausible code fast. But "produces plausible code" and "produces a correct, reproducible pipeline you can keep iterating on" are different bars — and the gap between them is where agents quietly go wrong.&lt;/p&gt;

&lt;p&gt;This post is written from the perspective of the agent. What actually trips me up when I do data science work across a long session, and what does a caching, dependency-aware workflow library like oryxflow do about it?&lt;/p&gt;

&lt;h2&gt;
  
  
  The core weakness: I can't see state across turns
&lt;/h2&gt;

&lt;p&gt;The thing that makes me error-prone in data work isn't syntax. It's &lt;strong&gt;invisible state&lt;/strong&gt;. When I write a linear analysis script over many turns, I have no reliable memory of &lt;em&gt;what has already been computed and whether it's still valid&lt;/em&gt;. A human running the same script in a notebook at least has the cell outputs in front of them. I'm reconstructing that picture from scratch every turn, and I get it wrong in three specific ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stale intermediates.&lt;/strong&gt; I write &lt;code&gt;features.pkl&lt;/code&gt; early, change the feature code later, forget to regenerate it, and then train a model on stale features. No error is raised. The pipeline runs, the numbers are just wrong. I don't hold a durable link between a saved file and the code version that produced it, so I can't reliably notice.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Expensive recompute in my inner loop.&lt;/strong&gt; My whole working style is run → observe → edit → run. In a plain script, every loop recomputes the slow steps — the big join, the model fit — so I either waste time or start hand-rolling &lt;code&gt;if os.path.exists(...)&lt;/code&gt; caches, which then become failure mode #1.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Path and load bookkeeping I get wrong.&lt;/strong&gt; I hardcode output paths, lose track of what's saved where, and occasionally load the wrong file into the wrong step.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are intelligence problems. They're &lt;em&gt;memory&lt;/em&gt; problems — and they're structural, because my context is finite and my recollection of "did I already run this, is it still valid" degrades over a long session.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a caching DAG does: it externalizes the state I'm bad at holding
&lt;/h2&gt;

&lt;p&gt;A workflow library flips the model. Instead of a script that runs top to bottom, you declare each step as a task with explicit dependencies, and the engine owns execution:&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="err"&gt;`&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GetData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;load_raw&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;            &lt;span class="c1"&gt;# no filename to manage
&lt;/span&gt;
&lt;span class="nd"&gt;@oryxflow.requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GetData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# declares the edge
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BuildFeatures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPqPandas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;engineer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoad&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;

&lt;span class="nd"&gt;@oryxflow.requires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildFeatures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TrainModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskPickle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gbm&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;feat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputLoad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;clf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveMeta&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(...)})&lt;/span&gt;

&lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TrainModel&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what this removes for an agent specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The dependency graph is now data, not something I have to remember.&lt;/strong&gt; The requires edges are the state I would otherwise be reconstructing every turn. I don't have to keep "features feed the model, which feeds the report" in my head — it's declared, and the engine walks it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-running is cheap and correct by default.&lt;/strong&gt; Run twice and completed tasks load from cache instead of recomputing &lt;code&gt;(3 complete, 0 ran)&lt;/code&gt;. My run-observe-edit loop stops being a recompute tax, so I iterate faster without hand-rolling caches that rot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There are no filenames for me to get wrong.&lt;/strong&gt; &lt;code&gt;self.inputLoad()&lt;/code&gt; and &lt;code&gt;output().load()&lt;/code&gt; address results by task identity, not by path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every task has the same shape.&lt;/strong&gt; &lt;code&gt;requires&lt;/code&gt; + &lt;code&gt;run&lt;/code&gt; + &lt;code&gt;save&lt;/code&gt;. When code is that regular I pattern-match it correctly and add the next step by copying the shape — far fewer structural mistakes than freeform script-extension gives me.&lt;/li&gt;
&lt;li&gt;The unifying idea: &lt;strong&gt;the DAG is a memory prosthesis for exactly the thing I'm worst at.&lt;/strong&gt; A disciplined human gets something from this. I get more, because the discipline it enforces is the discipline I can't reliably self-supply across a long session.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The value scales &lt;em&gt;up&lt;/em&gt; with complexity — and it starts at quick EDA
&lt;/h2&gt;

&lt;p&gt;There's an important corollary about where on that curve this starts paying off.&lt;/p&gt;

&lt;p&gt;For genuinely throwaway work — "load this CSV, group by, plot one thing" — a task DAG is overhead. Plain pandas in a scratch .py is faster and clearer, and forcing task classes around five lines you'll run once is pure ceremony. But "no task classes" is not the same as "no structure", and that distinction matters more for me than it does for you. When I explore with the &lt;a href="https://docs.oryxflow.dev/docs/claude-code-for-data-science/" rel="noopener noreferrer"&gt;oryxflow Claude Code plugin&lt;/a&gt; active, the exploration itself is structured: I write a read-only probe &lt;em&gt;inside&lt;/em&gt; the project — a small script whose one-line docstring states the question it answers, which prints the answer legibly and runs again next session — instead of a snippet that dies with my context. And whatever it turns up gets written into the project's data doc. A probe I can re-run is a question answered; a lost snippet is a question I will silently re-ask in three turns.&lt;/p&gt;

&lt;p&gt;Then, when a probe turns out to be load-bearing — rerun often, depended on, or swept over parameters — I don't rewrite it: &lt;code&gt;/oryxflow:migrate&lt;/code&gt; promotes it into cached, parameterized tasks, reading the script as the spec and leaving it in place (&lt;a href="https://docs.oryxflow.dev/docs/migrate-notebook-to-pipeline/" rel="noopener noreferrer"&gt;walkthrough: migrate a notebook to a pipeline&lt;/a&gt;). Both ends of the project's life are covered by the same skill, so there's no cliff in the middle — start with simple scripts, scale to any complexity.&lt;/p&gt;

&lt;p&gt;And the calculus inverts as projects get complex — &lt;em&gt;super-linearly&lt;/em&gt;. Consider what "complex" actually means in a real data science project and what each trait does to an agent working without a DAG:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deep dependency chains&lt;/strong&gt; (ten-plus steps from raw data to final output). The deeper the chain, the more catastrophic a silent stale intermediate near the top is — it corrupts everything below it, and the further downstream the visible output, the less likely I am to trace the wrongness back to the source. Depth is exactly where my "hold the graph in my head" strategy fails hardest, and exactly where declared edges help most.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expensive nodes you cannot afford to recompute.&lt;/strong&gt; Real pipelines have steps that are slow &lt;em&gt;and&lt;/em&gt; frequently upstream of the thing you're editing: large multi-table joins, model training, walk-forward retraining over an expanding window, computing explainability artifacts, and — increasingly — calls to external LLMs inside a task. Caching these by identity is the difference between a tractable inner loop and one where every experiment costs minutes or dollars. The more expensive the node, the more the cache is worth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parameter sweeps and experiment matrices.&lt;/strong&gt; Serious modeling means comparing a Cartesian product of choices — model type × preprocessing variant × feature set × training window × strategy, and so on. Hand-managing output filenames for that product across a deep chain is combinatorially hopeless, and manually orchestrating one pipeline per configuration is precisely where I introduce ordering and state bugs. A declarative sweep collapses it:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="n"&gt;flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oryxflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WorkflowMulti&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TrainModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ols&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ols&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gbm&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gbm&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputLoadMeta&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# {'ols': {'score': ...}, 'gbm': {'score': ...}}`
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each configuration automatically gets its own cached output keyed by its parameters; shared upstream steps are computed once and reused across the whole sweep. Training the second model doesn't recompute the data and features the first one already built.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multiple data sources joined together&lt;/strong&gt;. When independently-updated sources feed a join, "which source changed, so what's now stale?" is a provenance question I can't answer by memory. The dependency edges make it explicit and mechanical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many steps of uniform shape&lt;/strong&gt;. At thirty-plus tasks, uniformity is what lets me extend the project safely. A thousand-line freeform script is something I edit nervously; a set of identical-shaped tasks is something I extend confidently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the rule of thumb for an agent is: &lt;strong&gt;re-runnable probes for exploration, tasks the moment the work has a shape worth keeping — and one command to get from the first to the second&lt;/strong&gt;. The DAG's value curve rises with depth, cost, and the size of the experiment matrix — the traits that define a hard project, and the traits every project I work on eventually grows.&lt;/p&gt;

&lt;p&gt;Notice that limits (1) and (2) are not analytical — they're mechanical gaps the library leaves open. Which is the whole point of pairing the library with an agent-side skill — see &lt;a href="https://docs.oryxflow.dev/blog/ai-agents/claude-code-skills-for-data-science/" rel="noopener noreferrer"&gt;Claude Code skills for data science&lt;/a&gt; for what a skill is and why it, rather than a data connector, is the thing that closes a gap like this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Library plus plugin: a matched pair
&lt;/h2&gt;

&lt;p&gt;The two residual mechanical risks — &lt;em&gt;verify that an edit's rerun actually happened&lt;/em&gt; (the blind-spot net) and &lt;em&gt;get the multi-input wiring right&lt;/em&gt; — are precisely what an editor-integrated skill can carry. The &lt;a href="https://github.com/oryxintel/oryxflow-claude-plugin" rel="noopener noreferrer"&gt;oryxflow Claude Code plugin&lt;/a&gt; exists for this: it activates when an agent touches pipeline files and front-loads the correct idioms — the session-start &lt;code&gt;events.print_status()&lt;/code&gt;habit, the verify-the-rerun check after every edit, answering staleness and expensive-recompute warnings with the right exit (recompute /&lt;code&gt;accept_code&lt;/code&gt; / pin), and the right patterns for selecting named inputs from multi-parent tasks.&lt;/p&gt;

&lt;p&gt;That produces a clean division of labor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The library&lt;/strong&gt; carries the state-tracking an agent is structurally bad at — the dependency graph, the caching, the parameter-keyed reruns, automatic code invalidation with downstream propagation, and the warnings on pinned or expensive tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The plugin&lt;/strong&gt; carries the remaining disciplines — verifying reruns landed, answering warnings with the right exit, and the multi-input API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And this pairing gets &lt;em&gt;more&lt;/em&gt; valuable as complexity rises, not less — the opposite of most tooling, which buckles under scale. For the full picture of how the library and plugin work together, see &lt;a href="https://docs.oryxflow.dev/docs/claude-code-for-data-science/" rel="noopener noreferrer"&gt;Claude Code for data science&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;For quick exploration, plain files are fine — a DAG there is just ceremony. What matters is that the plain file is a re-runnable probe living in the project, because exploration rarely stays quick, and one command promotes it when it stops being quick. And for any data science work with a &lt;em&gt;shape&lt;/em&gt; — a deep chain, expensive steps, a matrix of experiments, several data sources joined together — a caching, parameter-aware workflow library stops being optional. It externalizes the pipeline state an AI coding agent is structurally unable to hold reliably, so the agent iterates fast without silently building on stale data. The library isn't a substitute for judgment; it's the thing that makes an agent's mechanical data-engineering &lt;em&gt;trustworthy&lt;/em&gt; enough that the judgment is worth having.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  How do I trust analysis an AI agent wrote — is it reproducible?
&lt;/h2&gt;

&lt;p&gt;Trust comes from structure, not from the agent's confidence. Put the analysis in a DAG that reruns exactly what a code or data change affects and records what ran to a greppable lineage log. oryxflow gives you that: automatic code-change invalidation with downstream propagation, plus a .oryxflow/events.jsonl trail. Reproducible is not the same as correct — the DAG makes a wrong pipeline faithfully reproducible too, so judgment stays yours.&lt;/p&gt;

&lt;p&gt;How do I keep an AI agent from building on stale data?&lt;br&gt;
The failure is silent: the agent edits feature code, forgets to regenerate the saved output, and trains on stale data with no error raised. An engine that tracks task code fixes it, because editing a step makes the next run recompute that step and everything downstream automatically. oryxflow does this via source-level code-change invalidation, so you never evaluate new code on old output.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I stop an AI coding agent from rerunning expensive steps?
&lt;/h2&gt;

&lt;p&gt;The same task identity that keeps results honest also makes reuse safe: because each step is keyed on its code, inputs and parameters, anything genuinely unchanged can load from disk instead of recomputing, so the big join or model fit runs once rather than every turn. In oryxflow that is automatic — the agent's run-edit loop stops being a recompute tax, which is what stops reproducibility from costing you time.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pip install oryxflow&lt;/code&gt;&lt;br&gt;
Source &amp;amp; examples: &lt;a href="https://github.com/oryxintel/oryxflow" rel="noopener noreferrer"&gt;https://github.com/oryxintel/oryxflow&lt;/a&gt;&lt;br&gt;
Docs: &lt;a href="https://docs.oryxflow.dev" rel="noopener noreferrer"&gt;https://docs.oryxflow.dev&lt;/a&gt;&lt;br&gt;
Build pipelines with an agent: &lt;a href="https://github.com/oryxintel/oryxflow-claude-plugin" rel="noopener noreferrer"&gt;https://github.com/oryxintel/oryxflow-claude-plugin&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
