Skip to main content
agentic-aivalidationrust
11 min read

Trajectory Lab: Scoring Structure, Not Length

I argued that the structure of an agent run beats its length. Then I built a tool to measure it, and won't ship it until the central claim is proven. Here's the build, and the gap.

By Pallav

I handed the same auth-refactor task to the same model twice. The first time I gave three agents a vague brief ("clean up the auth module") and walked away. Two of them wrote the same file, the lead reported a clean merge that didn't actually reconcile the two versions, and the run scored 31 out of 100. An F.

The second time I gave the same three agents a tight, bounded brief: one file each, explicit boundaries, a verification step. Same model, same task, same swarm topology. It scored 100. An A.

Last time, in Scaffolding Over Horsepower, I argued that the leverage in agentic coding is the structure around the loop, not the model or the agent count. That was an argument. The two scores above are what happened when I tried to turn the argument into a number: a system I've been building called Trajectory Lab. A claim you can't measure is just an opinion, and I wanted to know if mine survived contact with data.

It mostly did. But the part that matters most, the part that justifies the whole tool, isn't proven yet, and I'm not shipping until it is. This post is the build and the gap, in that order.

Same task, same model. The brief is the only variable.VAGUE BRIEFworker-a writesworker-b writesauth/session.tscollision: 2 agents, 1 filelead merge: ok=falsehallucinated merge31 / FTIGHT BRIEFworker-a writesworker-b writesauth/session.tsauth/tokens.tsdisjoint targets, no collisionlead merge: ok=trueverified against tests100 / A
FIG_01: THE SAME TASK, SCORED. STRUCTURE IS THE ONLY DIFFERENCE.

Score the shape, not the length

The whole design rests on one refusal: length is not a signal. A run isn't better because it took more steps or worse because it took fewer. That sounds obvious until you notice how much agent tooling quietly rewards verbosity. So every metric in Trajectory Lab is a ratio or an event count: something that doesn't move just because the run got longer.

It scores a trajectory: one agent run, an ordered set of steps by one or more agents toward a goal. Each step is classified into one of read | write | verify | plan | merge, and steps are attributed to the leaf actor that ran them: a sub-agent's writes belong to the sub-agent, not the orchestrator. From there, four metrics, each between 0 and 1, where W is the number of writes:

MetricFormulaWhat it captures
grounding1 − blindWrites / WDid the agent read a file before writing it? A write with no prior read by the same agent is a blind write.
validationmin(verifies / W, 1)Is the work checked? Tests, type-checks, builds: verification per unit of writing.
focus (anti-churn)1 − redundantWrites / WNo needless rewrites. Re-editing the same file over and over is churn, and churn predicts trouble.
collision ratecollisions / WPer-write collision pressure: a collision is the same file written by more than one agent in a single run, the failure that sinks a swarm.

None of these counts steps. A 4-step run and a 40-step run with the same shape score the same. That's the point, and later it turns out to be the empirically correct choice, not just an aesthetic one.

Health is then 100 minus a set of named, attributable penalties, each one traceable to specific steps in the run, not a black-box number. The weights aren't guesses; I'll get to where they came from.

health (crates/core/src/score.rs)
text
p_ground  = -round(14 * (1 - grounding))
p_valid   = -round(26 * (1 - validation))
p_focus   = -round(28 * (1 - focus))
p_collide = -round(min(collisions   * 15, 36))   // capped
p_merge   = -round(min(failedMerges * 14, 28))   // capped

health = clamp(100 + p_ground + p_valid + p_focus + p_collide + p_merge, 0, 100)
// grade: A >= 90 · B >= 75 · C >= 60 · D >= 45 · else F

And every penalty surfaces as a human-readable finding that points at the real steps that caused it. Severity isn't cosmetic: it decides what the CI gate will block on, so it's load-bearing.

FindingSeverityFires when
collision:<file>highA file was written by more than one agent
hallucinated mergehighA merge step reports success on changes it didn't actually reconcile (ok:false)
blind writemedA write with no prior read by that agent
no validationmedWrites exist but nothing verifies them
cleanlowNone of the above: the shape that ships

One core, two compile targets

Here's the architecture decision I'm proudest of, because it heads off the one bug a scoring tool cannot survive: the dashboard and CI disagreeing on a score. If the web tool says an agent run is an A and the CI gate says it's a D, the whole product is worthless: nobody can trust either number. A second implementation that can drift is, for this product, the unforgivable bug.

So there is exactly one scorer: a pure Rust core (types, classifier, scoring engine, gate) with no I/O. It's compiled two ways: to WebAssembly for the browser dashboard, and to a native binary for the trajectory CLI that runs in CI. Same bytes of logic, two delivery vehicles. The browser and the build server are literally running the same compiled function.

trajectory-core (Rust, pure, no I/O)typesclassifierscoringgateWASMbrowser dashboardnative binarytrajectory CLI in CITS parity oracle502 / 502 vectorsSSR + pre-load fallbackTS == native == WASM, proven before the TS scorer retires
FIG_02: ONE SCORER, COMPILED TWO WAYS. THE DASHBOARD AND CI CANNOT DRIFT.

An earlier TypeScript scorer still exists, but only as a conformance oracle: a parity check runs every trajectory through TS, native, and WASM and asserts all three agree (currently 502 of 502 test vectors identical) before the TS version is allowed to retire. The boundary types the React layer consumes are generated from the Rust structs, so the UI can't drift from the core either. The invariant is mechanical, not a matter of discipline.


The gate blocks on shape changes, not score swings

The obvious way to gate CI on a score is to block when the number drops. That's a trap. Agent runs are non-deterministic; the same task scores 88 one run and 84 the next for reasons that have nothing to do with quality. Gate on a wobbling number and you'll either block constantly or tune the threshold so loose it never fires.

So the gate is categorical, not numeric. It hard-blocks on exactly one thing: a new high-severity finding mode (a collision, or a hallucinated merge) that appears in the candidate run and was absent from every run in a baseline window of recent runs on the base branch. A genuinely new structural failure, not a few points of noise. The numeric health is warn-only by default; blocking on a health regression is an opt-in stricter rule.

WHAT "NEW" MEANS, AND WHY IT SURVIVES NOISE

A finding's mode is the prefix before the colon, so collision:auth/x.ts and collision:auth/y.ts are the same mode: collision. A run only blocks if its mode is absent from the entire baseline window, which makes the gate robust to run-to-run target churn. The baselines live in-repo as a small snapshot per task, refreshed on merge. Zero backend: scoring and baseline are fully local, and nothing leaves CI.


The part where I can't ship it

Everything above is built and working. The web tools score, the CLI gates, the three implementations agree to the vector. And the npm package is marked private, because none of that matters if the score doesn't actually predict whether a run succeeds. A scorer that gives confident grades uncorrelated with reality is worse than no scorer: it launders a guess into a number. So shipping is gated on validation, and validation is where the honesty lives.

The first test I could run, I ran. There's a public dataset (nebius/SWE-agent-trajectories on HuggingFace) of real SWE-agent runs on SWE-bench, each labeled with whether its patch actually resolved the task. I adapted roughly 3,100 sampled runs into the trajectory schema and scored them with the same core, then measured each metric by AUC: the probability that a resolved run scores above an unresolved one, where 0.5 is a coin flip and means no signal.

SignalResultRead
focus / anti-churnAUC ~0.62Real signal. Failing runs rewrite the same files far more. Churn is a genuine tell.
validation≈2x resolutionRuns that verify resolve at roughly double the rate (17.9% vs 9.7%).
groundingAUC 0.46Confounded here. SWE-agent's scaffold forces read-before-edit, so grounding saturates and carries little signal on this data.
length (raw)AUC 0.33Inverted: longer skews toward failure, but unreliably. Exactly why the design refuses to count steps; focus captures the churn part without the length.

These are spike numbers from a sampled subset, not a proof, but they were enough to act on. A train/test grid search re-weighted the health formula toward the signals that held (focus-led, validation close behind), lifting composite health AUC from about 0.55 to about 0.63, with the Rust and TS cores kept in lockstep the whole way. That's the decision behind the weights you saw earlier.

THE GAP THAT ACTUALLY BLOCKS SHIPPING

The single-agent spike validated grounding, validation, and focus. It could not touch the two findings the entire gate is built around (collision and hallucinated merge) because those structurally cannot fire on one agent. Two agents are required to collide. So the marquee feature is, as of today, unvalidated. That is the number-one risk, and I'm not going to pretend the single-agent result covers it.

The natural fix is to find a multi-agent dataset. There isn't one. A deep multi-source search came up empty on the specific combination I need: multi-agent, and per-agent file-targeted tool calls, and a per-run success label, and public. The closest datasets are failure-only or store traces as opaque prose with no structured file targets. So the only path is to generate labeled multi-agent traces where I control the schema: a parallel-worker-with-merge swarm, the one topology that can actually produce a collision.

A cold-read review caught me conflating two different questions there, so I split it into two experiments. And the distinction is the most important thing in this whole post.

Experiment A: trace fidelityBUILT · cheap · not circulardo the findings match git ground truth?faithful corpus: precision/recall = 1.0teeth check: dishonest corpus collapses to 0proves the metric can fail, so passing means somethingExperiment B: predictive validityNOT DONE · gates npm publishdoes a collision predict a worse outcome......beyond being a proxy for a bad decomposition?needs a control arm + statistical powerthis is what flips private -> public
FIG_03: TWO QUESTIONS, NOT ONE. FIDELITY IS BUILT; PREDICTION IS WHAT GATES SHIPPING.

Experiment A asks only: does the emitted trajectory faithfully represent what the agents did, and do the scorer's findings match an independent ground truth: the actual git diffs and whether the merge applied and built? No outcome label needed. This one is built. On a faithful corpus, collision, hallucinated-merge, and blind-write detection all hit precision and recall of 1.0. And critically, I ran a teeth check: a deliberately dishonest corpus where agents lie about what they touched (one reports a clean, partitioned set of writes while secretly editing a shared file, another claims a write it never made) collapses collision fidelity to zero. A fidelity metric that can't fail proves nothing; this one can fail, which is what makes its passing meaningful.

Experiment B asks the question that actually matters: does the presence of a collision predict a worse outcome, beyond just being a proxy for "the task was decomposed badly in the first place"? That needs a control arm (partitioned versus overlapping decompositions) and real statistical power. It is not done. Until it holds, the gate's headline feature is a plausible hypothesis with a working detector, not a validated predictor.

WHAT THE PILOT IS NOT

The Experiment-A harness currently runs with a fixture worker making real git edits, not a real coding agent. Its per-arm rates (clean separation between partitioned and overlapping arms) are artifacts of the fixture, not predictions about real agents. They prove the machinery and the adapter work end to end. They say nothing yet about how often real agents collide, which is exactly what Experiment B has to measure.


What holds, and what doesn't

What holds: length is measurably the wrong thing to reward. On real data it's not just unhelpful but mildly inverted, and the structural signals (churn, validation) carry what little predictive power length seemed to have. Structure is scorable in a way that points at specific steps. And because there's one core compiled two ways, the grade on the dashboard and the verdict in CI can't drift apart. That invariant is mechanical and done.

What doesn't hold yet: the two failure modes the gate is built around (collision and hallucinated merge) are unproven as predictors, because no public data could test them and the experiment that can is only half-run. So the package stays private. The last post's discipline was to not add a building block until you can name the failure it prevents. The same rule applies one level up: don't ship a tool that grades agent runs until you can show the grade predicts the failure it claims to catch. I can name the failure. I can detect it faithfully. I can't yet prove the detection predicts anything. And that gap, not the build, is the real status of this project.