DEV Community

Ahmed Nafies
Ahmed Nafies

Posted on

I built an agentic coding exam for Hy4 preview, then made the model sit it

Hy4 Preview

Tencent open-sourced Hy4 preview on 28 August 2026. 770B parameters, 49B active per token, a 1M context, Apache 2.0 weights, and a model card that admits two unflattering things about itself: it "spends longer than necessary reasoning through complex tasks" and it has "a tendency to over-verify its own work."

The benchmarks look good (GPQA Diamond 92.3, SWE-Bench Multilingual 82.9, HLE with tools 55.4). Tencent's own blind eval puts it 0.07 ahead of GLM 5.3 and 0.05 ahead of Kimi K3. On a 3-point scale, margins that small are noise, and none of it tells me whether the model is any good in my editor. So I built a smaller test: a repo with a missing feature, the rules hidden in a policy document, and 26 tests that will not pass until the feature exists.

One disclosure before the results. I wrote the test and I also took it, running as opencode-go/hy4-preview inside opencode. Read this as a field report from one afternoon, not a study.

The test bed

orderflow is a toy event-sourced order pipeline. Orders get placed, taxed, captured and fulfilled. It cannot give money back.

src/orderflow/
  money.py      # integer cents, banker's rounding, largest-remainder allocate()
  events.py     # frozen dataclasses + a name -> class registry + (de)serialization
  state.py      # Order aggregate, TaxTable, OrderBook.apply(event)
  pipeline.py   # FIFO dispatch, idempotency by event_id, step limit
  handlers/     # audit, pricing, inventory
tests/          # 42 passing tests
docs/REFUND_POLICY.md
SPEC.md
Enter fullscreen mode Exit fullscreen mode

The task is to implement refunds, and to do it by extending the architecture rather than bolting a function onto the side: new events, new aggregate state, a new handler, all wired into the registries that already exist.

Three things make it harder than it looks.

The rules live in a document, not in the prompt. SPEC.md is a short brief. It says, in effect: go read docs/REFUND_POLICY.md, go read the tests, follow the conventions you find. The policy document is where the validation order, the clamping rule, the proportional split and the idempotency requirement actually live. Skim the brief and start typing and you get something that looks right and is wrong.

The registry is a forcing function. Events register themselves by name, and one generic test walks the entire registry:

@pytest.mark.parametrize("name", sorted(EVENT_REGISTRY))
def test_every_registered_event_survives_serialization(name: str) -> None:
    cls = EVENT_REGISTRY[name]
    original = _sample_event(cls)          # builds an instance from type hints
    restored = deserialize_event(serialize_event(original))
    assert restored == original
Enter fullscreen mode Exit fullscreen mode

You cannot add an event and forget to register it. You cannot add a field whose type the serializer cannot round-trip. That one test ended up being worth more than the 20-odd tests that spell out refund behaviour.

The money is the point. Refunds split across line items, and the split has to be exact. Money.allocate() uses the largest remainder method, the net portion rounds down, and the tax portion absorbs the difference, so net + tax == share on every line and the shares add back to the refunded amount. Refund a 49.48 order as three partials plus a final sweep and you still land on 4948 cents, not 4947.

The run

Baseline, before any solution code:

42 passed                      # everything except the contract
ERROR tests/test_refunds.py    # ImportError: cannot import name 'RefundFailed'
ruff: clean
mypy: 16 errors, all in test_refunds.py
Enter fullscreen mode Exit fullscreen mode

Then the implementation: three events and two value objects in events.py, per-SKU refund tracking plus a RefundIssued branch in OrderBook.apply, and an 81-line handler in handlers/refunds.py.

Eight failures on the first run. One on the second. Green on the third.

74 passed
ruff check .   All checks passed!
mypy src tests Success: no issues found in 18 source files
Enter fullscreen mode Exit fullscreen mode

For scale: about 81 lines of handler, 40 of events, 35 of aggregate state, and 5 lines of wiring in handlers/__init__.py and AUDITED_EVENTS.

Two bugs the fixed tests did not catch

1. X | None is not Optional[X]

The round-trip test failed on refund.requested, whose scope field is:

line_items: tuple[str, ...] | None
Enter fullscreen mode Exit fullscreen mode

typing.get_origin() returns types.UnionType for the PEP 604 form and typing.Union for Optional[X]. The serializer only handled typing.Union, so the union branch was skipped, None fell through to the tuple branch, and decoding died with TypeError: 'NoneType' object is not iterable.

That is a production deserialization crash sitting one annotation away, and every hand-written test stepped over it, because every hand-written example passed a real tuple.

2. The one the fuzzer found

With the suite green I wrote a throwaway randomized harness: random orders, random SKU subsets, random refund sequences, checking invariants after every step. Per-line sums, per-SKU ceilings, refunded no greater than captured, the status flip, idempotency on replay, and a serialization round trip on every emitted event.

It failed on the first run, here:

def _decode(value, hint):
    if isinstance(value, dict) and "__type__" in value:
        return _decode_tagged(value, hint)
    ...
Enter fullscreen mode Exit fullscreen mode

When line_items is None, the encoded payload carries a plain null. There is no tagged-dict branch to take, so None reaches the union branch, picks the first non-None argument, and tries to iterate it. One line fixed it:

if value is None:
    return None
Enter fullscreen mode Exit fullscreen mode

Three thousand randomized scenarios now hold every invariant, and a seeded 200-case version lives in the suite as tests/test_refunds_property.py.

Example-based tests encode the cases you already thought of. For money, allocation and serialization, those are the easy ones.

The disagreement

One test failed with a reason code I did not expect:

issued(refund(pipeline, amount="1000.00", request_id="req-1"))
outcome = failed(refund(pipeline, amount="1.00", request_id="req-2"))
assert outcome.reason is RefundFailureReason.EXCEEDS_REFUNDABLE   # got STATUS_NOT_REFUNDABLE
Enter fullscreen mode Exit fullscreen mode

Both answers are defensible. Refund an order in full and two things are true at once: the order is now in state REFUNDED, which is not in REFUNDABLE_STATUSES, and there is nothing left to refund.

The policy document settles it, in the model's favour. It defines a validation order and says to stop at the first failure, with status_not_refundable at position 5 and exceeds_refundable at position 7. A fully-refunded order trips rule 5. My test was wrong, the document was right, so I changed the test and added a separate case that exercises EXCEEDS_REFUNDABLE properly: refund SKU A completely, then ask for more of A while B still has money.

The other direction is worth thinking about, because it is the one a compliant model would have taken. To make my test pass it could have reordered the validation rules or quietly widened REFUNDABLE_STATUSES. Either change passes review and produces a refund bug six months later. It pushed back instead and said which document it was following.

What I learned

The over-verification is real. I wrote a 3,000-scenario property harness for a toy repo with no users, which is silly by any reasonable accounting. It also caught a crash that 74 green tests missed. The trait the model card lists as a weakness is the trait that caught the bug. I will take that trade on anything touching money. I would not want to pay for it on a ten-minute task.

It reads before it writes, and it treats the tests as the spec. The first move was a grep over the test file to extract the exact API surface the contract expected (refunded_amount, refundable_remaining, refunded_net_by_sku) instead of inventing names and iterating until they matched. Small thing, and it is most of the difference between useful in a real repo and impressive in a demo.

It used the allocation rule instead of approximating it. The brief says to use Money.allocate and not to hand-roll proportions, and the implementation does exactly that, including skipping zero-value lines and clamping each line to what remains for that SKU. No drift anywhere in the randomized runs.

Its own test code was the weak part. Seven of the eight initial failures were bugs in the contract I had written: tests that asked for a refund without placing an order first, and so got ORDER_NOT_FOUND. When a model writes both the exam and the answers, a broken exam is still broken.

Reusing this

The repo takes about ten minutes to read and the interesting knob is docs/REFUND_POLICY.md. Change the policy and you get a different exam over the same codebase: refunds that restock inventory, tax that is not refundable, a restocking fee that has to be allocated across lines, a different validation order. The money invariants stay put.

To point another model at it, hand over SPEC.md as the only prompt, forbid edits under tests/, and require pytest, ruff check . and mypy to be clean. The collection error at the start is deliberate. The model has to infer an API surface from tests that import symbols which do not exist yet.

So, does it impress me?

On the narrow question I actually care about, yes. Given a repo where the rules sit in a document and the arithmetic has to come out exact, it read everything first, produced a correct implementation in one pass, refused to fudge a test it could have satisfied by quietly breaking the spec, and then found two bugs I had shipped into my own suite. That is the behaviour I want from an agent working in a codebase I have to maintain.

On the question the benchmarks pretend to answer, no, and the numbers do not support it either. A 0.07 margin across 203 tasks rated by 163 people is a tie. One self-administered task on a codebase I designed is not evidence of a step change. What I have is a single data point saying the model behaves well under the conditions where I would actually use it, which is worth more to me than the benchmark table and much less than a real evaluation.

When I'd use it, and when I wouldn't

Use it for long-horizon agentic work that keeps resending a large system prompt and a growing tool history. The economics here are better than the headline prices suggest: at launch the listings had it at $0.834 per million input, $2.501 per million output, and $0.042 per million for cached input, which is a 20x discount off the input rate. Agent loops are mostly resends, so the cached rate is the number that decides your bill. It also undercuts GLM 5.3 by roughly 40% on output and Kimi K3 by about 6x.

Use it when you need genuine long context. 1M tokens is more than GLM 5.3 offers, and it is the main thing here that no cheaper open model matches.

Use it if Apache 2.0 with no field-of-use clause matters to you, which for most companies it does.

Do not use it if you are optimizing on price alone and do not need the context. DeepSeek V4 Pro costs roughly half as much on input and a third as much on output. That is still the floor and Hy4 does not go under it.

Do not use it for anything latency-sensitive. OpenRouter listed 43 tok/s best throughput at launch and one reviewer measured 36. That is slow when a human is waiting on the other end of an interactive loop.

Think twice before pinning production traffic to it at all. The card calls this an early version shipping with known issues, and the preview label means the checkpoint will be replaced.

One habit I would keep whichever way you go: let it write the implementation, and review the tests it writes yourself. Here the code was right and my tests were wrong, seven times over.

Top comments (0)