The Pollen Robotics Microduck, as pure Swift. Runs the robot's real trained policies, and tests on Linux.
Two products. DuckKit has zero dependencies — the robot, its policies, its protocol, its voice and its choreography. DuckEvidence takes swift-crypto and is the part that signs things. An app that just wants a walking duck does not link BoringSSL to get one.
let policy = try DuckPolicy.load(contentsOf: walkingONNX)
var duck = DuckSimulation(walk: policy)
let tick = duck.step(command: DuckCommand(twist: (0.15, 0, 0)))
let sites = DuckKinematics.sitePositions(jointAngles: tick.jointAngles)
// sites["head_camera"] -> where to put the camera, 24 cm up, in metresThat is the actual alpha_walking.onnx from Pollen's repo, running the actual
61-float observation the robot's own daemon feeds it, at the robot's own 50 Hz.
Not an animation of a duck walking — the trained network walking.
.package(url: "https://github.com/craigm26/duckkit.git", from: "1.0.0")Then take the product you actually need — they are separate for a reason:
| You want | Depend on | Brings |
|---|---|---|
| A walking duck, a voice, choreography, the protocol | DuckKit |
nothing |
| To draw the robot's actual shape | DuckVisual |
2.4 MB of triangles |
| To sign, hash-chain, or attest something | DuckEvidence |
swift-crypto → BoringSSL |
In xcodegen, a package that vends two products needs both spelled out:
packages:
DuckKit:
url: https://github.com/craigm26/duckkit.git
from: 1.0.0
targets:
YourApp:
dependencies:
- package: DuckKit
product: DuckKit
- package: DuckKit
product: DuckEvidence # only if you sign somethingDepend on it by tag, not by path. This repo is public, so a URL dependency needs no deploy key and no credential on a rented build machine — which was the only argument for a sibling-path dependency, and it no longer holds. A tag also means an archive built today and one built in six months run the same policy.
Two things that are not in here, deliberately. There is no Journal:
OpenCastor's journal chains over a Receipt, which is a decision a robot's
gateway actually signed, and letting a phone-minted diary entry wear that shape
would be a lie about what happened. DuckChain moves the fold and leaves the
ledger behind, and each consumer keeps its own namespace. And there is no
DuckBeak type: the mouth is joint 9 in DuckModel, DuckKinematics gives you
the mouth_tip site, and DuckPerformance decides how far open it is — three
types that already exist, rather than a fourth that wraps them.
DuckModel |
Joint names and order, home pose, travel limits, action scale, the trained-in filter coefficients, the battery curve |
DuckObservation |
The 61-float contract and the 13-value command block, with every upstream trap preserved |
DuckPolicy |
A hand-written ONNX reader and an ELU multilayer perceptron. Loads the real policies, refuses anything else. Describes and differentiates them too, and serializes its parameters in one fixed order so they can be identified |
DuckGait |
Raw policy output to joint targets: scale, low-pass, travel stops that are named rather than silent |
DuckKinematics |
Forward kinematics over the robot's MuJoCo chain. Every body and named site, in metres |
DuckSimulation |
The 50 Hz loop — observation, policy, targets, observation |
DuckSceneMJCF |
A captured room written as a deterministic MuJoCo scene |
DuckRoomReduction |
What a scanner saw, reduced to that room. Y-up to Z-up, and the floor is the lowest surface rather than the largest |
DuckClock |
A fixed 50 Hz accumulator with a catch-up clamp, so the gait does not run at the panel's refresh rate |
DuckRPC |
JSON-RPC 2.0 over NDJSON, with no transport underneath it. The framing is the hard part |
DuckState |
The robot.state notification, decoded. Every field optional, because a missing block must never read as a zero |
DuckToF |
The head's 8×8 depth sensor — tof.frame decoded, and ST's status byte kept three-way, because "nothing there" and "could not tell" are different facts |
DuckStateReducer |
The stream reduced to cumulative integers — distance, falls, time upright — in micrometres, reproducibly |
DuckSkill |
The five skills and the twelve presses, including what robotd refuses and why |
DuckSound |
The seven wire tags, the one held tag, and the arithmetic of the hold protocol |
DuckVoice |
Duck calls synthesized from arithmetic — no asset, no AVFoundation, no license question |
DuckVoice.Personality |
What makes this duck sound like a different creature from that one. Ported from the robot's own seeded synth |
DuckPerformance |
What the body does while it makes a noise. One set of curves, so the ghost and the robot are the same animal |
CanonicalJSON |
A JSON value model that keeps the int/float distinction canonical bytes depend on, identically on Linux and Apple |
DuckChain |
The fold, and only the fold: head₀ = "GENESIS", headᵢ = sha256(headᵢ₋₁ ‖ canonical(recordᵢ)) |
DuckSigning |
Ed25519 over canonical bytes — sign, verify, kid(for:) |
SigningKeyStore |
Keychain on device, in-memory on Linux, with the device-local invariant assertable under swift test |
DuckSoccerMatch |
A match as an append-only, hash-chained, signed record — a league table nobody can quietly edit |
DuckPolicy.fingerprint |
Which policy actually ran: SHA-256 over the parameters, not the file |
DuckOfficialPolicies |
Which nine policies Pollen actually released, by fingerprint — so "official" is checkable offline instead of asserted |
The Microduck's voice is not a set of WAV files — there are none to vendor. It is a seedable synth: one integer derives a personality (register, harmonic tilt, nasality, vibrato, quackiness, tempo) and the robot seeds itself from its own SoC serial, so two Microducks on the same table sound like two animals.
DuckKit does the same thing, from a port of upstream's derivation:
let duck = DuckVoice.Personality(identifier: robotSerial) // or (seed: 42)
let quack = DuckVoice.render(.chirp, part: .whole, seed: 1, as: duck)render(_:part:seed:) without a personality still renders the tables as
written, unchanged. Adding one bends them: the same tag stays recognisably that
tag, and the animal underneath it changes.
What is ported is the derivation — the generator (xoshiro256++ through
splitmix64), the draw order, the trait ranges, the harmonic weighting, and the
CRC-32 variant hash. Upstream's own note explains why that has to be exact:
a robot's sound bank is re-rendered from its seed on every install, so the
generator is the voice, and a change to the arithmetic re-voices the fleet
silently. DuckPersonalityTests pins the stream, the CRC-32 of all seven tags,
every trait of seed 1 and its harmonic weights against values computed by a
second, independent implementation of the same spec — because a golden value
copied out of the code under test only proves the code has not changed.
What is not ported, and not claimed: byte-parity with a real robot's audio. The recipes that turn traits into samples are upstream's; DuckVoice's own oscillator stack renders them, and how it consumes each trait is ours. So the traits for a seed match and the couplings are ours. Nothing has been checked against a real bank, because no robot exists to check against yet.
One invariant survives all of it: a personality never changes a length.
Every buffer stays a whole number of 50 Hz control ticks, because
DuckPerformance choreographs against those counts and a duck that stretched
its own audio would slide out of its own gestures.
Everything in DuckKit is Foundation and arithmetic. That is not minimalism for
its own sake: it is what lets swift test run the real policy on a Raspberry Pi
and get the same floats an iPhone will, with no toolchain, no accelerator and no
device in the way.
Two decisions follow from it. DuckPolicy parses the ONNX protobuf by hand
rather than taking onnxruntime, because every shipped alpha policy is the same
nine-operation graph —
obs[1,61] → (obs − mean)/std → 61×512 → ELU → 512×256 → ELU → 256×128 → ELU → 128×14
— about two hundred thousand parameters and forty microseconds of work.
onnxruntime is a hundred-megabyte answer to that question, and Core ML does not
run under swift test. And DuckKinematics carries its own quaternion type
because simd does not exist on Linux, and the whole computation is fifteen
multiplies.
DuckPolicy validates at load, never at inference — a file with the wrong op
sequence, a transposed weight or an unexpected width is refused while nothing is
moving, which is the rule the robot's own runtime follows.
It is also why signing lives next door rather than here. Ed25519 needs
swift-crypto, swift-crypto brings BoringSSL, and a soundboard should not compile
a TLS library to make a duck noise. So DuckEvidence is a separate product with
that one dependency, and anything in DuckKit that wants to be attested — the
policy weight fingerprint, for instance — is reached from there as an extension.
The cost is one extra import for the apps that sign, and nothing at all for the
apps that do not.
DuckEvidence uses swift-crypto rather than CryptoKit for the same reason
DuckKit uses no packages: the same Curve25519.Signing API compiles on Linux, so
the signer under swift test on the Pi is the signer on the phone.
Joint order, home pose, action scaling and the filter coefficients are ported
from the robot's runtime (duck-control, robotd), where they were measured
against hardware and trained into the policies. Re-deriving them from a
datasheet is the kind of change that looks right and walks wrong.
The kinematic chain is that repo's own MuJoCo model, vendored as a fixture, and
DuckKinematicsTests re-derives the tables from it — so the hardcoded values
cannot drift from upstream without a test going red. DuckPolicyTests proves the
forward pass against onnxruntime's own output over the vendored network: same
weights, same bytes in, same floats out to 1e-4.
swift testRuns on Linux aarch64 (a Pi 5) and on macOS. 347 tests, no hardware, no network, no device — including the real trained policy, the synthesized voice, and the signing.
The two products are tested apart, DuckKitTests against DuckKit alone, so a
dependency creeping into DuckKit fails the build rather than passing quietly.
DuckEvidence does depend on DuckKit — it attests things DuckKit describes, so
it has to see them — and that direction costs nothing, because DuckKit brings
nothing with it. The arrow never points the other way.
Apache-2.0, matching upstream. Tests/DuckKitTests/Fixtures/duck/ vendors
alpha_walking.onnx and robot_walk.xml verbatim from
pollen-robotics/microduck.
DuckKit is not affiliated with Pollen Robotics.