Jev-Evolve: is your agent deciding, or reading the option order?

September 21, 2026 · View on GitHub

Jev-Evolve: is your agent deciding, or reading the option order?

The same schema, the same model and the same 96 items scored 0.188 or 0.542 depending on what order the options were listed in. Find out what your typed decision is actually deciding on, remove it, and stop believing searches that never cleared their own noise floor.

PyPI Python License Tests

Quickstart · The measurement · Why jev · How it works · API · FAQ · 简体中文


What is Jev-Evolve?

An agent framework where every branch is a typed question: pick one of these options, yes or no, score this. Not generated text that something downstream parses.

It exists because of what fell out of building it. We took an eight-option support-intent schema with careful hand-written rules and evolved its text for 14 generations and 43 candidates. It improved nothing, twice. The reason was not the search:

  answers that changed across 8 option orderings   96/96  (100%)
    of which noise alone                            0/96  (0%)
  accuracy by ordering            0.188 to 0.542   spread 0.354

The schema was not measuring its criteria. It was measuring where each option happened to sit in the list. Every text mutation had been fighting that.

So the library does three things, in this order:

1. Measurepermutation_sensitivity runs k orderings against k fixed-order controls, so option order is not blamed for backend noise
2. FixMarginalized averages the ordering away. +15.6 points held-out, against +0.0 from every text operator on the same schema
3. Verifyevolve reports what a search of the same size scores when nothing improved, and decides on held-out data

Quickstart

pip install jev-evolve
python examples/order_check.py     # 20 seconds, no API key, no downloads

Check before you tune. It is the cheapest thing in the library and it decides whether anything else you do can mean something. The example runs it on two schemas, one that fails and one that passes, because a check that always fires is not a check.

from jev_evolve import permutation_sensitivity, Marginalized, run_policy

print(permutation_sensitivity(policy, backend, tasks, score, k=8).report())
  option orderings tried   8   (plus 8 controls with the order held fixed)
  answers that changed     96/96   (100.0%)
    of which noise alone   0/96   (0.0%)
    attributable to order  100.0%
  accuracy by ordering     0.188 to 0.542   spread 0.354
    control spread         0.000

  ORDER-DEPENDENT: this schema is partly measuring option
  position, so tuning its text will mostly fit noise.
  Wrap the backend in Marginalized(backend, k) and re-check.

Then fix it, which is one wrapper:

run_policy(policy, Marginalized(backend, k=8), tasks, score)

The measurement

Qwen2.5-0.5B, 96 banking77 items over 8 confusable card intents, one written rule per option. python experiments/order_dependence.py --k 8.

Eight orderings of the same eight options:

orderingaccuracy
0 (as written)0.458
10.302
20.438
30.542
40.188
50.417
60.385
70.438

Which number your schema ships with depends on the order you happened to type the options in.

The control that the claim depends on

Averaging k calls could help simply because it is an ensemble, which would have nothing to do with option order, with typed decisions, or with this library. So the same k calls are also run with the ordering left alone:

splitas writtenk calls, order fixedk calls, order permuted
train0.4580.4580.635 (+0.177)
held-out0.4480.4480.604 (+0.156)

The fixed-order column equals the as-written column to three decimals. The backend is deterministic, so ensembling contributes exactly nothing, and the entire gain is the permuting.

Searching for a good order is not the same as removing it

Both halves of the library meet on this table. Picking the best of the eight orderings on the training split is a search, so the selection floor applies to it. Marginalizing selects nothing, so it does not.

train gainfloorheld-out gain
pick the best of 8 orderings+0.083+0.073+0.115
marginalize the ordering awayn/anone applies+0.156

The search's apparent train gain of +0.083 sits +0.010 above the +0.073 that eight candidates on 96 items score with no real differences at all. Almost all of it was the selection. It still transfers, to +0.115 on held-out, and it is still worse than removing the dependence, which reaches +0.156 without selecting anything and beats the luckiest ordering in the set.

That is the argument for fixing a bias rather than tuning around it, on one schema, with numbers.

Order dependence grows with the option set

Practitioners report that a typed decision model's label boundaries get unstable as the candidate set grows while small candidate sets stay reliable. Much of that instability is the ordering, and it is fixable. python experiments/option_count.py:

optionsndecided by orderas writtenmarginalized k=8
22441.7%0.6250.667 (+0.042)
33647.2%0.7220.806 (+0.083)
44862.5%0.6460.833 (+0.188)
67295.8%0.5140.792 (+0.278)
896100.0%0.4580.635 (+0.177)

order dependence against the number of options

Read the table down the "decided by order" column and across each row. Do not read the accuracy columns down: every row is a different task over a different label subset, with a different chance level and a different n, so accuracies are not comparable between rows. The 2-option row rests on 24 items and its flip rate is roughly estimated.

What the numbers support is narrow and useful: the share of answers decided by option order rises monotonically with the option set, and the value of marginalizing rises with it.

Irrelevant state moves the answer too

Option order is one invariance. Here is a second, and the same control applies. state_sensitivity adds fields that cannot bear on the decision, drawn from things that read like real business metadata (session_id, ab_bucket, queue_depth, agent_shift) rather than obvious filler, because a model that shrugs off obvious filler can still be moved by something that looks like it belongs.

Three such fields, on the same 8-option schema:

  answers that changed     30/96   (31.2%)
    of which noise alone   0/96   (0.0%)
    attributable to padding 31.2%
  accuracy, clean          0.458
  accuracy, padded         0.391   (-0.068)

  STATE-SENSITIVE

Three irrelevant fields moved 31% of the answers and cost 6.8 accuracy points, with the control at zero. That is the practitioner complaint about long contexts, reproduced at three extra fields rather than a long prompt, and it is why mutate_state_fields searches over what each decision point can see instead of leaving it to a design review.

A third invariance, this one about content

The two checks above are about formatting. A practitioner report pointed at something else: these models get pulled toward the most vivid event in the input and miss the responsible party, the root cause or the precondition. That is a claim about content, so it needs a different probe, and it needs a control that separates capture from ordinary degradation.

salience_sensitivity builds a contrast set. Every item is a real banking77 utterance of intent A. Its twin prepends a real utterance of a different intent B, quoted and marked as already resolved, with the gold label kept at A: what happened last month and was settled does not change where today's request is routed. Then it asks one question. Of the answers the injection broke, how many landed on B?

Extra text can simply degrade a decision, and then the new errors spread across the wrong labels. Capture concentrates them on B. The baseline is not the theoretical 1/(m-1) but the model's own rate for the B label among its unperturbed errors, because a model with a favourite label beats the theoretical level on every label and would look captured.

Same 8-option schema, ordering marginalized first so the effect is not read off order noise, 192 pairs (python experiments/salience_probe.py):

  accuracy, clean          0.620
  accuracy, distractor in  0.495   (-0.125)
  answers it broke         29   (and 5 it happened to fix)
    landed on the distractor  19   (65.5%)
    this model's own rate     15.1%   (73 clean errors)
    -> 4.3x   p=1.04e-09

  CAPTURED

The matched control sits at 15.1%, next to the theoretical 14.3%, so the model has no special appetite for the labels that were drawn as distractors. The concentration is the injection.

And the two obvious fixes both make it worse

A finding like that invites two responses, and both were tested, cheap one first (python experiments/decomposition.py):

armaccuracycapturedvs baselinedecisions/item
A baseline, as written0.49556.7%1
B one instruction: ignore resolved matters0.43848.1%5 fixed / 16 broken, p=0.027 worse1
C a separate typed gate, then a routed intent question0.42244.1%7 fixed / 21 broken, p=0.013 worse2

The gate in arm C was right on 160 of 192 items, so this is not a broken gate. Capture fell monotonically across the arms, so both interventions hit what they aimed at. They cost more than they saved, and the state check above says why: on this model, adding text to the context is itself expensive. The fix for a content bias is more instruction, and more instruction is the thing this model handles badly. That tension is real, it is measured, and this library does not paper over it by shipping arm C as a feature.

from jev_evolve import salience_sensitivity, quote_prior

s = salience_sensitivity(policy, backend, tasks,
                         quote_prior("text", examples_by_label), score)
print(s.report())      # .share .matched_control .ratio .p_value .captured

inject(state, label, rng) -> (new_state, distractor_label) is yours and must preserve the gold label; quote_prior is the ready-made one for text classification.

Why typed decisions, and why a fast one

The search space exists because the decisions are typed

An agent that decides by generating text gives a tuning loop one prompt and one scalar per episode. An agent whose branches are typed questions gives it a structure, and the structure is most of what there is to work with.

what a loop can see or changegenerating agenttyped-decision agent
instruction wordingyesyes
the text describing each optionno, options are not a declared setyes
the order the options are presented innot separable from the promptyes
which decision runs firstno, it is one blobyes
what each decision is allowed to seeno, it is one contextyes
when to abstain instead of answeringno calibrated probabilityyes
which option to sharpen, against which rivalno probability vectoryes
which decision point lost the episodeone scalar outcomeyes

The third row is the one this project was built on the wrong side of. Permuting the options of a declared option set is a well-defined operation. Permuting "the options" inside a free-text prompt is not.

And the fix costs k decisions per answer

Marginalizing over orderings is not a new idea. What is new is being able to afford it. Measured on the same connection, same items, same option set, same criteria text, 160 items over 8 confusable intents:

modelaccuracyp50p90
typesafe/jev-1.130.856450 ms536 ms
mistralai/mistral-nemo0.900656 ms1206 ms
qwen/qwen3.7-flash0.881764 ms917 ms
openai/gpt-5-nano0.881703 ms907 ms
ibm-granite/granite-4.0-h-micro0.786568 ms1064 ms

Read it honestly: out of the box the decision model was the fastest with by far the tightest tail, and 4.4 points less accurate than the best cheap generative model.

That is the whole argument, and it is not about speed for its own sake. At 450 ms with a p90 of 536, Marginalized(backend, k=8) is a decision that takes a few seconds. At 3 seconds a call with a p90 over a second wider, the same fix is half a minute per decision and nobody ships it. A known, correct, expensive correction becomes practical, and on the local model it was worth +15.6 points on held-out data, which is more than three times the gap the decision model started with.

How it works

Five pieces, each usable on its own.

Policy. The decision points, as data: instruction text, per-option criteria, the order the options are listed in, which state fields each point can see, and the confidence below which it abstains. All searchable.

from jev_evolve import Policy, choice, noul

policy = Policy({
    "in_scope": noul("Is this about a bank account?", threshold=0.6),
    "intent":   choice("Which intent?", {"lost_card": "...", "top_up": "..."},
                       threshold=0.3, state_fields=["text", "channel"]),
}, order=["in_scope", "intent"])

Invariance. The check and the fix. Run it first.

from jev_evolve import permutation_sensitivity, Marginalized

s = permutation_sensitivity(policy, backend, tasks, score, k=8)
if not s.sound:
    backend = Marginalized(backend, k=8)

Sensitivity carries .flip_rate, .noise_rate from the control, and .excess_flip_rate, which is the part attributable to order. The verdict rests on the excess, and on the flip rate rather than the spread, because max-minus-min over k runs grows with k on its own and is badly resolved at any k worth paying for.

Agent. A loop over the points. act(state, answer) applies each answer and is where your tools live. Return {jev_evolve.STOP: True} to finish.

def act(state, answer):
    if answer.name == "in_scope" and not answer:
        return {jev_evolve.STOP: True, "outcome": "handoff"}
    return {answer.name: answer.choice}

episode = Agent(policy, backend, act=act).run("t1", {"text": "lost my card"})

answer is falsy when the point abstained or answered no. answer.margin is the gap to the runner-up.

Trace. Every decision, its probabilities and its latency, as JSONL.

jev_evolve.confusions(trace)       # (point, picked, should have been) -> count
jev_evolve.point_accuracy(trace)   # which point owns the loss
jev_evolve.overconfident(trace)    # wrong and sure: thresholds cannot catch these
jev_evolve.cost(trace)             # decisions and seconds per episode

Evolve. Generations of mutants, scored, best kept, and an honest report.

from jev_evolve.mutate import default_operators

ops = default_operators(available_fields=["text", "channel"],
                        examples_by_label=INTENTS, trace=trace)
res = evolve(policy, backend, train, score, ops, heldout=held)
print(res.report())
res.best.save("policy.json")

A loop that keeps the best of many candidates reports a gain even when nothing improved. python examples/null_loop.py runs this loop against a backend that answers at random and ignores the policy, where the true gain is exactly zero:

   5 generations  apparent gain +0.017   floor +0.064   NOT CONFIRMED
  20 generations  apparent gain +0.050   floor +0.086   NOT CONFIRMED
  60 generations  apparent gain +0.058   floor +0.101   NOT CONFIRMED

what a loop reports when nothing improves

The reported gain grows with the number of generations on an agent that is not improving. Every self-improving loop that selects on an eval score has this; most do not report it. The arithmetic is evalfloor, a dependency rather than a copy.

Mutations

Six edit the policy blind. Two read the agent's own trace.

operatorwhat it changesneeds
mutate_option_orderhow the options are orderednothing
mutate_threshold_from_traceabstention, on a grid read off the backend's own confidencesa trace
mutate_orderwhich point runs first, and so what later points seenothing
mutate_state_fieldswhat one point is allowed to seefield names
mutate_criteria_from_examplesdescribes an option by real inputslabelled data
mutate_criteria_from_errorsdescribes an option by inputs it misseda trace
mutate_from_confusionsrewrites the option most often picked by mistakea trace, a rewriter
mutate_instructionsrewrites a point's instruction texta rewriter

Use mutate_threshold_from_trace rather than the fixed-grid mutate_threshold. On the 8-option task above, the fixed grid's values at or below 0.2 were all the same no-op, and those at or above 0.5 abstained on four decisions in five: two of eleven grid points did anything at all, and a third of the candidate slots in a 15-generation run went to values that could not have helped.

A rewriter is any (text, slot, rng) -> text. The rng is part of the contract: a rewriter reaching for the global random makes a run unreproducible, and a tool whose job is telling you how much of a gain is real cannot give a different answer each time you ask.

Backends

backendwhat it iscost
RuleBackenda Python function. Tests, baselines, hybrid policiesfree
OverlapBackendjev_evolve.demo, scores by word overlap. Examples and CIfree
LocalBackendoption logits from any causal LM, one prefill, no generationlocal GPU
JevBackenda hosted typed-decision endpointper call
Marginalizedwraps any of the above, k orderings averagedk times the inner

LocalBackend needs pip install "jev-evolve[local]". Everything else works with no extra dependency, and import jev_evolve pulls in no model library.

JevBackend defaults to the OpenRouter decisions API and takes endpoint= for anything with the same {model, state, questions} shape. Nothing else in the package knows which vendor is behind it.

Hosted providers generally forbid using output to train or build a competing model, and a generational loop is thousands of decisions. Evolve against something you own, then validate the winner:

from jev_evolve import JevBackend, validate_transfer

t = validate_transfer(policy, res.best, JevBackend(), heldout, score)
print(t.report())    # costs exactly 2 * len(heldout) calls

validate_transfer reports no floor and none applies: two fixed policies scored on the same items select nothing. Running it over several candidates and keeping the best would reintroduce selection, and the floor with it.

API

from jev_evolve import Policy, Point, choice, noul       # the policy
from jev_evolve import permutation_sensitivity, state_sensitivity, salience_sensitivity, quote_prior
from jev_evolve import Marginalized, Sensitivity, StateSensitivity
from jev_evolve import Agent, Answer, STOP               # the loop
from jev_evolve import Trace, Episode, Decision          # the record
from jev_evolve import evolve, run_policy, Result        # the search
from jev_evolve import validate_transfer, Transfer       # local -> hosted
from jev_evolve import confusions, point_accuracy, overconfident, cost

permutation_sensitivity(policy, backend, tasks, score, k=4)
# -> Sensitivity: .flip_rate .noise_rate .excess_flip_rate .sound .report()

state_sensitivity(policy, backend, tasks, score, k=4, fields=3)
# -> StateSensitivity: .excess_flip_rate .cost .sound .report()

salience_sensitivity(policy, backend, tasks, inject, score)
# -> SalienceSensitivity: .share .matched_control .ratio .p_value .captured

evolve(policy, backend, tasks, score, operators,
       generations=20, candidates=4, heldout=None, act=None, seed=0)
# -> Result: .best .gain .floor .heldout .credible .verdict .report()

validate_transfer(base, evolved, backend, tasks, score)
# -> Transfer: .gain .paired .transferred .calls .p50_s .p90_s .report()

A task is (id, state, label) or {"id":..., "state":..., "label":...}. score(episode) -> float is yours; 0/1 keeps the floor arithmetic exact.

Limits

permutation_sensitivity covers option order, state_sensitivity covers irrelevant fields and salience_sensitivity covers capture by a vivid distractor. State field order and option naming are still untested, and a schema that passes all three may still be measuring one of those. The salience probe's gold labels are preserved by construction, not by human annotation, and its demo-backend result is mechanically trivial (a bag-of-words scorer fed a whole sentence of label B); the evidence is the Qwen2.5-0.5B run, which is not a bag of words.

The reported floor assumes candidates are evaluated independently on a 0/1 metric. Candidates in an evolutionary loop are correlated by descent, which makes the true floor higher, so the printed number is a lower bound.

Attribution needs to know which decision point should have answered what. With one choice point, default_gold handles it. With more, pass your own gold(episode) -> {point: answer}. It refuses to guess, because a guessed attribution aims every later mutation at the wrong decision.

The measurements above are one model on one 8-option task. The mechanism is general and well documented elsewhere; the magnitude is not a constant, which is why the check ships as a function you run on your own schema instead of a number you take from this README.

FAQ

My agent already works. What do I get? Run permutation_sensitivity on one recorded set. It costs 2 * k * n decisions and tells you whether your schema is measuring its criteria or its formatting. That is worth the afternoon before anything else here.

Isn't option-order bias a known problem? Yes, and that is the point. It is known, the fix is known, and it is skipped because it costs k times as much. What this library adds is measuring it against a control, fixing it in one wrapper, and making the cost affordable by putting the decision on a model built for decisions.

Why not just search for the best ordering? Measured above: picking the best of eight gave +0.115 on held-out, while almost all of its training gain was selection noise. Marginalizing gave +0.156 and selected nothing.

Do I need a Jev or System One model? No, and the name is still right. What the library needs is that decisions be typed, which is what the search space is made of. A jev-class model is the cheapest, lowest-latency way to serve typed decisions, which is what makes Marginalized and a thousand-decision loop affordable. LocalBackend and RuleBackend serve typed decisions too.

How is this different from DSPy or a prompt optimiser? Those search over prompt text and few-shot examples, which is one row of the table above. The rest exist only because a decision is a declared object with named options and calibrated probabilities. And a run here ends with a verdict rather than a best score.

Why is my run UNDERPOWERED? An exact sign test over d disagreements cannot return a p-value below 2^(1-d), so five or fewer never reach 0.05. Your held-out split is too small to settle it, which is not the same as a negative result.

MIT.