Skip to content

Latest commit

 

History

History
343 lines (263 loc) · 16.5 KB

File metadata and controls

343 lines (263 loc) · 16.5 KB

rultra

A self-optimizing Raspberry Pi 5 sensor box, in Rust

Every sensor on the board behind one trait — and an honest record of how well each one actually works.

CI license rust tests platform

rultra running on a Raspberry Pi 5: probe output showing three verification states, and a governed cycle rolling back a thermal proposal

Real output from a Pi 5 at 83.6 °C. The box proposed backing off, applied it, measured, and its own gate refused — then rolled back and signed the record.

Quick start · The idea · Architecture · ADRs · Contributing


rultra runs on a Raspberry Pi 5 mounted on an Elecrow CrowPi V3.2 learning board. It does two things:

  1. Unifies the hardware. Light sensor, LED matrix, LCD, segment display, GPIO — one Sensor/Actuator trait, one JSON-Lines telemetry stream, one command.
  2. Improves its own configuration, under governance. The box measures itself, proposes a change, and promotes it only through a hard fitness gate with canary and verified rollback — every step recorded in a signed audit trail.

Built on the ruvnet stack: autogenous for governed evolutionary promotion, ruvector for vector memory, and MetaHarness's scoring methodology, ported to Rust.


The idea most sensor libraries skip

Ask a typical Raspberry Pi library whether a device works and you get a boolean. That boolean is a lie of omission — and this one board has a live example of each of three genuinely different states:

Device Bus State What is actually known
BH1750 light sensor I²C 0x5c ✅ Working Tracks real light: 58 lux in a lit room, 5.8 in the dark
MAX7219 8×8 matrix SPI0 CE1 ✅ Working Lit and confirmed by an observer
HT16K33 segment display I²C 0x70 ✅ Working Digits observed changing
HC-SR04 range finder GPIO 23/24 🔵 Unvalidated Stable to ±0.6 cm — but never checked against a known distance
MCP23008 LCD I²C 0x21 ⚠️ Acks but silent Chip acknowledges; nothing displayed yet
Buttons / tilt GPIO ❓ Untested Lines read once, but the BCM numbers were never recorded

So verification is a type, not a comment:

pub enum Verification {
    Working,        // observed producing correct output
    Unvalidated,    // stable, plausible output — never checked against a reference
    AcksButSilent,  // answers on the bus, no observable effect yet
    Untested,       // documented, never exercised here
}

Working is the only variant that licenses the word "works" — in the code, in this README, or in a status endpoint. probe() reports what responded now and never upgrades a device's verification level. Tests enforce it: a Working claim must carry substantive evidence, and no device may claim to work while relying on a placeholder GPIO line.

This README therefore advertises fewer working devices than the board has. That is the intended behaviour, not an oversight.

The rule earned its keep three times during bring-up — it caught a device claiming to work on a pin number nobody had written down, and twice stopped a display being marked working because a command exited zero.

Quick start

git clone https://github.com/ruvnet/rultra && cd rultra
cargo test                    # runs anywhere — mock backend, no hardware required
./scripts/build-pi.sh         # cross-compile to aarch64
scp target/aarch64-unknown-linux-gnu/release/rultra-sense pi@raspberrypi:/tmp/

On the Pi:

sudo install -m0755 /tmp/rultra-sense /usr/local/bin/
rultra-sense inventory        # the catalog, with evidence
sudo rultra-sense probe       # what answers right now
sudo rultra-sense stream      # JSON-Lines telemetry
$ sudo rultra-sense probe
{"device":"light","responding":true,"verification":"working","detail":"ACK at 0x5c"}
{"device":"matrix","responding":true,"verification":"working","detail":"/dev/spidev0.1 present (SPI cannot confirm a peer)"}
{"device":"lcd","responding":true,"verification":"acks_but_silent","detail":"ACK at 0x21"}

$ sudo rultra-sense stream 1000
{"device":"light","at":1789166455,"value":{"kind":"scalar","n":37.5,"unit":"lux"},"verification":"working"}
{"device":"cpu_temp","at":1789166455,"value":{"kind":"scalar","n":57.3,"unit":"celsius"},"verification":"working"}

Drive the outputs:

sudo rultra-sense matrix heart
sudo rultra-sense matrix scroll "58 LUX 57C"
sudo rultra-sense lcd write "rultra online" "matrix: OK"

The console

rultra-ui is a management console in the same design language, served by a Rust backend over the very same crates the CLI uses — /api/cycle shells out to the rultra binary rather than reimplementing the loop, so the console and the CLI cannot drift into two different answers about what the box did.

The rultra console showing the witness page: a signed, hash-chained causal record of a thermal proposal being gated and rolled back

The Witness page, reading a real chain off the box: the die hit 83.6 °C, a backoff was proposed, the gate refused it, and the change was rolled back.

Five sections — Overview, Devices, Loop, Witness, Hardware — each with its own URL, so pages are linkable and the browser's back button works.

It is fully navigable from the keyboard, not as an afterthought:

Key Action
← → ↑ ↓ Move between cards — grid-aware, so it follows the layout as it reflows
enter / space Activate the focused card
1–5 Jump to a section · j/k to cycle
backspace Back · r refresh · c run a cycle · ? all keys

Mouse works everywhere too, every control has a visible focus ring, and the whole UI honours prefers-reduced-motion.

The governed loop

   observe  ──▶  score  ──▶  gate  ──▶  promote  ──▶  (or roll back)
      │            │          │           │                │
      └────────────┴──────────┴───────────┴────────────────┘
                       one signed witness chain

autogenous owns the gate: typed mutations, an authority ceiling no descendant may exceed, and an AND-gate with min semantics — so a strong score in one dimension can never offset a safety or governance failure. Promotion additionally requires that a rollback was actually executed and verified, not merely available.

rultra owns the translation on either side: turning sensor readings into a typed mutation, and turning a promoted mutation into a change on a real box.

Crate Responsibility Tests
rultra-sense One trait over every device, with verification provenance 15
rultra-score Parent-vs-child scoring → an agl-types FitnessVector 7
rultra-evolve Telemetry → typed mutation; applier with verified rollback 13
rultra-witness One signed, hash-chained audit trail across the loop 11
rultra The binary: runs one governed cycle against real hardware —

Cycles also run unattended — rultra-cycle.timer fires every 15 minutes with a randomized delay, and Persistent=true so a missed window runs on resume rather than being silently skipped. The console's Loop page shows whether the timer is live and what the box last decided.

$ sudo rultra cycle          # one full observe → score → gate → promote cycle
$ sudo rultra chain          # the signed causal record
$ sudo rultra policy         # what is in force right now

The first mutation surface is thermal headroom versus sensor poll rate — chosen because it is measurable today: this board's get_throttled has already returned 0x80000 (bit 19, a soft temperature limit has occurred). The controller backs off multiplicatively when hot and advances additively when cool: fast to retreat, slow to advance, so the box settles rather than oscillates. A window under 10 samples produces no proposal at all.

Two traps, documented so you don't lose an evening

1. Your SPI device is silently running at 125 MHz.

spidev falls back to the devicetree spi-max-frequency whenever a speed is never set explicitly. On a Raspberry Pi that is 125000000 — 12.5× the MAX7219's 10 MHz ceiling. It fails silently and identically on every chip-select, so sweeping CE0/CE1 looks like a wiring fault and sends you hunting the wrong thing entirely. Always set the speed per transfer.

2. RUSTFLAGS in the environment overrides .cargo/config.toml — it does not merge.

A host-wide -C link-arg=-fuse-ld=mold leaks into an aarch64 cross-link and fails as collect2: fatal error: cannot find 'ld' — a message that sends you hunting for a missing linker that is, in fact, installed. scripts/build-pi.sh clears it; that is the only fix that survives any host shell configuration.

Bonus, CrowPi-specific. The vendor manual transposes the BCM numbers between its two SPI rows: the matrix is on physical pin 26 = BCM GPIO7 = CE1 (/dev/spidev0.1), not GPIO26. BCM GPIO26 is a button line. And the LCD is an MCP23008 expander, not the common PCF8574 backpack — PCF8574-style writes land in its IODIR/GPIO registers, which is exactly why it acknowledges and appears to invert on readback while displaying nothing.

Build

Requires Rust 1.74+. Tests use a mock backend by default, so CI and development machines need no hardware — if a test ever needs a real device to pass, the abstraction has leaked.

cargo test                                   # mock backend
cargo build --features rultra-sense/hardware # real I²C/SPI/GPIO, Linux only
./scripts/build-pi.sh                        # aarch64 cross-build

CI runs cargo fmt --check, clippy -D warnings, the test suite with no hardware, an aarch64 cross-build, and cargo audit.

What a real run looks like

Heated to 83.6 °C with four busy cores, the box proposed backing off, applied it, measured, and the gate refused:

$ sudo rultra cycle 10
{
  "decision": "rolled_back",
  "from": { "poll_interval_ms": 1000 },
  "to":   { "poll_interval_ms": 2000 },
  "reason": "delta_ci=[-0.500,-0.500] beats_parent=false gates=false safety=0.00 rollback_verified=true",
  "witness_entries": 5,
  "witness_verified": true
}

That run found a real flaw in the design — the objective made the controller's own safety action structurally unpromotable, and every unit test passed both before and after the fix. ADR-0005 is the writeup. It is the clearest argument in this repo for running a control loop on physical hardware rather than a simulation.

The witness chain is continuous across separate invocations, so the record survives restarts:

seq  0  observed    {"die_temp_c": 83.645, "read_error_rate": 0.0, "samples": 10}
seq  1  proposed    {"mutation_id": "poll-2000-...", "parent_genome_hash": "371e17b2..."}
seq  2  observed    {"die_temp_c": 83.590, ...}
seq  3  gated       {"passed": false, "reason": "delta_ci=[-0.500,-0.500] ..."}
seq  4  rolled_back {"restored_hash": "371e17b2...", "verified": true}

Security

The console is a control surface, not a dashboard — it can run a governed cycle and drive hardware. Two rules follow, both enforced in code:

  • Loopback by default. rultra-ui binds 127.0.0.1 unless told otherwise. Reaching it from another machine is a deliberate act: an SSH tunnel, or an explicit RULTRA_UI_BIND.
  • A non-loopback bind requires a token. Without RULTRA_UI_TOKEN, the binary refuses to start on a public address rather than logging a warning — a warning in a log nobody reads is not a control.
$ RULTRA_UI_BIND=0.0.0.0 rultra-ui
Error: refusing to bind 0.0.0.0 without authentication: this console can run
cycles and drive hardware. Set RULTRA_UI_TOKEN, or bind loopback and reach it
over an SSH tunnel.

Watching and driving are separate capabilities. A second, independent read-only token (RULTRA_UI_READ_TOKEN) grants telemetry, the device catalog and the witness chain, and carries no control authority — it cannot run a cycle or drive hardware. Handing someone a dashboard should not hand them the box. The two tokens are independent rather than derived, because a read-only credential computed from the control credential is one bug away from being a control credential. Setting a read token without a control token is refused at startup: it would imply a separation that does not exist while leaving writes open.

This follows the admission model in cognitum-one/cognitum-media (ADR-0004), which issues independent control and listener capabilities for the same reason.

The token is compared in constant time, accepted only from Authorization: Bearer or X-Rultra-Token, and never from a query parameter — a token in a URL lands in server logs, browser history and Referer headers. The browser keeps it in sessionStorage, so it dies with the tab.

Honest status

This is a working research prototype, not a product.

  • "Self-optimizing" means something specific here. A systemd timer runs a governed cycle every 15 minutes, so the box does improve itself unattended — but only within the one mutation surface it has (poll interval against thermal headroom), and only through the hard gate. It is not evolving its own code. MetaHarness-style evolution assumes a sandbox and a git tree, which is a CI workflow rather than something running in a field.
  • autogenous's generic types are unproven outside the security domain. Every shipped upstream example is a security antibody package. Repurposing them for device policy looks sound but is unproven — see ADR-0004.
  • The witness chain does not detect tail truncation without an external anchor, and offers no defence against an attacker holding the signing key. Both limits are stated in the module docs rather than implied away.

Architecture decisions

  • ADR-0001 — what rultra is, and what it refuses to be
  • ADR-0002 — one sensing surface, verification in the type system
  • ADR-0003 — composing autogenous, ruvector, MetaHarness and ruflo
  • ADR-0004 — mapping device policy onto AGL mutation scopes
  • ADR-0005 — fitness must encode sustainability, not raw throughput
  • ADR-0006 — repeatable is not the same as correct

Contributing

Built in public on the ruvnet swarm. CONTRIBUTING.md has the full on-ramp — generating your key, claiming work on pub:claims, and the three protocol gotchas that each cost an hour.

Good first tasks are the honest gaps above:

  • Pin the button and tilt GPIO lines — currently Untested with placeholder numbers, and a test stops them claiming otherwise.
  • Get the LCD displaying — the driver is written; it has not yet been seen working.
  • Add sensors — PIR, ultrasonic, sound, keypad, relay and buzzer are all on the board and absent from the catalog.

House rules: Rust only, evidence over intent, and tests must pass with no hardware attached.

License

MIT © ruvnet