Skip to content

Specify the wire protocol, and check it on every change - #1

Merged
tactino merged 13 commits into
mainfrom
chore/artifact-readiness
Sep 11, 2026
Merged

tactino merged 13 commits into
mainfrom
chore/artifact-readiness

Conversation

@tactino

@tactino tactino commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

Gives the wire protocol a written specification, a way to check an
implementation against it, and CI that does so on every change. The licence
and packaging work this branch started with is now the smaller half.

The protocol is written down

SPEC.md (530 lines) is the first statement of what the bytes between the
training server and an env client actually are: transport settings, the
msgpack ndarray convention, the four messages, the exchange rules, the close
reasons, a conformance checklist.

It is written from the implementation and says so. Where the
implementation is under-specified, or where two of our own servers disagree,
it says that in a box marked Gap rather than inventing a rule. Five of
those:

Gap
The two servers disagree WebSocketAgentServer sends env_ids with the action; RayAgentServer sends none, ignores env_indices, and keeps one env's state per connection. An earlier single-env dialect that was never updated.
text leaks numpy It ships as a <U array — fixed-width UTF-32, NUL-padded, width set by the longest string in that message. Measured, not assumed.
The two clients disagree about it plugrl-env-client sends the <U array; examples/raw_client.py sends a msgpack string array. Both accepted, because the server never validates observation contents.
step_ids Required, sent, read by nothing.
Authorization Sent when configured, checked by no server. There is no authentication.

The metadata message used to be a sixth: always {}. plugrl-server now
fills it in, and section 5.1 documents the keys plus the rule that makes them
trustworthy — an absent key means the server does not know, never that the
value is a default.

It can be checked, not just read

examples/conformance_server.py speaks the protocol and grades a client
clause by clause, printing what it broke and exiting non-zero. plugrl-server
cannot do this job: it validates the message_type and hands the rest to the
policy, which is right for a training run and useless to someone bringing up
a client in a new language.

The report has two severities and keeping them apart is the point. A
violation is something the real server would reject or mishandle. A
note is something it accepts that differs from what the Python client
sends — a portability risk, not a breach. Collapsing the second into the
first would mean enforcing rules the protocol does not have.

I checked that the harness bites: four deliberately broken clients were run
against it. Two infers in a row, a fourth key in the observation, and
mismatched leading dimensions were all caught. A float64 reward was not — and
that turned out to be the specification's fault. It said <f4; the server
never inspects the width. The spec now says float, and the harness reports
the difference as a note.

CI checks the cross-language claim

This repository asserts that the protocol can be spoken by something that is
not this codebase. That was demonstrated once, by hand, and would have stayed
green in the README long after it stopped being true. The new job builds the
C++ client, fails if ldd shows anything but the C++ and C runtimes, and
drives the conformance server. The Python reference client gets the same.

One step is subtler: a client that hard-codes float32 sends perfectly valid
messages and passes every clause a server can observe — it only misreads what
comes back. So that step runs a server whose actions are <f8 and whose
values encode the horizon position, and asserts on the client's own output.
It is the regression test for a bug the C++ client had until this branch.

Bugs this found in the C++ reference client

  • pack_f8 and pack_i8 memcpy'd the host layout while declaring <f8 and
    <i8. Correct on x86, silently wrong on a big-endian controller, and
    undetectable at the far end because the declared byte order still said
    little.
  • The action decode assumed float32. Against an <f8 server the old binary
    printed 0 0 0 0 0 0 where the new one prints 0 0 0 1 0 0.
  • A text frame where binary was expected was fed to the msgpack parser.
  • A frame length field was used to allocate before being bounded.

The rest

Apache-2.0 licence; msgpack_numpy.py attributed to openpi in the file
header and in NOTICE (it is openpi's file, reformatted only, and the wire
format is deliberately identical); CITATION.cff; pre-commit.

Tests: 30, from 0. The private-dependency CI problem this branch opened
with is resolved — the repository is public.


🤖 Generated with Claude Code

tactino and others added 10 commits September 9, 2026 15:32
The repos shipped with no license at all, which makes them legally unusable
and is a straight deduction in artifact review. Apache-2.0 matches openpi and
lerobot, the two Apache-2.0 dependencies, and adds a patent grant.

Also fills in the empty package descriptions and raises the setuptools floor
to 77, the first release that understands the PEP 639 license fields used here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
plugrl-protocol had no CI, no pre-commit config and no tests at all, despite
being the one package both sides depend on: every message between the training
server and every env client goes through these four enum values and this
serializer. A silent change here breaks both sides at once, so the tests pin
the wire values and assert numpy arrays, uint8 image batches and the nested
images/states/text observation shape all survive a round trip.

Includes the ruff-format changes needed for the lint job to pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CITATION.cff validates against CFF schema 1.2.0. The author list is taken from
pyproject.toml and lists one person; co-authors should be added before any
submission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both drive a real plugrl-server through complete infer/action/feedback
exchanges while sharing no code with PlugRL, which turns "the protocol is
portable" from a claim into something a reader can check.

The C++ one is the interesting case: 633 lines with no third-party
libraries at all. It was written on a machine with no msgpack library, no
WebSocket library and no OpenSSL, so SHA-1, base64, WebSocket framing and
masking, and the msgpack subset the protocol needs are all in that one file.
ldd shows libstdc++, libgcc_s, libc and libm and nothing else - the situation
an embedded controller is actually in.

The Python one needs msgpack and websockets and nothing else; notably no
numpy, which matters because the dtype typestr is the only piece of numpy
vocabulary on the wire and both clients parse it by hand in about ten lines.

They belong here rather than with the server because this is the package they
implement, and because it is public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
raw_client.py imported struct but ended up using the array module; the
docstring said struct too. Both fixed. Verified afterwards that both clients
still complete exchanges against a real server - formatting should not change
behaviour, but should-not is not did-not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
src/plugrl_protocol/msgpack_numpy.py is openpi's file, reformatted. Diffing
it against third_party/openpi/packages/openpi-client/src/openpi_client/
msgpack_numpy.py shows only line-wrapping differences from ruff-format.

openpi is Apache-2.0, Copyright 2024 Physical Intelligence. Apache-2.0
section 4 requires carrying attribution forward, and this repository's NOTICE
claimed the whole work for PlugRL. That is a compliance defect, and this
repository is already public, so it is fixed first.

NOTICE now records the chain, which runs one step further than openpi: their
file is itself adapted from lebedov/msgpack-numpy (BSD 3-Clause), as their
docstring says.

Worth stating plainly rather than burying: PlugRL's wire format IS openpi's
wire format. That is not an accident to be embarrassed about - it means a
PlugRL client and an openpi client speak the same bytes. But it does mean the
transport was never PlugRL's invention, and nothing downstream should claim
it as one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Until now the protocol existed only as two implementations that agreed with
each other. SPEC.md states it: transport settings, the msgpack ndarray
convention, the four messages, the exchange rules, the close reasons, and a
conformance checklist. It is written from the implementation and says so,
and where the implementation is under-specified it says that too rather
than inventing a rule.

Writing it down turned up five things worth knowing:

  * The two servers disagree. WebSocketAgentServer sends env_ids alongside
    the action; RayAgentServer sends no env_ids at all, ignores the
    request's env_indices, and keeps one environment's state per
    connection. It is an earlier single-environment dialect that was never
    updated.
  * The text field is the one place numpy leaks past the typestr. It ships
    as a <U array - fixed-width UTF-32, NUL-padded, width set by the
    longest string in that message - so a non-Python client has to
    implement UTF-32 to read a prompt. Measured, not assumed.
  * The two shipped clients disagree about how to write it.
    plugrl-env-client sends the <U array; examples/raw_client.py sends a
    plain msgpack string array. Both are accepted because the server never
    validates observation contents.
  * step_ids is required, sent, and read by nothing.
  * The metadata message is always empty. Everything a client needs to know
    out of band - action horizon, action shape, protocol version - would
    naturally go there.

tests/test_spec_conformance.py makes the checkable half executable: bin
keys rather than str, C-order bytes from a non-contiguous view, byte-order
declarations, one byte per boolean, the rejected dtype kinds, and the text
encoding down to its padding bytes. A specification nobody runs is a
document that drifts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SPEC.md gave the protocol a written form. This gives it a way to be
checked. conformance_server.py speaks the protocol and validates every
clause visible from the server's side, then prints which one a client
broke and exits non-zero. plugrl-server cannot do this job: it validates
the message_type and hands the rest to the policy, which is right for a
training run and useless to someone bringing up a client in a new
language.

The report has two severities, and keeping them apart is the point. A
violation is something plugrl-server would reject or mishandle. A note is
something it accepts that differs from what plugrl-env-client sends - a
portability risk, not a breach. Collapsing the second into the first would
mean enforcing rules the protocol does not have, which is exactly how a
specification stops describing its implementation.

Both reference clients now pass, 24 clauses, with one note each: they send
text as a msgpack string array rather than a <U array. That is the section
3.4 Gap, reported rather than hidden.

Checking that the harness bites: four deliberately broken clients were run
against it. Two infers in a row, an observation with a fourth key, and
images and states with different leading dimensions were all caught. A
float64 reward was not - and that turned out to be the specification's
fault, not the harness's. SPEC.md said `<f4`; the server never inspects the
width. The spec now says float, notes that float32 is the convention, and
the harness reports the difference as a note. A specification written from
an implementation does not get to be stricter than it.

The C++ client changes are all conformance fixes found by reading it
against SPEC.md:

  * pack_f8 and pack_i8 memcpy'd the host layout while declaring "<f8" and
    "<i8". Right on x86, silently wrong on a big-endian controller, and
    undetectable at the far end because the declared byte order would still
    say little. Bytes are now emitted explicitly little-endian.
  * The action decode assumed float32. The dtype is the environment's and
    is never renegotiated, so it now parses the typestr. Verified: against
    a server sending "<f8", the old binary printed 0 0 0 0 0 0 and the new
    one prints 0 0 0 1 0 0, which is also the time-major layout of section
    5.3 showing up in the values.
  * A text frame where binary was expected is now fatal with the server's
    message, per section 7.4, instead of being fed to the msgpack parser
    and reported as "unsupported msgpack type 0x47".
  * A frame length field is capped before allocating.
  * The default port was 8123 while every document said 8000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This repository asserts that the protocol can be spoken by something that
is not this codebase. Until now that was demonstrated once, by hand, and
would have stayed green in the README long after it stopped being true.

The new job builds the C++ client, fails if ldd shows anything but the C++
and C runtimes, and drives conformance_server.py, which grades it against
SPEC.md and exits non-zero on a violation. The Python reference client gets
the same treatment.

One step is subtler than the others. A client that hard-codes float32 sends
perfectly valid messages and passes every clause a server can observe; it
only misreads what comes back. So that step runs a server whose actions are
float64 and whose values encode the horizon position, and asserts on the
client's own output - which also pins the time-major layout of section 5.3.
It is the regression test for a bug this client had until today.

Verified locally against gcc 11.4: both clients pass, the ldd allowlist
holds, and a deliberately misbehaving client makes the server exit 1.

No -Werror: a newer compiler finding a new warning belongs in the log, not
in a red build on an unrelated change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 5.1 described an extension point with nothing in it. plugrl-server
fills it in as of today: protocol_version, server and version, algorithm
and policy names, action_horizon and action_dim.

The rule that makes the rest trustworthy is written down with them: an
absent key means the server does not know, never that the value is zero or
a default. A client written against the old empty message still works,
which is why none of the keys are required.

conformance_server.py sends the descriptive keys too, so a client that
reads them is exercised and one that ignores them is proved not to break.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tactino tactino changed the title Artifact readiness: licence, CI, reproducible deps, and fixes found on the way Specify the wire protocol, and check it on every change Sep 10, 2026
tactino and others added 3 commits September 10, 2026 19:30
The abstract said PlugRL exists so that environments whose dependencies
"cannot coexist with a modern deep learning stack" can still be used for
reinforcement learning. plugrl-server's E1 experiment refutes exactly that,
in four rounds, and keeps the refutation in the repository.

Replaced with what survived and is checkable: separate processes joined by a
small WebSocket protocol, independently installable and versionable, an env
client that need not be Python, and openpi's serving format plus a feedback
return channel - which is the difference between serving a policy and
training one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README said `uv add plugrl-protocol` / `pip install plugrl-protocol`.
None of the three PlugRL packages is on PyPI - all three return 404 - so
following that instruction fails with no matching distribution. It now shows
the pinned git install, which is how plugrl-server and plugrl-env-client
actually depend on this package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The citation and package metadata named one author. The commit history of
these three repositories names two: Chenhao Lu, and Zuo Gou, whose 31
commits here include the GRPO-diffusion policy and the policy-gradient base
class that DPPO, FPO and PI0 all derive from - and who is the largest
contributor to plugrl-protocol, at 12 commits to 9.

Order is by who started the project, not by volume; reorder if you would
rather it were otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tactino
tactino marked this pull request as ready for review September 11, 2026 15:14
@tactino
tactino merged commit 840296e into main Sep 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant