phoxal 0.41.0

Phoxal - production-oriented autonomous robot framework: the runtime engine and model (the api contract tree lives in phoxal-api, the typed bus in phoxal-bus).
Documentation

phoxal

A production-oriented framework for autonomous robots, shipped as one crate.

Phoxal gives a robot a small, strongly-typed core: a contract bus over Zenoh, version-qualified contracts, and a derive-based participant authoring model. The framework owns the awkward parts - argument parsing, bus connection, scheduling, query serving, shutdown, and health - so the code you write is the robot's behavior, not its plumbing.

[dependencies]
phoxal = "=0.36.0"

The authoring model

A participant is a Config struct, an Api struct of typed bus handles, a state struct, and one annotated inherent impl. The Api struct declares the contracts it uses (as handle fields); the impl declares the lifecycle.

use phoxal::{api, prelude::*};

#[derive(serde::Deserialize, phoxal::Config)]
struct Config {
    cruise_linear_x_mps: f32,
}

#[derive(phoxal::Api)]
struct Api {
    state:  Latest<api::drive::State>,
    target: Publisher<api::drive::Target>,
}

#[phoxal::service(id = "avoid-obstacles")]
struct AvoidObstacles {
    cruise_linear_x_mps: f32,
}

#[phoxal::behavior]
impl AvoidObstacles {
    #[setup]
    async fn setup(ctx: &mut SetupContext<Self>, config: Self::Config) -> Result<(Self, Self::Api)> {
        Ok((
            Self { cruise_linear_x_mps: config.cruise_linear_x_mps },
            Self::Api {
                state:  ctx.latest(api::topic::new().drive().state()).await?,
                target: ctx.publisher(api::topic::new().drive().target()).await?,
            },
        ))
    }

    #[step(hz = 50)]
    async fn step(&mut self, api: &mut Self::Api, step: StepContext) -> Result<()> {
        // Read inputs and publish version-local bodies. `drive/target` is a
        // command, so it carries no production instant at all; a state or
        // measurement publish instead takes `&step`, which is the only proof
        // this participant reached the instant it is stamping.
        api.target.send(api::drive::Target {
            linear_x_mps: self.cruise_linear_x_mps,
            angular_z_radps: 0.0,
            curvature_limit_radpm: None,
        })?;
        Ok(())
    }
}

fn main() -> phoxal::Result<()> { phoxal::run::<AvoidObstacles>() }

Key rules the example shows:

  • Import phoxal::{api, prelude::*}. The locked framework train selects the complete concrete API behind the facade; official participants do not mix revisions field by field.
  • Handle fields use version-qualified bodies: Publisher<api::drive::Target>, Latest<api::drive::State>. #[derive(phoxal::Api)] requires every field to name a ContractBody, so a non-contract type is a compile error.
  • Topics are api-local: api::topic::new().drive().state().
  • The wire body is the plain payload, and contract identity lives only in the version-qualified topic key. Bus metadata carries the codec and provenance - the producing process, its sample sequence, and the robot instant the content was produced at when the sample expresses robot time at all - but no API version or contract family. Normal participants never open Zenoh - the runner opens the launch-selected bus profile before #[setup].
  • config is for user participants only. Official participants take no config param and read the robot model through ctx.robot().

The runner also owns the rest of the lifecycle: #[step(hz = ...)] is the scheduled control loop, optional #[reset] clears prior simulation-execution state, #[server] / #[server_snapshot] serve queries, and #[shutdown] runs graceful park/stop/flush before the bus closes. #[reset] is serialized with steps and exclusive servers and receives ResetContext. Clockless operator input needs no marker to survive it: a command carries no production instant, so it belongs to no timeline, and what bounds it is its Lease rather than the world boundary.

Every process carries a ProducerId - minted by the supervisor and passed through the launch contract, or minted by the process itself for an ad hoc run. It rides bus metadata and forms the participant's exact Liveliness key, <robot-root>/liveliness/participants/<participant-id>/<producer-id>. Observers use that exact key for spawn readiness and aggregate all live producers of a participant id when they need stable present/not-present state. Because a fresh process is always a fresh producer whose sequence restarts at zero, a restart is structurally distinguishable from a replay - there is no sequence-reset rule to get wrong. It measures handler duration/lateness/missed ticks plus the exact version-qualified typed-bus buffers declared by the participant and emits one bounded portable runtime-performance rollup per host-monotonic grid interval. The setup-declared rows persist for the process lifetime; interval counters are best-effort boundary samples rather than a transactional queue snapshot. A participant writes no telemetry code, unscheduled participants report step timing as not applicable, and no per-process CPU/RSS sampler is involved.

#[phoxal::tool] is intentionally outside the robot clock. Tools use managed host/event loops and LocalInstant - the host's suspend-aware boot clock - for cadence and freshness. They express no robot time: a tool's publications are commands and diagnostics, which carry no production instant. The macro rejects #[step], the setup context exposes no clock, and the tool process launch has no clock option. The normal embedding API likewise accepts no clock argument; deterministic clock injection is restricted to typed graph participants. Official tool sources are additionally checked against importing or subscribing to simulation-clock surfaces; user-authored tools must preserve the same boundary when using the privileged raw bus. A service that consumes asynchronous tool input keeps a bounded latest value with consumer-local monotonic arrival time and samples it at its own logical step.

Modules

A participant depends on the phoxal facade.

Module What it is
[phoxal::api] Alias of the train-selected phoxal_api::latest concrete revision. phoxal-api retains concrete modules such as v0_1 for compatibility adapters.
[phoxal::prelude] Everything a participant author imports with use phoxal::prelude::*;: the handle types, SetupContext/StepContext, and Result.
[phoxal::bus] Re-export of the phoxal-bus crate: the key scheme <namespace>/robots/<robot-id>/<version-qualified-topic>, the named-field MessagePack codec, provenance-only BusMetadata, and the body-typed handles Publisher/Subscriber/Latest/Querier.
[phoxal::participant] The static metadata traits the macros target, SetupContext/StepContext/ShutdownContext, the clock (RealClock/TestClock) + scheduler, ParticipantLaunch, and the runner (run / tokio::run).
[phoxal::model] Authored manifest schemas (robot.yaml, structure.urdf, component.yaml, …).

Authoring the API tree

The phoxal_api modules (in the phoxal-api crate) are generated by phoxal_api_tree! from a tree of nested nodes. A node is name { … } (static) or name(var) { … } (dynamic, where var is a key segment filled at build time), nestable to any depth. A node body holds any mix of struct/enum type declarations, topic declarations, and child nodes. Each topic declares a role: topic <leaf>: command <Body>; (a control input the owning service subscribes), topic <leaf>: state <Body>; (telemetry the owning service publishes), or topic <leaf>: query <Req> => <Resp>; (request/response). command and state are both pub/sub on the wire; the role selects the side brand of the generated builders (L1), so the public client builder (api::topic::new()) and the internal owner builder (api::topic::internal::new(cap)) return different branded topics (Publish/Subscribe) and taking the wrong side does not compile. The owner builder additionally requires the runner-minted OwnerCap (L2): pass ctx.owner_capability(), so owning a topic is a deliberate, capability-gated opt-in rather than something that can happen by accident. There are no key-template strings and no topic parameter lists; dynamism comes entirely from the (var) nodes on the path.

phoxal_api_tree! {
    version v0_1 {
        drive {                                  // static node
            struct Target { linear_x_mps: f32, angular_z_radps: f32 }
            topic target: command Target;        // key v0.1/drive/target
            struct State { /**/ }
            topic state: state State;            // owner-published telemetry
        }
        component(instance) {                    // literal "component" + var {instance}
            motor(capability) {                  // literal "motor"     + var {capability}
                enum Command { Velocity(f32), Torque(f32), Stop }
                topic command: command Command;
            }
        }
    }
    latest v0_1;
}

Everything a topic exposes is derived from the node path n1 … nk to its leaf (each ni has a name and an optional var):

  • Topic key (ContractBody::TOPIC, version-qualified): the v<major>_<minor> module is the first segment; each node then emits name, or name/{var} for a dynamic node, joined by /, followed by /<leaf>. So drivetarget is v0.1/drive/target, and component(instance)motor(capability)command is v0.1/component/{instance}/motor/{capability}/command.
  • Module path / type location: each node becomes a nested pub mod name, and a node's types + their ContractBody impls live in that module. Variables never appear in the module path, so the body above is phoxal_api::v0_1::component::motor::Command.
  • Contract identity (ContractBody::NAME): the version-qualified Rust path, e.g. v0.1::drive::Target or v0.1::component::motor::Command.
  • Builder: api::topic::new().n1(var?)…nk(var?).leaf(). A dynamic node's method takes its var as impl Display (* / ** stay valid for subscribe); the leaf method yields the typed Topic. So api::topic::new().component("front_left").motor("drive").command() builds the key v0.1/component/front_left/motor/drive/command.

Each topic node is self-contained: it declares its own request, response, and state bodies, with no super:: and no shared/common module. This is required because one body has one ContractBody identity and therefore one topic. The narrow exception is a plain, non-topic protocol value declared by a parent node and referenced from children through an absolute crate path. For example, v0.1::tool::Cursor carries the same retention position in the separate tool::log and tool::bus protocols, while each child still owns its distinct SnapshotRequest, Snapshot, and Follow topic bodies. Because the node path disambiguates, topic-body names are path-local - component::imu::Sample and component::range::Sample are distinct types that may safely repeat field names. Duplicating an identical topic body across sibling nodes is intentional, not a smell.

Published concrete revisions are immutable. A new revision uses one-parent extends; unchanged definitions are materialized under the child's concrete identity, while replace and remove make breaking deltas explicit. Exactly one final latest <revision>; selects the facade for the train.

Entrypoints

  • Default (blocking): fn main() -> phoxal::Result<()> { phoxal::run::<R>() }.
  • Advanced (async): phoxal::tokio::run::<R>().await for custom Tokio mains.

Official service set

The platform participant set ships alongside this crate in the workspace service/ tree (drive, motion, navigation, localize, map, …). They are full official participants authored on exactly this surface, and they double as worked reference reading. Neutral teaching examples live in phoxal/examples/ (runtime_control_loop, runtime_query_server, runtime_snapshot_server, runtime_async_entrypoint).

Status

Pre-1.0, building in public. There is no per-participant API version ceiling; the wire body is the plain payload and the version identity rides the version-qualified key. Compatibility is checked at build/check time (compiled-in artifact metadata + phoxal-cli check), never by introspecting a running binary.

License

AGPL-3.0-only. A commercial license is available - see the repository.