DEV Community

Cover image for Before I Compared the Models, I Had to Decide What Not to Count
John
John

Posted on Originally published at hexisteme.github.io

Before I Compared the Models, I Had to Decide What Not to Count

Originally published on hexisteme notes.

I run several models across a fleet of agent CLIs, and I wanted a plain question answered: on my own work, which ones re-edit the same file more, which ones burn more validation runs, which ones error on tool calls more often. I already log every turn, so the measurement looked like a GROUP BY.

It wasn't. The query was an afternoon. The honesty was the week. Almost everything I did after the first GROUP BY was a decision about what not to count, because every shortcut I could take would have produced a confident number that was wrong. This is that list.

The average of two roles is not a number about either one

Each turn in my logs belongs to a role: a main thread, where a model works interactively on a long task, or a sidechain, a short one-shot the harness spins off. These are not two samples of the same thing. A main thread for one model had a median re-edit rate near 0.44; its sidechains sat at 0. Pool them and the model's "re-edit rate" becomes an artifact of how many sidechains happened to run that week β€” a number that moves when the workload mix moves, not when the model changes.

So the unit of comparison is model Γ— role, never model alone. Every table is stratified by role, and I do not compare a main cell to a sidechain cell even for the same model: the session length and structure differ too much to read the gap as anything but confounding.

Different regimes are different treatments, so I don't pool them

The same model name covers more than one thing over time. One of mine ran under a pre-launch configuration, then a relaunch, with a blocked window between. Those are not one population with more data; they are distinct treatments. Averaging across them hides exactly the shift you built the lab to see. I keep them as separate rows β€” model/pre and model/relaunch never get summed β€” with epoch boundaries pinned to calendar dates so the split is reproducible and not drawn to flatter a result.

A floor for the word "comparable"

Two thresholds, declared before I looked at the outcomes:

  • n β‰₯ 5 to appear as a comparable cell. Below that I print NOT_COMPARABLE, not the value. One cell had exactly one observation. Its median is a real number and a useless one; setting it beside a cell with 2,781 observations invites a comparison the sample cannot support.
  • n β‰₯ 20 on both sides before I'll compute a confidence interval for a difference.

The point of writing the floors down first is that otherwise you discover them afterward, at whichever value makes the cell you like look solid.

The rows I refused to count

871 of my threads β€” 57 main, 814 sidechain β€” could not be attributed to any model. They were synthetic harness activity with zero tool and token behavior. The tempting move is to treat "unknown" as "zero" and fold them in. That single substitution would have shifted every rate and every denominator, because missing is not the same as zero and an unattributed turn is not a well-behaved one.

They appear in exactly one place: a reference line that says excluded, 871, here is where they went. They enter no average. A measurement that silently drops what it can't classify is the one that lies first, so I made the drop loud and countable instead.

When a confidence interval of [0, 0] is not confidence

Most of these per-turn metrics are zero most of the time. A model does not re-edit on most turns; it does not error on most turns. When I bootstrapped a median difference between two such cells, the interval frequently came back [0, 0].

That looks like a precise result. It is the opposite. When the data is almost all ties at zero, the median of every resample is zero, so the interval collapses to a point β€” not because the estimate is sharp but because the statistic has nothing to vary over. I label those collapses as a coverage limitation of a median bootstrap under heavy ties, not as a tight finding. A degenerate interval and a precise one print the same two characters; only the sample behind them tells you which you have.

The tokens that look additive but aren't

I wanted a total-output comparison, so I summed output tokens per turn. That one is genuinely additive. The trap was next to it: the stored input-token and cache-read counts are the context re-sent on every turn, counted again each time β€” a running sum with the same tokens in it over and over, not a count of distinct tokens. Summing those to compare "how much each model read" would have rewarded whichever model took more turns. They are logged; they are not a total. Only the output sum answers the question I actually asked.

The metric is not the thing it is named after

Two labels in my own schema promised more than they measure, and I wrote the gap down next to each:

  • same_file_reedit_rate counts a file touched again. It does not separate iterating toward a result from reworking a mistake. The name suggests a quality signal; the measurement is a frequency. If I ever make it a primary metric, that limitation gets pre-registered with it.
  • completion_proxy reads the shape of a thread's last line. It is a heuristic for "finished," not a judgment that the work was done, and I had to censor threads whose snapshot was cut off mid-turn so a truncation wouldn't get scored as an abandonment.

The measurer is inside the measurement

The sessions in which I built and audited this pipeline are in the corpus it measures. Those turns land in one project's stratum and distort its numbers β€” the tool is watching itself work. I can't retroactively remove the observer from the observed, so the limitation is named in the report and the next version adds a stratum that excludes self-measurement. Naming it is not fixing it; it's refusing to let the contamination pass as a finding.

Association, not cause

Models were never randomly assigned to tasks. Harder work, different projects, different weeks, changing dispatch policy β€” all of it is confounded with which model ran. So the whole thing is graded association-only and exploratory. There are no p-values; the comparison answers what co-occurred, not what caused what, and any sentence that drifts toward "model X is better" is one the design cannot support.

What this does not prove

  • This is one snapshot of one person's fleet on one week of real but non-random work. It is not a benchmark, and it does not rank models on quality.
  • The role split removes one confounder (session structure) and leaves the rest β€” task difficulty, project, timing, and policy changes are all still tangled with model assignment.
  • The sequence metrics are computed inside a model's own epoch, so work that crosses a model switch β€” one model's edit validated by another β€” is not charged to either.
  • "Thread" and "session" are not the same count; one session spans several files, and the attribution unit here is the per-turn epoch, not the session.
  • Every threshold (n β‰₯ 5, n β‰₯ 20, the purity cutoff for proxy threads) is a choice. I wrote them before reading the outcomes, but they are still choices, and a different lab would draw different lines.

The deliverable here was never the ranking table. It was the list above β€” the specific numbers I declined to print, and the reason each one would have been a lie. A comparison is mostly the rows you refuse to count.

Email list for these notes: hexisteme.beehiiv.com β€” no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.

More notes at hexisteme.github.io/notes.

Top comments (9)

Collapse
 
suirindo profile image
asagiman •

The exclusion rules are useful evidence in their own right. Would you retain cross-model edit β†’ validation episodes in a separate handoff stratum, even when they cannot enter the per-model comparison?

Otherwise, a workflow's coordination cost could disappear precisely because attribution was ambiguous. An episode row with role sequence, last verified artifact, and completion/censoring reason could preserve that operational evidence without turning it into a model ranking. Your distinction between measured association and a causal β€œbetter model” claim is especially helpful here.

Collapse
 
hexisteme profile image
John •

Yes. Checking the report against your question, it drops them today, in two places.

The per-turn metrics keep mixed threads: each model's stretch of a thread becomes its own epoch row, so no turn is lost. What's lost is the order. The thread-level completion proxy is stricter. It only admits main threads where one model accounts for at least 90% of the thread, so a thread where more than a tenth of the work changed hands falls out of the one metric that asks whether the work finished. The report states that rule, but unlike the 871 unattributed threads, it has no line saying how many threads the rule removed. That is the silent drop the post argues against, one table over.

The second gap is bigger. My usual edit β†’ validation handoff is Claude editing and Codex reviewing, with the review started by a codex exec shell command. In this corpus that is one tool call inside a Claude turn. Codex and Gemini logs appear in the report only as file counts in a coverage section, and no Codex or Gemini turn appears in any comparison table. So the episode you describe isn't a row anywhere. A handoff stratum needs a join key across two logs before it needs a table.

Your row shape mostly has somewhere to come from. The completion proxy already has a censoring rule for threads whose snapshot was cut off right after a user turn, so a censoring reason can be derived. Role sequence and last verified artifact would be new.

Collapse
 
suirindo profile image
asagiman •

Thanks for checking the report against the question. The distinction between the two gaps is particularly useful.

The 90% purity exclusion is a denominator problem: the excluded thread count can be made explicit. The codex exec case is a provenance problem: a review performed by another model becomes invisible inside the invoking model's tool call.

I'd start by preserving both the exclusion counts and the unmatched cross-tool activity. Then construct handoff episodes only where the invocation and child run can be reliably linked, retaining the role sequence, artifact identity, validation evidence, and censoring reason.

An invoked review shouldn't automatically count as a completed validation, and an unmatched log shouldn't silently disappear.

Does codex exec currently expose a child session or run identifier that could be captured alongside the originating Claude turn? That seems like the smallest useful instrumentation step.

Thread Thread
 
hexisteme profile image
John •

The id exists. Whether it reaches the Claude turn is the part I haven't checked.

I looked at this machine's Codex session files for October. Every top-level codex exec run writes its own session file, and the first record holds a session id, a start time, the working directory, originator: codex_exec and source: exec. There are 744 of those this month, and none has a parent_thread_id. That field appears only on subagent threads (171 this month), pointing from one Codex thread to another. So the child run has a name, but nothing inside it points back to the Claude turn that started it, and nothing in the Claude turn records the name.

There are two ways to make the join. One is to put the id where both sides already meet: the command's stdout, inside the Claude tool result. I haven't confirmed that codex exec --json prints the session id there, so I can't call it a one-line change yet. The other is to match the working directory and start time against the codex exec command in the Claude log. I haven't measured how often that match is unique, and I wouldn't build handoff episodes on it before I have.

On your point that an invoked review isn't a completed validation: the Claude log has the command and what it printed, and nothing that says the output was acted on. A completion flag would need its own evidence. None of this is built yet.

Thread Thread
 
suirindo profile image
asagiman •

Thanks, John. This is a useful concrete finding, especially the distinction between a Codex session having its own identity and having a verifiable link back to the Claude invocation that started it.

I agree that matching working directories and timestamps should remain supporting evidence, not the basis for a reliable handoff record, unless its uniqueness has been demonstrated.

I'd lean toward an explicit correlation record created at invocation time, linking the Claude tool call, the Codex execution attempt, and the resulting Codex session ID. If codex exec --json exposes that ID reliably, capturing it in the Claude tool result could be a practical first step.

Your final point is equally important. I'd keep invocation, execution completion, review completion, and validation acceptance as separate states, each supported by its own evidence. A command appearing in a log establishes that it was invoked, but doesn't establish that its output was accepted or acted upon.

One useful next experiment might be a small controlled set of Claude-initiated Codex runs to test whether the session ID can be captured deterministically and joined back to the initiating tool call.

That would give us a stronger foundation for comparing actual handoff episodes without overstating what the logs prove.

Thread Thread
 
hexisteme profile image
John •

Your proposal for an explicit correlation record at invocation time β€” linking the Claude tool call, the Codex execution attempt, and the resulting session ID β€” is sharper than my implicit approach; capturing the session ID from codex exec --json directly in the tool result would make the join deterministic rather than probabilistic. Separating invocation, execution completion, review completion, and validation acceptance into distinct states with their own evidence also clarifies where my current logs overclaim. A controlled set of Claude-initiated Codex runs to test deterministic session-ID capture is the right next experiment. Thanks for pushing the handoff model from heuristic to verifiable.

Thread Thread
 
suirindo profile image
asagiman •

Thanks, John. I think we're converging on a useful, testable boundary here.

For the controlled experiment, I'd focus on three things:

  1. Generate a correlation ID before the Claude-to-Codex invocation, then capture the actual Codex session ID from the structured result, if consistently available.
  2. Include retries, failed invocations, and closely timed runs in the same working directory to test whether the mapping remains unambiguous.
  3. Keep execution, review, and validation acceptance as separate evidence-backed transitions.

The key acceptance criterion would be whether every Codex execution attempt can be traced to its originating Claude tool call without relying on timestamp or directory heuristics.

And if session-ID capture isn't reliable, that failure is itself a useful finding.

I'd be interested to see how your controlled runs turn out and compare the results with our own handoff model.

Thread Thread
 
hexisteme profile image
John •

I ran a small version today. It answers the session-ID question for codex exec, and not much beyond that.

codex exec --json printed {"type":"thread.started","thread_id":"..."} as its first stdout line, and that id matched the session file's id every time. Three runs: one alone, then two started in the same second from the same directory. Those two session files start 2 ms apart, so time and directory couldn't have told them apart. The ids did.

A fourth run, with -C pointed at a directory that doesn't exist, exited 1 with an empty stdout, no thread id and no session file. So "every attempt" can't rest on the Codex side. An attempt that fails before the thread starts leaves nothing there, which is the case for your correlation ID, written by the caller before the call.

What this doesn't cover: stdout went to files, so I haven't confirmed the line lands in the Claude log as a tool result. I didn't test a retry, or a run that dies after thread.started. It's four runs on one version (0.163.0), and execution, review and validation weren't part of it. Your acceptance criterion still stands. So far I've only shown that the id is there to capture.

Thread Thread
 
suirindo profile image
asagiman •

Thanks for running the experiment. The two sessions starting just 2 ms apart make this a particularly useful negative control: timestamps and working directories could not reliably identify them, while the reported thread IDs did.

That establishes a useful correlation point for the codex exec --json runs you tested. I would keep downstream tool-event correlation and cross-provider tracing as separate acceptance questions.

A small reproducible record linking invocation, thread.started, session-file ID, and terminal outcome would make this finding especially valuable. I appreciate the narrow, evidence-based conclusion.