Duck Cards: the evidence
Can a Microduck walk one square and turn a quarter turn per card, reliably enough for a classroom? We wrote down what reliable means before running anything, ran it in Pollen Robotics' own simulator, ran it again on a different machine, and publish every row. So far the answer is no, and this page says exactly where and why.
Summary
- Single cards mostly pass. Under randomized physics and sensing, 95 to 98% of the pre-registered short rows end within 10 cm and 15° of where they should.
- The pre-registered bar is not met. At Studio's 10 Hz state rate, heading sits on the 95% line (96.9% on one machine, 94.9% on another, 93.8% on a repeat), Walk back misses position (92.8% and 93.1%), and the stressed condition fails both.
- Long rows fail. Of the packs' rows of 10 cards or more, only 33 to 42% land under randomization and none under stress.
- No falls. 0 falls in 4,128 rows on a quiet machine. Falls seen on an overloaded machine were the machine.
- The first fix tried was rejected. Calibrating heading drift while the duck stands still helped the stressed rows but added falls and broke rows with no drift at all (below).
- The second fix, the mat as a compass, is being evaluated again. Its first run lifted long rows from 37% to 81% but added falls, on a machine its own renderer starved; a repeat with the renderer bounded is running (below).
- So closed-loop cards stay off on real ducks. In the shipped app a duck runs Walk and Turn as timed cards; the board rule is kept by the child and the paper board.
The question
Duck Cards are printed coding cards: a child lays Walk, Turn and Repeat cards in a row and the duck acts them out on a 30 cm grid. A timed card (walk for two seconds) does not land on a square: walking speed varies with the floor, the policy and the battery. A closed-loop card walks until odometry says one square and turns until the heading says a quarter turn. The question is whether that is good enough, measured, before any child relies on it.
System under test
- Simulator: Pollen Robotics' duck-sim with Pollen's own robotd answering
JSON-RPC
robot.move, and Pollen's v8 velocity-standing policy walking the duck. Odometry is robotd's own estimate; errors are judged against MuJoCo's true pose. - Envelope: commands are clamped to Pollen's gamepad defaults, vx within 0.4 m/s, vy within 0.3 m/s, vyaw within 1.0 rad/s.
- Controller:
duck-card-loop/1. The row keeps a board: every card targets an absolute pose on it, so a card corrects what the last one left. Walk cards steer onto the line; Turn cards stop short by their measured coast, settle and trim. Dead bands from system identification: forward starts near 0.20 to 0.25 m/s, back near -0.35 m/s, turning near 0.5 to 0.625 rad/s.
Pre-registered criteria
Written into docs/PACKS.md on 2026-10-15, before any result was in, and not
changed since. A condition passes only if every criterion passes.
- Position: at least 95% of rows end within 10 cm of the target.
- Heading: at least 95% of rows end within 15° of the target.
- Falls: the upper end of the Wilson 95% interval on the fall rate is at most 1% (with zero falls, that takes 381 rows).
- Time: no Walk card over 8 s and no Turn card over 6 s.
- Ending badly: every row that fails says so to the child.
Every share on this page is the observed share with its Wilson 95% interval in brackets.
Conditions
| Condition | What is perturbed |
|---|---|
| nominal | Nothing. State at 20 Hz, the reader's rate. |
| randomized | Per row: floor friction ×U(0.5, 1.2); masses and inertias ×U(0.95, 1.05); IMU heading bias U(-5, 5)° drifting at U(-0.5, 0.5)°/s; added state latency U(0, 0.15) s; odometry noise 5 mm and 0.5° per frame; odometry scale U(0.95, 1.05). State at 20 Hz. |
| randomized-10hz | As randomized, with state at 10 Hz, the rate Studio asks for. |
| stressed-10hz | The far end of every range at once: friction ×0.5, mass ×1.05, bias 5° and drift 0.5°/s, latency 0.15 s, odometry scale 0.95 or 1.05. State at 10 Hz. |
Evidence ladder
- Rung 1: Pollen's duck-sim, Monte Carlo. Every criterion in every condition, for the pre-registered rows and separately for rows with a Walk back card. Not passed.
- Rung 2: a second, clean machine. The same commit and conditions from a fresh checkout on different hardware, matching every verdict. Run on 2026-10-16 on Hugging Face Jobs (x86, AMD EPYC); 38 of 40 verdicts matched, so not reproduced.
- Rung 3: a real duck on a taped 30 cm grid. Studio's Measure my duck protocol, tape-measured and filmed. Not started; any fall sends the work back to rung 1.
Only when all three pass does a card pack turn closed-loop cards on for real ducks.
Results
Pre-registered rows, duck-card-loop/1. First machine: a Raspberry Pi 5.
Second machine: Hugging Face Jobs cpu-upgrade. 455 rows per condition on each.
| Condition | Criterion | First machine | Second machine |
|---|---|---|---|
| nominal | position | PASS 98.7% [97.2, 99.4] | PASS 99.1% [97.8, 99.7] |
| nominal | heading | PASS 98.7% [97.2, 99.4] | PASS 98.9% [97.5, 99.5] |
| randomized | position | PASS 96.9% [94.9, 98.2] | PASS 97.8% [96.0, 98.8] |
| randomized | heading | PASS 96.7% [94.6, 98.0] | PASS 98.0% [96.3, 99.0] |
| randomized-10hz | position | PASS 97.1% [95.2, 98.3] | PASS 96.0% [93.8, 97.5] |
| randomized-10hz | heading | PASS 96.9% [94.9, 98.2] | FAIL 94.9% [92.5, 96.6] |
| stressed-10hz | position | FAIL 86.8% [83.4, 89.6] | FAIL 84.4% [80.8, 87.4] |
| stressed-10hz | heading | FAIL 87.3% [83.9, 90.0] | FAIL 85.9% [82.4, 88.8] |
Two of the 40 rung 1 verdicts differ, both heading at randomized 10 Hz: this row, and the same criterion for Walk back rows below. The intervals overlap; a repeat on the first machine got 93.8%. We read it as a true rate near 95%, on the line, and count it as failing. Falls were 0 in every condition on both machines, and every Time and Ending badly criterion passed.
| Rows, condition | Criterion | First machine | Second machine |
|---|---|---|---|
| Walk back, randomized-10hz | position | FAIL 92.8% [89.9, 94.9] | FAIL 93.1% [90.1, 95.1] |
| Walk back, randomized-10hz | heading | PASS 96.0% [93.6, 97.5] | FAIL 93.8% [91.0, 95.8] |
| Walk back, stressed-10hz | position | FAIL 79.9% [75.7, 83.5] | FAIL 79.9% [75.7, 83.5] |
| 10 cards or more, randomized | position | 41.7% [30.1, 54.3] | 33.3% [22.7, 45.9] |
| 10 cards or more, randomized-10hz | position | 41.7% [30.1, 54.3] | 36.7% [25.6, 49.3] |
| 10 cards or more, stressed-10hz | position | 0.0% [0.0, 5.2] | 0.0% [0.0, 6.0] |
In evaluation: the mat as a compass (duck-card-loop/4)
A taped grid on the floor, 30 cm squares, sits outside the duck and does not drift. After each
Walk or Turn card the duck takes one frame from its head camera, keeps the dark tape pixels
whose rays meet the floor 0.2 to 1 m ahead, and finds the small turn and shift that put them
back on the grid. Heading comes back to the nearest quarter turn and position to the nearest
square. In the simulator the mat is rendered from the duck's true pose, and each condition
adds errors in what the duck believes about its camera (pitch, roll, mount, height,
brightness, noise). Pre-registered with /3's rule before any row ran.
Run 1 (4,800 rows on Hugging Face Jobs), against /1 on the same
machine type:
- Long rows' position at randomized 10 Hz: 36.7% to 80.8%; randomized 33.3% to 82.5%; stressed 10 Hz 0% to 46.7%. Their heading error stopped growing: median 3.6° against 8.6°.
- Falls in every condition, from none: 146 in all.
- Nominal, pre-registered position: 99.1% to 93.7%.
Rejected by its own rule. The machine looks like the cause. This is not
proven. 170 rows stopped because the duck's pose stopped arriving for half a second; the
/1 run had 2. 17 falls came before the mat had changed anything. The host's load
median was 260, against 21 for /3's run. The mat was rendered in software, which by
default takes every core for each frame, beside four simulated ducks on 8 vCPUs. On the Pi,
with a GPU, development rows had no falls.
The repeat was pre-registered after run 1 and before any row of it. It
changes only the instrument: one render thread, at a lower priority than the duck. The seeds,
code, criteria and rule are run 1's. It counts only if 10 or fewer of its 4,800 rows lose the
pose. If more do, it is called inconclusive, and the next idea starts from /1. It is
running now. Its result goes here, beside run 1, whatever it is.
Rejected candidates
The Walk lead (duck-card-loop/2)
duck-card-loop/2 gave Walk cards the lead Turn cards have: stop short by the
measured coast, settle, and lead later Walk cards by the row's mean coast. Its parameters were
chosen on development seeds only, then evaluated once on fresh seeds (4,800 rows), on both
machines. Against /1 on the same kind of machine:
- Walk back position at randomized 10 Hz: FAIL 93.1% to PASS 96.3%.
- Walk back heading, randomized: PASS 98.3% to FAIL 92.5%.
- Long rows' position at randomized 10 Hz: 36.7% to 20.0%.
- Falls: 11 on the overloaded first machine, 0 in 4,800 rows on the second.
It trades one failing criterion for another and makes long rows worse. Not adopted.
Calibrate at every stop (duck-card-loop/3)
The duck stands still for 5 s before a row and between cards; the yaw rate it reports while
standing is taken as drift (a pooled least-squares slope over every still window) and taken
out of heading and position. Pre-registered with its decision rule before any row ran; chosen
on development seeds; evaluated once on fresh seeds, 4,800 rows on Hugging Face Jobs. The rule:
it replaces /1 only if no verdict /1 passed becomes a fail and long rows'
position at randomized 10 Hz rises. Against /1 on the same machine type:
- Long rows' position at randomized 10 Hz: 36.7% to 45.8% (rises).
- Stressed 10 Hz, pre-registered position: 84.4% to 92.9%; long rows 0% to 30.8%.
- Falls: none to 1 in 510 (randomized), and 2, 4 and 1 in 493 Walk back rows. Six of the ten came on a row's first card, just after the stand.
- Every row at nominal, where no drift is injected: position 99.0% to 81.5%; long rows 98.3% to 45.0%.
Rejected by its own rule. The rows say why: the estimate follows the injected drift but sits about 0.15°/s low in every condition, nominal included. A standing duck's yaw really moves (the policy creeps or settles), and a sensor inside the duck cannot tell that from drift. Subtracting it for the whole row turns a small true creep into a heading error that grows with time. Heading needs a reference outside the duck, which is the next idea.
Why long rows fail
The controller already aims every card at an absolute place on the board, so its own errors do not stack. What stacks is what the duck believes about itself. A heading sensor drifting at up to 0.5°/s is off by up to 30° after a minute, and a minute is about a ten-card row; odometry that reads 5% long or short is off by 5% of every metre walked. Neither is visible to the controller. The enemy is elapsed time and distance, not the number of cards, and no per-card tuning removes a wrong sensor reading.
What is being tried next
Each is a new controller version, held to the same criteria above, evaluated on fresh seeds on a quiet machine, and published here whether it passes or not. One at a time, in this order:
- Calibrate at every stop. Tried and rejected: a standing duck is not still enough for its own sensor to measure drift.
- The mat as a compass. Grid lines seen by the duck's camera fix heading to the nearest quarter turn, and position to the nearest square. In evaluation: run 1 rejected on a starved machine, the repeat running.
- Spend less time. Merge consecutive Walk cards into one walk and two quarter turns into a half turn, so a row takes less time to drift.
- A reset in the game. A nest square where the child re-seats the duck, and long challenges split into chapters no longer than the length that passes.
Separately, Measure my duck will record how much a real duck's heading drifts standing still. If that argues for a gentler simulated condition, the condition is added and pre-registered as new; the conditions above are not edited.
Reproduce it
From a fresh checkout of duck-studio on Linux (x86 or arm64), with nothing else running:
sh cardreader/sim/validate.sh
python3 cardreader/sim/mc/mc_summary.py cardreader/sim/results/raw \
cardreader/sim/results/raw-acceptance cardreader/sim/results/raw-back
validate.sh builds Pollen's stack at pinned commits, runs system identification
and every Monte Carlo batch, and writes HOST.txt (machine, commit, Pollen SHAs) and a
per-minute load log. Each session's randomness is seeded from the batch seed and the rows
already done, so a rerun is a fresh statistical sample, not a replay: compare verdicts and
intervals, not rows. A loaded machine makes simulated bodies fall behind real time and fall;
check the logs for "behind real time" before trusting a fall.
Limits
- No real duck has run a closed-loop card yet.
- duck-sim drives position servos, which are not the actuator v8 was trained with.
- Real floors, real IMU drift and real foot slip appear only through the randomized ranges. The simulated drift is a constant rate per row; a real one wanders.
- The second machine is shared hardware; its load reading covers the host, not the job. No simulated body logged falling behind real time.
Data and code
- The pre-registration and every result docs/PACKS.md
- Every row: first machine, repeat, and the Hugging Face Jobs run in rung2-hf cardreader/sim/results
- The Walk lead's rows cardreader/sim/results-walkback
- Calibrate at every stop, criterion by criterion cardreader/sim/results-calibrate
- The mat as a compass, run 1, criterion by criterion cardreader/sim/results-compass
- The controller cardloop.py
- A draft proposal to Pollen: walk by distance, turn by angle not filed
Questions, a second reproduction or a correction: email or open an issue on duck-studio.