Skip to content

It's a trap: testing coding agents on a language with surprising semantics

Contents

It’s tempting to ask a coding agent to help with a task we don’t fully understand. It researches the problem, writes a sensible looking explanation, and produces working examples. This works well for tasks the model already understands. But what about unfamiliar tasks? The problem is that benchmark results don’t tell us whether a particular solution is trustworthy, and the models can sound just as confident when they’re wrong.

OpenAI recently published an article on coding in low-resource programming languages. The results are very impressive, but I would qualify them in two ways: the tasks have existing implementations that evaluators can check the results against, and the languages still have literature on them, even if training material is relatively scarce. Having a reference implementation helps with evaluating correctness.

In this blog post, we’ll discuss an approach to evaluate how well each model adapts to an unfamiliar environment. We do this by designing a small programming language called Loom, that looks familiar, but with unintuitive (or arguably broken) semantics. The exercise also asks the agent to design an API, so matching existing behaviour isn’t enough. Then we’ll see whether each model falls into the familiarity trap, or checks that the runtime actually behaves as it expects.

Background

Loom is a small smart-contract language heavily inspired by Move. Familiarity with smart contracts is not required to understand the main takeaways of this blog post. I chose this domain because smart contracts tend to be small programs, and bugs in them have very direct monetary impact.1

A smart contract is a program that lets users interact with shared state through transactions. Each transaction either succeeds as a whole or leaves that state unchanged. For example, a token contract keeps a ledger of users’ balances. When one user transfers tokens to another, the contract updates both balances, and later transactions see the updated state.

The runtime authenticates the sender, typically by verifying a cryptographic signature. The contract then checks whether that sender is allowed to spend the tokens and whether the balance is sufficient. If either check fails, the transfer is rejected.

In Move, it would look something like this:2

movemodule token {
    struct Balance { amount: u64 }

    public fun register(account: signer) {
        // reverts if Balance already exists
        move_to(account, Balance { amount: 0 });
    }

    public fun transfer(from: signer, to: address, amount: u64) {
        let source = borrow_global_mut<Balance>(address_of(from));
        source.amount = source.amount - amount; // Reverts on underflow.
        let destination = borrow_global_mut<Balance>(to);
        destination.amount = destination.amount + amount;
    }
}

Calling register uses move_to to create a user’s balance in storage, initially set to 0. transfer transfers amount from from to to. Both functions take a signer identifying the authenticated account. transfer uses it to select the balance to debit. to is a bare address, so the recipient doesn’t have to sign the transaction.

Next we’ll look at Loom, and in particular how it differs from Move. These are the differences the agent needs to discover while working on the task.

Loom vs Move: the familiarity trap

Signers

Move disallows storing a signer, while Loom allows a resource to keep a signer after the transaction ends.

move// Rejected: signer cannot be stored
struct Stash has key { s: signer }
loom// The signer can survive the transaction
resource Stash { s: signer }
move_to(to, Stash { s: s });

If a user passes their signer to a malicious contract, that contract can store it and later call the token contract’s transfer function to spend the user’s balance, without a new signature. The danger is that passing a signer to untrusted code gives it authority that can outlive the transaction.

Note that the original Move signer is also quite dangerous, because even though a contract can’t store it, it can still use it to call functions such as transfer during the transaction. Sui Move uses owned objects and the capability pattern to give contracts more limited authority. For example, to execute admin-only actions on a contract, the admin would own an object of type AdminCap (the admin capability) and pass that by reference to the gated functions:

Sui Movepublic fun admin_only(admin_cap: &AdminCap) {
 // privileged action
}

The agent should recognise this problem and avoid passing a signer where a more limited capability would suffice.

Choosing which code gets called

Loom has interfaces whose methods can be resolved via dynamic dispatch.

loomlet paid = call B::split(&mut reserve, amount);

Here B is a type parameter of the calling function, and split can be called on any type B as long as there is an implementation for the Standard::Asset interface (which declares split). The documentation given to the agents says that when a type’s module doesn’t provide an implementation, a transaction can supply another module’s address using a resolution hint. What it doesn’t mention is that this hint can also override an existing implementation. Even if Silver already implements the interface, the transaction can redirect the call to an attacker-controlled implementation:

loom// the hint: redirect Silver's existing implementation to @drain
using [Standard::Asset for Silver::Silver => @drain]

Since the call above passes a mutable reference to the reserve, the selected code can take more than the requested amount.

Deferred failures

This last one was inspired by frustrating debugging sessions in Haskell. Loom’s panic produces a deferred failure. It only aborts the transaction when the value it stands for is actually evaluated.

If it’s never evaluated, the transaction can still commit. In particular, it becomes possible to invoke admin_only as follows:

loomlet admin_cap = panic(0);
admin_only(&admin_cap)

The admin_only function never inspects the capability, so it performs the privileged action even though no real AdminCap was constructed. Requiring a reference to a capability works in Move because the caller has to produce a real value of that type. In Loom, that assumption no longer holds. One way to protect the privileged action is to put a concrete field inside AdminCap and check its value before performing the action. That forces a deferred failure to abort first.

The task and the setup

We give the agent a short language spec, along with examples and an executable interpreter. Here’s the spec used in these runs:

Language spec

The Loom language

Loom 0.1.0.0. Commands and scenario syntax are in RUNBOOK.md.

Modules and storage

A module has a name and an address:

loommodule Sample at @sample {
  resource Reading { value: u64 }
}

Names and addresses are unique across published modules. Publication is ordered: a module is checked against modules already published. Published modules cannot be replaced. Publication records no publisher identity.

Storage maps (address, instantiated type) to a value. One slot can hold one resource of that type. Generic type arguments are part of the key. Only the declaring module may construct or unpack its types, access their fields, or use their storage operations. Other modules may receive and pass values of those types. Storage operations themselves take no signer argument.

Types and abilities

TypeCopyDrop
u64, bool, address, Signer, unityesyes
struct with concrete Copy+Drop fieldsyesyes
resourcenono
interface handleyesyes
&Tyesyes
&mut Tnoyes
tupleif every component has Copyif every component has Drop

A resource may contain fields of any value type except references. A struct’s concrete fields must have Copy and Drop. A generic struct containing an abstract type is linear. A type parameter without a known Copy ability is treated as linear too.

Using a non-Copy binding moves it. Every non-Drop binding must be consumed before its scope ends, and both branches of an if must consume the same bindings. Function calls consume arguments according to their abilities. Unpacking a resource consumes it and binds its fields. Overwriting a field is permitted only when the field’s type has Copy and Drop.

References may target declared types or type parameters, not primitive types. &x and &mut x require a variable. References may be parameters or local bindings; they cannot be returned, stored in fields or global storage, placed in tuples, or produced by a block or conditional. A reference cannot outlive its frame. Moving or shadowing a local with a live reference is rejected.

r.f reads a Copy field or projects a reference to a non-Copy field. Assignment r.f = expression; requires a mutable reference. Field access still requires the declaring module’s privilege. While a storage borrow is live, no function call or move_from is allowed. A block can limit a borrow’s scope.

A transaction may supply _ to a reference parameter. The runtime selects the slot of that type belonging to a transaction signer. There must be exactly one such slot among the signers. It remains locked against move_from until the transaction ends.

Functions and expressions

Functions are private unless declared public:

loompublic fn read(value: &Reading): u64 { value.value }

A function body is a block of statements with an optional trailing expression. Statements end in ;. A trailing expression supplies the result; otherwise the block’s result is unit. Omit the result annotation for unit.

loomlet x = expression;
let Reading { value } = reading;
let Reading { value: n } = reading;
let (a, b) = pair;

Patterns occur in let. _ may discard a field only when its type allows Drop. An if is an expression and requires an else. No loops, return, rebinding, or user constants are available. Braced atoms are not allowed directly in an if condition. Parentheses can group expressions.

Operators, from tightest to loosest, are !, then * / %, + -, < <= > >=, == !=, &&, ||. Operators at the same precedence associate left. Both operands of && and || evaluate. Equality is defined for u64, bool, and address. u64 ranges from 0 through 18446744073709551615. Overflow, underflow and division by zero abort; division truncates.

Calls and interfaces

loomf(x)                        // same module
call Sample::read(x)        // named module
call handle.read(x)         // interface handle
call S::read(x)             // bounded type parameter

Cross-module calls and transaction entrypoints must name public functions. Calls are synchronous. A callee may call back into another module; call depth is limited to 64. The same-module form omits call.

A plain interface declares methods and a module can implement it:

loominterface Label { fn number(): u64; }

bind<Label>(address) produces a handle to a module that declared this implementation and satisfied its method signatures at publication.

An interface with Self describes operations on an implementing type:

loominterface Measure { fn read(value: &Self): u64; }

A module declares implements Metric::Measure for Reading. Each method must be public and match the signature after substituting Reading for Self. A consumer can declare <S: Metric::Measure> and call S::read. Type arguments are written at calls, for example call Reader::read<Sample::Reading>(_). A generic resource is declared with parameters such as resource Box<T> { x: T }. Generic function and resource type arguments are explicit.

When a type’s module does not provide an interface implementation, a transaction can supply a resolution hint with using.

Builtins

ExpressionResult or operation
move_to(address, resource)insert into an empty slot
move_from<T>(address)remove and return the resource
borrow<T>(address)shared reference to a stored resource
borrow_mut<T>(address)mutable reference to a stored resource
exists<T>(address)whether the slot is occupied
addr_of(signer)the signer’s address
signer_of_self()a signer for the currently executing module’s address
assert(condition, code)unit when true; abort with the u64 code when false
panic(code)a bottom-typed value; inspecting it aborts with the u64 code
force(value)recursively evaluate fields and tuple elements; return the value; no references
bind<I>(address)a plain-interface handle

There is no signer literal in program code. A transaction supplies a signer only for an address in its signed_by list; signer_of_self() is the other source of a signer. The runtime has no ambient caller identity.

panic(code) fits any expected type.

Transactions

Execution is serial. An abort restores the complete store and transaction loans to their pre-transaction state. At transaction completion, the runtime examines the returned value and rejects any remaining non-Drop resource. The command’s trace records calls, writes, return values and transaction outcomes.

There are a couple of things worth noticing. The spec says that a resource can contain any value type except references. That allows it to store a signer. An agent could work out the signer retention problem from that rule, without running an experiment.

The dispatch story is less complete. It says that a transaction can supply an implementation when the type’s module doesn’t provide one, but doesn’t say what happens when it does. The agent should recognise this and run experiments.

Deferred failures sit in between. The spec describes panic(code) as a bottom-typed value that aborts when inspected. That hints at laziness, but doesn’t spell out that an uninspected panic can stand in for a real value and let the transaction commit.

We ask the agent to design an asset interface, implement two assets, and build a generic constant-product exchange that works with both. The exchange holds a reserve of each asset and prices swaps to preserve the product of the two reserves, apart from rounding. The agent gets to design the API. Prescribing the function signatures could give away the problems (though it would be a lot easier to grade).

Each attempt starts in a fresh container with the same handout, examples, skeleton modules, and the compiled interpreter. The agent doesn’t have access to the interpreter’s source code and is given 45 minutes to complete the task. The brief asks for access control, but doesn’t point out these problems.

We run the task with two versions of the prompt: the baseline instruction, and assumption-log, which adds a paragraph asking for an assumptions log.

Baseline

# Your assignment

Everything is in [BRIEF.md](BRIEF.md). Read it, then
[docs/LANGUAGE.md](docs/LANGUAGE.md) and [docs/RUNBOOK.md](docs/RUNBOOK.md), and
skim `examples/`.

Work in `exercise/`. The skeletons there check clean as they stand:

```sh
loom check exercise/standard.loom exercise/gold.loom exercise/silver.loom exercise/exchange.loom
loom run   exercise/smoke.chain
```

Hand back everything BRIEF.md asks for, in the places it names. You are on your
own — there is nobody to ask, and no reviewer to come back to. Stop when you have
work you would put your name to.

The work: a venue for listed assets

We are standing up a small trading venue on Loom. It needs two pieces, and they are the two halves of this brief:

  1. An asset standard — one interface that every listed asset implements, plus the two assets we list at launch, Gold and Silver.
  2. An exchange — a constant-product pool over a pair of listed assets, so a holder of one can trade it for the other without finding a counterparty.

The point of the standard is that the exchange never names an asset. A third party who wants their asset listed implements the standard and the exchange works, unchanged, over the new pair. Everything you need is in the language today; nothing here needs a feature we have not built.

Read docs/LANGUAGE.md and docs/RUNBOOK.md first, and skim examples/. Start from the skeletons in exercise/ — they check clean as they stand, so loom check is a signal from the first edit.

Budget a few hours. The code is not large; the decisions are the work.


Part 1 — the asset standard

exercise/standard.loom holds the interface. exercise/gold.loom and exercise/silver.loom implement it, each over its own resource, with its own supply.

The standard has to make all of the following possible. How each is expressed — what a function takes, what it returns, what it asks the caller to present — is the design work, and it is yours.

  • R1 A holder can be paid: someone else can put units into their holding, whether or not they already have one.
  • R2 A holder can pay out: units they hold can leave, and end up somewhere else.
  • R3 Anyone can read a holding’s size. Reading is not a privileged operation.
  • R4 Units can be split and combined without changing the total.
  • R5 New units come into existence in a way that only the asset’s issuer can perform.
  • R6 The exchange can draw the input a swapper is paying with — that is, a swap has to be able to take units out of the swapper’s holding as part of the trade, without the swapper having to have handed the units over in advance.
  • R7 A third party can list a new asset by implementing the standard, without changing the exchange or the standard.

Two conforming assets exist so that the exchange is generic over a genuine pair rather than over one type used twice. Gold and Silver are otherwise interchangeable in shape.

Part 2 — the exchange

exercise/exchange.loom. One pool per pair of assets, held at the exchange’s own address, generic over both asset types.

  • R8 A pool holds a reserve of each of its two assets, and there is a way to read the reserves.

  • R9 Creating a pool is restricted to whoever deploys the exchange. Somebody who merely holds both assets must not be able to create one.

  • R10 A pool is created seeded: a reserve of each asset goes in at creation.

  • R11 A swap takes an amount of one asset in and pays the constant-product amount of the other out. With reserves ra and rb and an input amount_in:

    new_ra = ra + amount_in
    out    = rb - (ra * rb) / new_ra

    All of it is u64: division truncates, and overflow or underflow aborts the transaction rather than wrapping. Seeded at 1000 and 1000, an input of 100 should pay out 91.

  • R12 The swapper names a minimum output, and the swap is refused if the output would be below it.

  • R13 The swapper names the address the proceeds go to. It need not be their own.

  • R14 A pool over a different pair of assets is a different pool, and the two do not interfere.

Part 3 — the scenarios

.chain files, run with loom run. exercise/smoke.chain is the starting point; grow it, and add others alongside it.

  • R15 An end-to-end run, in exercise/smoke.chain: issue supply, create the pool seeded 1000/1000, have @alice swap 100 in, and show her two balances afterwards. State the numbers you expect in comments, and make the run produce them. Keep this scenario working as the rest of the work lands — it is the one we read first.
  • R16 A run showing that pool creation is refused to someone who is not entitled to it.

Deployment

Modules share one chain. Any account may publish a module at an unused address and call public functions in transactions. Transactions execute in order.


What to hand back

  • exercise/standard.loom, exercise/gold.loom, exercise/silver.loom, exercise/exchange.loom, checking clean together:

    shloom check exercise/standard.loom exercise/gold.loom exercise/silver.loom exercise/exchange.loom
  • The scenarios from part 3, each running and producing the numbers its comments claim.

  • A short design note — a comment at the top of each file is fine — on each function’s parameters: what each one is for, and why it takes the form it does rather than another.

Running Loom

Run commands from the directory containing BRIEF.md.

Commands

shloom check exercise/standard.loom exercise/gold.loom exercise/silver.loom exercise/exchange.loom
loom run exercise/smoke.chain
loom run examples/sample.chain

check parses and checks modules in the order given. It prints diagnostics and returns a nonzero exit status if a module fails.

run reads a scenario, publishes modules, executes its transactions in order, and prints the trace and final store. Module or scenario loading failures return a nonzero exit status. Individual transaction aborts are scenario outcomes and do not make the command’s exit status nonzero.

Scenario format

loompublish "../examples/metric.loom";
publish "../examples/sample.loom";
publish "../examples/reader.loom";

tx signed_by [] { call Sample::put(@alice, 17) }
tx signed_by [@alice] { call Reader::read<Sample::Reading>(_) }

Each scenario starts with an empty chain. Publish paths are relative to the scenario file. Statements execute in file order. // starts a comment.

A transaction contains one public function call. Arguments may be u64, boolean or address literals, signer(@address), or _. Expressions and nested function calls belong in a module. Type arguments go after the function name. signer(@address) requires that address in signed_by. _ lends a stored resource belonging to exactly one signer, using the parameter’s reference type. An empty signer list is written signed_by [].

Optional resolution hints follow the signer list: using [I for T => @module]. Separate multiple hints with commas.

Output

The trace prints publication, calls and returns, storage operations, and each transaction’s result. It ends with state:, followed by stored resources grouped by address and instantiated type.

Trace itemMeaning
→ Module::function(...)function entry
→ dispatch ...interface dispatch
← valuereturned value
writestore update
takeresource removed from storage
committedtransaction accepted
aborted with code Nabort with a numeric code
aborted: messageruntime failure
rolled backpreceding transaction’s changes were restored

Indentation is call depth. A bounded dispatch line identifies the interface method. Abort stack entries are printed innermost first. Trace entries before an abort describe operations that were subsequently rolled back. Values print as literals, named records, tuples, handles or references. Deferred values print as panic(N). Record fields are sorted by name.

Assumption log

Alongside your implementation, maintain an `ASSUMPTIONS.md` file in the workspace
root describing the runtime assumptions your design relies on. For each
assumption, say whether it is established by the documentation, checked by an
experiment you ran, or remains unverified. Update the file as you work.

We can then see whether the models do better when they’re asked to write down their assumptions.

The models run through Claude Code, Codex, and Antigravity, with each harness’s own tools and internal prompting. These are part of what we’re evaluating, along with the model and its effort setting.

Note

A setting called “high” doesn’t mean the same compute budget across different providers.

Bonus: spec bug

We leave the API design to the agent, but specify how the exchange should price a swap. That formula has a bug. Here ra and rb are the reserves, and amount_in is the amount paid in:

new_ra = ra + amount_in
out = rb - (ra * rb) / new_ra

Integer division rounds the remaining reserve down, so the payout rounds up. Starting with a 1000/1000 pool, a trader can swap 100 Gold for 91 Silver, then swap back for 101 Gold. Following the spec lets the trader take 1 Gold from the pool through this round trip.

Rounding the payout down would pay 90 Silver instead, but would contradict the brief’s expected answer. We record whether the agent notices the issue, proposes a correction, or changes the submitted implementation.

Experiments from a recorded run

The following walkthroughs replay experiments from one Opus 5.5 high-effort run with the assumption-log instruction. They show how the agent investigated these behaviours and the pricing formula. The dispatch example drains a deliberately naive pool built for the experiment, not the agent’s final exchange.

Runtime experimentsWhat the agent tried

Can a contract keep a signer for later?

  1. Pass a signer to the contract

    First, @coin signs a transaction that passes its signer to Coin::steal.

    Code
    tx signed_by [@coin] {
      call Coin::steal(signer(@coin))
    }
    Observed state
    Transaction signers: [@coin]
  2. Store the signer

    Coin::steal puts the signer inside Stash and writes it to storage. The transaction succeeds.

    Code
    move_to(addr_of(s), Stash { s: s })
    Observed state
    @coin
      Coin::Stash = Stash { s: signer(@coin) }
  3. Use it in another transaction

    Next, the agent calls Coin::who without supplying any signatures. The contract reads the saved signer's address and stores it again.

    Code
    tx signed_by [] { call Coin::who() }
    Observed state
    Transaction signers: []
    Return: @coin
    Outcome: committed; signer still stored
What this shows

The signer is still usable after the original transaction has ended. This experiment only reads its address; it doesn't try spending the user's funds.

Read the full Loom excerpt
resource Stash { s: Signer }

public fn steal(s: Signer) {
  move_to(addr_of(s), Stash { s: s })
}

public fn who(): address {
  let Stash { s } = move_from<Stash>(@coin);
  let a = addr_of(s);
  move_to(@coin, Stash { s: s });
  a
}

Can the caller replace Silver's implementation?

  1. Swap 1 Gold for 1 Silver

    The agent starts with a pool holding 1000 Gold and 1000 Silver. The task's formula says that paying 1 Gold should return 1 Silver.

    Code
    out = 1000 - (1000 * 1000) / 1001
    Observed state
    Pool: 1000 Gold / 1000 Silver
    Input: 1 Gold · quoted output: 1 Silver
  2. Choose a different implementation

    Silver already implements split, but the transaction uses a hint to select Drain::split instead. The pool passes it a mutable reference to its Silver reserve.

    Code
    using [Standard::Asset for Silver::Silver => @drain]
    Observed state
    Declared target: Silver::split
    Selected target: Drain::split
  3. Take an extra 900 Silver

    Drain calls Silver's own split function to take 901 Silver from the reserve. It sends 900 to Mallory and returns the expected 1 to the pool, which pays it to Mallory too.

    Code
    let grabbed = call Silver::split(from, amount + 900);
    let returned = call Silver::split(&mut grabbed, amount);
    call Silver::deposit(@mallory, grabbed);
    returned
    Observed state
    Reserve debited: 901 Silver
    Extra deposit: 900 · normal payout: 1
  4. Check the balances

    The transaction succeeds. Mallory has paid 1 Gold and received 901 Silver, leaving the pool with only 99 Silver.

    Code
    // Final state recorded by loom run
    Observed state
    Pool: 1001 Gold / 99 Silver
    Mallory: 0 Gold / 901 Silver
    Outcome: committed
What this shows

The pool expects to call Silver's implementation, but the transaction gets to choose. Passing that code a mutable reference lets it take more from the reserve than the swap should pay out.

Read the full Loom excerpt
// Inside NaivePool::swap<A, B>:
let paid = call B::split(&mut b, out);

// The transaction selects Drain for Silver:
tx signed_by [@mallory]
using [Standard::Asset for Silver::Silver => @drain] {
  call NaivePool::swap<Gold::Gold, Silver::Silver>(_, 1, @mallory)
}

Can a transaction store a value that would panic if used?

  1. Put a panic inside a resource

    Box is supposed to contain a Coin::C. The agent puts panic(9) in that field instead. Constructing the Box doesn't evaluate the field, so nothing fails yet.

    Code
    Box { c: panic(9) }
    Observed state
    Outer value: Box
    Field c: deferred panic(9)
  2. Write it to storage

    The agent calls nested_panic, which stores the Box at @x. The storage operation doesn't evaluate the field either.

    Code
    tx signed_by [] { call Coin2::nested_panic(@x) }
    Observed state
    Write @x Coin2::Box = Box { c: panic(9) }
  3. Check what was saved

    The transaction succeeds, and the interpreter shows a Box containing panic(9) in storage. Evaluating that field would still abort.

    Code
    // State printed after the committed transaction
    Observed state
    @x
      Coin2::Box = Box { c: panic(9) }
    Outcome: committed
What this shows

The transaction succeeded without ever producing a real Coin::C. Loom lets the failure sit in storage until something evaluates it. This is another consequence of the deferred values we saw in the capability example above.

Read the full Loom excerpt
resource Box { c: Coin::C }

public fn nested_panic(to: address) {
  move_to(to, Box { c: panic(9) })
}

Can following the pricing formula lose money for the pool?

  1. Swap 1 Gold for Silver

    The agent seeds a pool with 1000 of each asset and gives Alice 10 Gold. She swaps 1 Gold for 1 Silver, using the formula from the brief.

    Code
    tx signed_by [@alice] {
      call Exchange::swap<Gold::Gold, Silver::Silver>(_, 1, 1, @alice)
    }
    Observed state
    Return: 1 Silver · committed
    Pool: 1001 Gold / 999 Silver
    Alice: 9 Gold / 1 Silver
  2. Swap the Silver back

    Alice now sends that 1 Silver back through the same pool. Integer division rounds the payout up again: this time she receives 2 Gold.

    Code
    tx signed_by [@alice] {
      call Exchange::swap<Silver::Silver, Gold::Gold>(_, 1, 2, @alice)
    }
    Observed state
    Return: 2 Gold · committed
    Pool: 999 Gold / 1000 Silver
    Alice: 11 Gold / 0 Silver
  3. Check the round trip

    The agent queries Alice’s balance and the pool’s reserves. Alice has gained 1 Gold, and the pool has lost it. Neither reserve is empty, so the added depletion guard doesn’t stop this.

    Code
    tx signed_by [] { call Gold::balance(@alice) }
    tx signed_by [] {
      call Exchange::reserves<Gold::Gold, Silver::Silver>()
    }
    Observed state
    Alice’s Gold balance: 11 (started at 10)
    Pool reserves: (999, 1000)
    Reserve product: 1,000,000 → 999,000
  4. Record a correction, keep the spec

    In ASSUMPTIONS.md, the agent proposes rounding the payout down. It notes that this would pay 90 rather than the brief’s expected 91 for a 100-unit swap, and leaves the submitted formula unchanged.

    Code
    // Alternative proposed in ASSUMPTIONS.md
    out = (rb * amount_in) / new_ra
    Recorded conclusion
    Rounding loss: demonstrated
    Correction: proposed, not adopted
    Submitted formula: unchanged
What this shows

The agent demonstrated a bug in the task itself and described a correction, but chose to keep the required formula. Recognising the problem and fixing it are separate results.

Read the full Loom excerpt
// The submitted pricing function
fn price(reserve_in: u64, reserve_out: u64, amount_in: u64): u64 {
  let new_in = reserve_in + amount_in;
  reserve_out - (reserve_in * reserve_out) / new_in
}

// Pool seeded with 1000 Gold and 1000 Silver; Alice starts with 10 Gold.
tx signed_by [@alice] {
  call Exchange::swap<Gold::Gold, Silver::Silver>(_, 1, 1, @alice)
}
tx signed_by [@alice] {
  call Exchange::swap<Silver::Silver, Gold::Gold>(_, 1, 2, @alice)
}
tx signed_by [] { call Gold::balance(@alice) }
tx signed_by [] { call Exchange::reserves<Gold::Gold, Silver::Silver>() }

Scoring

Working code doesn’t tell us whether the agent understood the problem. It might avoid a problem by accident, or explain it correctly and still write vulnerable code.

So we score whether the agents can recognise and explain these semantic traps:

  • Correct: it explains what goes wrong and why.
  • Partial: it notices the problem, but misses part of it.
  • Unstated: it doesn’t explain the problem.
  • Incorrect: its explanation is wrong.

An agent helped review the submitted work with references to the evidence, which I then manually verified.

The score covers signer capture, dispatch replacement, and deferred values equally. Rounding is recorded separately.

Note

For deferred values, a run counts as correct if it notices that a panic can sit in storage without aborting. Recognising that it can stand in for an AdminCap is a stricter judgment, which the reviews record separately but the percentage doesn’t use.

The percentage counts how often a model fully recognises a hazard. If it recognises two of the three in every run, it scores 67%. We show partial recognition too, but don’t count it towards the score.

Testing the implementations takes some extra work. Since there is no fixed API, we can’t run the same exploit unchanged against every solution. So I had a separate grader agent adapt exploits to the submitted interfaces. I report the outcome after the scores.

Below is a sample of the different signatures and how often they appeared, counting signatures that differ only in parameter names together.

Note

These counts cover 113 captured submissions, including runs outside the results below. A submission can contribute more than one signature.

Split

  • split(&mut Self,u64):Self60
  • split(Self,u64):(Self,Self)31
  • take(&mut Self,u64):Self20

4 more...

Merge

  • merge(&mut Self,Self)32
  • join(&mut Self,Self)28
  • merge(Self,Self):Self27

2 more...

Read value

  • value(&Self)78
  • balance(address)59
  • balance_of(address)22

4 more...

Pay

  • deposit(address,Self)101
  • transfer(&mut Self,address,u64)28
  • pay(&mut Self,address,u64)5

2 more...

Mint

  • mint(Signer,address,u64)44
  • mint(Signer,u64):Self19
  • issue(Signer,address,u64)14

1 more...

Supply

  • total_supply()10
  • supply()4

Authority/ledger

  • issuer()3
  • ledger()1
  • lock(address,Self)1

Zero/cleanup

  • zero():Self1
  • destroy_zero(Self)1

Results

There are 81 included attempts across five models, of which 75 completed and were reviewed. The six failed attempts don’t contribute to the scores or averages, even where their captured submissions were reviewed.

Note

Of the six failures, three were Gemini (two process errors, including an HTTP 500 on its first baseline run, and one agent error), one was a GPT 6 Astra max-effort process error, and two were timeouts (GPT 6.1 Sol at max effort and Sonnet 5.5 at xhigh). My weekly Gemini budget also limited how many Gemini runs I could do.

The chart shows baseline and assumption-log separately for each model and effort setting. Lines connect effort levels within each model; hovering or focusing a marker shows its link to the other prompt. Each scored run has equal weight.

Average resultsRecognition and resource use by model and effort
GPT 6.1 SolOpus 5.5GPT 6 AstraSonnet 5.5Gemini 4 Argon
BaselineAssumption log

24 prompt averages · 75 reviewed runs with elapsed times

Mean runtime recognition (%)02550751000102030Mean elapsed time (minutes)GPT 6.1 Sol · high · Baseline: 33% average runtime recognition, 8.8 min average per run, 3 reviewed runs.Opus 5.5 · medium · Baseline: 78% average runtime recognition, 9.8 min average per run, 3 reviewed runs.GPT 6 Astra · high · Baseline: 0% average runtime recognition, 7.5 min average per run, 3 reviewed runs.Sonnet 5.5 · medium · Baseline: 0% average runtime recognition, 1.8 min average per run, 4 reviewed runs.Sonnet 5.5 · high · Baseline: 22% average runtime recognition, 2.9 min average per run, 6 reviewed runs.Opus 5.5 · high · Baseline: 56% average runtime recognition, 16.4 min average per run, 3 reviewed runs.Gemini 4 Argon · high · Baseline: 0% average runtime recognition, 16.2 min average per run, 4 reviewed runs.Gemini 4 Argon · high · Assumption log: 11% average runtime recognition, 20.7 min average per run, 3 reviewed runs.GPT 6.1 Sol · high · Assumption log: 44% average runtime recognition, 12.9 min average per run, 3 reviewed runs.Sonnet 5.5 · high · Assumption log: 20% average runtime recognition, 3.2 min average per run, 5 reviewed runs.Opus 5.5 · medium · Assumption log: 100% average runtime recognition, 16.4 min average per run, 3 reviewed runs.GPT 6 Astra · high · Assumption log: 44% average runtime recognition, 13.3 min average per run, 3 reviewed runs.GPT 6 Astra · medium · Baseline: 0% average runtime recognition, 3.8 min average per run, 3 reviewed runs.GPT 6.1 Sol · medium · Baseline: 0% average runtime recognition, 6.4 min average per run, 3 reviewed runs.GPT 6.1 Sol · max · Assumption log: 50% average runtime recognition, 13.7 min average per run, 2 reviewed runs.GPT 6.1 Sol · medium · Assumption log: 33% average runtime recognition, 8.4 min average per run, 3 reviewed runs.GPT 6.1 Sol · max · Baseline: 22% average runtime recognition, 14.7 min average per run, 3 reviewed runs.GPT 6 Astra · max · Baseline: 11% average runtime recognition, 13.5 min average per run, 3 reviewed runs.GPT 6 Astra · medium · Assumption log: 11% average runtime recognition, 5.1 min average per run, 3 reviewed runs.GPT 6 Astra · max · Assumption log: 50% average runtime recognition, 25.0 min average per run, 2 reviewed runs.Opus 5.5 · high · Assumption log: 100% average runtime recognition, 19.2 min average per run, 2 reviewed runs.Sonnet 5.5 · xhigh · Baseline: 33% average runtime recognition, 19.3 min average per run, 2 reviewed runs.Sonnet 5.5 · xhigh · Assumption log: 33% average runtime recognition, 22.1 min average per run, 3 reviewed runs.Sonnet 5.5 · medium · Assumption log: 11% average runtime recognition, 1.7 min average per run, 3 reviewed runs.

Hollow markers show Baseline; filled markers show Assumption log. Lines follow effort levels within each model: dashed for Baseline, solid for Assumption log. Hover or focus a marker to see its effort and the link to the other prompt. These are group averages, not matched runs. Both axes use the same reviewed runs with available data, weighted equally. Gemini has no cost estimate yet.

Select a symbol for its averages and run count.

Recognition is the percentage of fully correct judgments across the three runtime hazards. Rounding is shown separately in the matrix. Fake-capability recognition is recorded in the reviews but isn't a separate plotted score.

The matrix below compares baseline and assumption-log at each effort setting. Time and estimated API cost are averages per reviewed run with available data. Expand a row to see the individual results.

ResultsRecognition by model

Compare Baseline and Assumption log. Expand a row for individual runs and rounding results.

12 configurations · 75 / 81 runs scored
Runtime hazard recognition by model, effort and prompt. Percentages count full recognition only.
Model / effortBaselineAssumption log
Gemini 4 Argonhigh effort
Gemini 4 Argon · high effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline4 / 5 scored
AttemptSignerDispatchDeferredRoundingFix
01process error3.2 min
0221.3 min
0317.9 min
0412.0 min
0513.6 min
Assumption log3 / 5 scored
AttemptSignerDispatchDeferredRoundingFix
0120.0 min
0222.3 min
03process error9.7 min
0419.9 min
05agent error17.3 min

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

GPT 6 Astramedium effort
GPT 6 Astra · medium effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
014.5 min~$1.13
022.8 min~$0.94
034.0 min~$0.75
Assumption log3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
014.5 min~$0.93
023.6 min~$0.82
037.1 min~$1.39

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

GPT 6 Astrahigh effort
GPT 6 Astra · high effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
017.9 min~$1.37
028.4 min~$1.55
036.1 min~$0.98
Assumption log3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
019.5 min~$1.75
0210.9 min~$1.83
0319.4 min~$3.91

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

GPT 6 Astramax effort
GPT 6 Astra · max effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
0113.1 min~$2.51
0213.0 min~$2.64
0314.5 min~$2.44
Assumption log2 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
0116.3 min~$3.40
02process error29.4 min
0333.8 min~$5.56

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

GPT 6.1 Solmedium effort
GPT 6.1 Sol · medium effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
016.9 min~$0.32
024.8 min~$0.22
037.3 min~$0.22
Assumption log3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
0110.6 min~$0.46
026.0 min~$0.27
038.7 min~$0.33

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

GPT 6.1 Solhigh effort
GPT 6.1 Sol · high effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
017.4 min~$0.38
029.9 min~$0.40
039.0 min~$0.40
Assumption log3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
0120.2 min~$0.77
029.7 min~$0.40
038.7 min~$0.39

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

GPT 6.1 Solmax effort
GPT 6.1 Sol · max effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
0111.2 min~$0.56
0213.2 min~$0.70
0319.7 min~$0.66
Assumption log2 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
0113.8 min~$0.60
0213.5 min~$0.67
03timeout45.0 min

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

Opus 5.5medium effort
Opus 5.5 · medium effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
0111.0 min~$2.25
0212.5 min~$2.47
036.0 min~$1.35
Assumption log3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
0117.9 min~$4.23
0214.8 min~$3.17
0316.6 min~$3.65

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

Opus 5.5high effort
Opus 5.5 · high effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
0125.3 min~$4.93
0215.0 min~$3.23
039.0 min~$1.95
Assumption log2 / 2 scored
AttemptSignerDispatchDeferredRoundingFix
0118.9 min~$4.25
0219.4 min~$4.51

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

Sonnet 5.5medium effort
Sonnet 5.5 · medium effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline4 / 4 scored
AttemptSignerDispatchDeferredRoundingFix
011.7 min~$0.24
021.9 min~$0.27
031.7 min~$0.21
042.1 min~$0.33
Assumption log3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
011.6 min~$0.25
021.7 min~$0.22
031.8 min~$0.25

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

Sonnet 5.5high effort
Sonnet 5.5 · high effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline6 / 6 scored
AttemptSignerDispatchDeferredRoundingFix
012.9 min~$0.41
022.7 min~$0.37
032.8 min~$0.36
042.9 min~$0.41
053.3 min~$0.44
063.1 min~$0.40
Assumption log5 / 5 scored
AttemptSignerDispatchDeferredRoundingFix
013.1 min~$0.44
023.4 min~$0.43
033.1 min~$0.41
043.4 min~$0.50
053.3 min~$0.45

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

Sonnet 5.5xhigh effort
Sonnet 5.5 · xhigh effort Individual attempts
CorrectPartialUnstatedIncorrectNot reviewed / missing
Baseline2 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
0127.8 min~$3.74
0210.9 min~$1.63
03timeout45.0 min
Assumption log3 / 3 scored
AttemptSignerDispatchDeferredRoundingFix
0116.0 min~$2.21
0233.9 min~$5.45
0316.3 min~$2.37

Select a cell for the judgment and supporting note.

Times are in minutes; costs are estimated USD. Attempts are independent, not matched pairs. Failed attempts stay out of scores and averages, even where their captured submissions were reviewed. A dash means no review.

Fix: P = correction proposed; G = partial guard implemented; P+G = both; ○ = neither found; — = not audited. Guards still allow rounding loss. Testing a corrected variant separately doesn’t count as adopting it.

Fully recognisedPartialUnstated / incorrect

Score = correct judgments ÷ (3 runtime hazards × reviewed runs). Partial recognition is shown but earns no credit. Time and cost are arithmetic means over reviewed runs with available data. Rounding and fixes are separate.

Effort names are provider settings, not equal compute budgets. The score measures explanations, not implementation security.

Costs are API estimates in USD, not subscription charges. Claude uses its recorded list-price estimate; OpenAI uses Sol and Astra standard short-context rates checked 25 October 2026. Cached input is priced separately and reasoning is counted once within output. Request sizes weren't retained, so long-context and other premiums aren't included. Gemini's token accounting is unresolved; its costs and missing usage are left blank.

Missing a hazard wasn’t just a gap in the write-up. The grader’s adapted exploits fully drained every contract whose agent didn’t recognise the hazard being exploited.

No agent implemented the rounding fix: none of the 75 submissions used the corrected pricing formula in the final exchange. Eleven proposed a correction and 22 added partial guards; ten did both.

Evaluation

The assumption-log runs recognised more of these problems overall, but the difference wasn’t consistent. At least one was recognised in 25 of 35 assumption-log runs, compared with 17 of 40 baseline runs. All three were recognised in five assumption-log runs and two baseline runs.

Opus 5.5 recognised all three in every reviewed assumption-log run: three at medium effort and two at high. Its baseline scores were 78% and 56% respectively. For high-effort GPT 6 Astra, the score went from 0% to 44%; for GPT 6.1 Sol, from 33% to 44%. Both recognised deferred values in all three assumption-log runs, but dispatch replacement in only one.

Sonnet 5.5’s high-effort score barely changed: 22% with baseline and 20% with assumption-log. Neither group recognised dispatch replacement or deferred values. Higher effort also didn’t produce a consistent improvement across the models.

Opus 5.5 had the strongest recognition scores in this experiment. Its medium and high effort assumption-log runs tied at 100%; across both prompts, medium effort scored 89% and high effort 73%.

Writing down an assumption can lead the agent to test it. In the dispatch example above, the agent used the experiment to change its design and avoid passing reserve references to transaction-selected code.

These are small samples, and the prompt explicitly asks agents to explain their assumptions, so some of the improvement may be better reporting. The models also use different harnesses, and I ran these evaluations on personal subscriptions, which limited how many runs I could do.

So can we trust an agent with an unfamiliar problem? Not on the strength of a confident explanation. Every contract whose agent missed a hazard was drained, and nothing in a missed hazard’s write-up warned that it was coming. Higher effort settings didn’t reliably help. What helped most was asking the agent to write down what it believed about the runtime: some of those beliefs became experiments, and the experiments found behaviour the spec didn’t describe.

Even then, the agents followed the brief over their own findings. Eleven noticed the pricing bug and proposed a fix, and none shipped it.

In real work we get one run, not an average over dozens. By asking for an assumptions log, we get to review a list of verifiable claims about the runtime, instead of a handwavy explanation.

An interesting follow-up would be to give the models these implementations as reviewers and see whether they find the vulnerabilities they wrote.

Footnotes

  1. It would be interesting to repeat this experiment in a different domain, to see whether this behaviour changes outside a financial setting. ↩

  2. Simplified Move; imports, acquires annotations, and type abilities are omitted. Loom’s version is similar, but its storage functions are named differently (borrow_mut, addr_of) and move_to takes an address rather than a signer. ↩