From 276eede746662b93b39753e3a91283b2a7329049 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 1 Oct 2026 16:19:00 -0700 Subject: [PATCH 1/4] feat(msgs): add EpisodeStatus built-in authoring sanity check --- examples/episode_status/README.md | 118 +++++++++ examples/episode_status/cpp/CMakeLists.txt | 5 + examples/episode_status/cpp/relay.cpp | 50 ++++ .../episode_status/demo_episode_status.py | 166 +++++++++++++ examples/episode_status/rust/Cargo.lock | 151 ++++++++++++ examples/episode_status/rust/Cargo.toml | 8 + examples/episode_status/rust/src/main.rs | 45 ++++ .../episode_status/test_episode_status.py | 227 ++++++++++++++++++ .../schemas/dimos_msgs/msg/EpisodeStatus.msg | 12 + .../schemas/dimos_msgs/msg/EpisodeStatus.msg | 12 + .../schemas/dimos_msgs/msg/EpisodeStatus.msg | 12 + 11 files changed, 806 insertions(+) create mode 100644 examples/episode_status/README.md create mode 100644 examples/episode_status/cpp/CMakeLists.txt create mode 100644 examples/episode_status/cpp/relay.cpp create mode 100644 examples/episode_status/demo_episode_status.py create mode 100644 examples/episode_status/rust/Cargo.lock create mode 100644 examples/episode_status/rust/Cargo.toml create mode 100644 examples/episode_status/rust/src/main.rs create mode 100644 examples/episode_status/test_episode_status.py create mode 100644 packages/dimos-generated/src/dimos_generated_schemas/package/rust/schemas/dimos_msgs/msg/EpisodeStatus.msg create mode 100644 packages/dimos-generated/src/dimos_generated_schemas/package/schemas/dimos_msgs/msg/EpisodeStatus.msg create mode 100644 packages/dimos-generated/src/dimos_generated_schemas/schemas/dimos_msgs/msg/EpisodeStatus.msg diff --git a/examples/episode_status/README.md b/examples/episode_status/README.md new file mode 100644 index 00000000000..eabafb09357 --- /dev/null +++ b/examples/episode_status/README.md @@ -0,0 +1,118 @@ +# Add EpisodeStatus as a built-in CDR message + +This sanity check adds one message to the CDR proposal. It does not migrate the +robot-learning collector or change its JSON transport. The source is the +[internal model in PR4343](https://github.com/dimensionalOS/dimos/blob/ef5e5f2710c482fb1d43d43ec50fd73b0fbe1dde/dimos/imitation/collection/episode.py). + +## Add the definition, generate, import + +Prepare the existing checkout once (Python 3.12, uv, Ruff 0.14.3 and Rust 1.92.0 +with rustfmt; native checks additionally need their toolchains/Fast CDR): + +```sh +uv sync --group tests --group message-codegen --frozen +``` + +Add `dimos/message_codegen/schemas/dimos_msgs/msg/EpisodeStatus.msg`: + +```text +float64 ts +string state +int64 episodes_saved +int64 episodes_discarded +string last_event "init" +string[<=1] task_label +``` + +Run the same two commands as the built-in tutorial, without type/output flags: + +```sh +uv run python -m scripts.generate_builtin_messages +uv run python -m scripts.generate_builtin_messages --check +``` + +Commit the definition **and** generated files in `packages/dimos-generated/src`. +The editable checkout already consumes this source; no message wheel/native +extension rebuild is needed before a new Python process imports the type: + +```python +from dimos_generated.dimos_msgs.msg import EpisodeStatus + +status = EpisodeStatus(ts=17.25, state="recording", episodes_saved=2, + episodes_discarded=1, last_event="start", task_label=[""]) +assert EpisodeStatus.decode(status.encode()) == status +``` + +A real typed module uses the normal stream API. This is the actual example's +inspector, with domain validation kept outside the generated value class: + +```python +from dimos.core.module import Module +from dimos.core.stream import In, Out +from dimos_generated.dimos_msgs.msg import EpisodeStatus +from examples.episode_status.demo_episode_status import validate_status + +class EpisodeInspector(Module): + status: In[EpisodeStatus] + observed: Out[EpisodeStatus] + + async def handle_status(self, status: EpisodeStatus) -> None: + validate_status(status) + self.observed.publish(status) +``` + +Run the finite two-worker exchange over an ephemeral loopback Zenoh router: + +```sh +uv run python examples/episode_status/demo_episode_status.py +``` + +It prints `PASS: typed EpisodeStatus state=recording, empty label preserved` +and writes `build/episode-status/status.mcap`. The recording embeds the complete +`ros2msg` schema and raw CDR; source and log timestamps are recorded separately. +The example reports tuning needs without applying host/network configuration. +There is no robot, controller or collection state machine in this example. + +## Semantic mapping + +| Source model | CDR value / application behavior | +|---|---| +| Required `ts: FiniteFloat` | `float64 ts`; the example validator rejects NaN/infinity. Seconds remain a float, without adding a Header or quantizing the payload. MCAP metadata uses rounded nonnegative uint64 nanoseconds. | +| Required `state: Literal["idle", "recording"]` | String spelling retained; the application validator enforces the two values. `.msg` strings do not encode a Python Literal constraint. | +| Required Python integer counters | Signed `int64`, including negative values allowed by the source model. Wire range is −2^63…2^63−1; arbitrary-size Python integers outside it cannot be represented. | +| `last_event`, default `"init"` | Same default and string spellings `start/save/discard/init`; application validation enforces the set. | +| `task_label: str \| None = None` | Bounded string sequence: `[]` means None, `[""]` means an explicitly empty label, `[label]` means a nonempty label. Construction/validation/codecs enforce at most one element; mutation can be temporarily invalid until validation. | +| Required-field presence / Pydantic coercion | The example-only `episode_status(...)` factory requires the four required inputs. Plain generated values have normal `.msg` zero/empty defaults and do not reproduce Pydantic input coercion. `EpisodeStatus()` therefore needs valid domain fields before application use. | +| JSON document `schema_version: 1` | This is added by `to_json()`, not an internal-model field. It is not added to the CDR value and no JSON recording compatibility is claimed. | + +The factory/validator are example application code, not new framework APIs. +Native users must also apply domain rules where needed; generated codecs enforce +wire types/bounds, not this model's finite/enum semantics. + +## Native package consumers and codec checks + +With Fast CDR 2.4.0 prepared as in the existing message toolchain setup, build the +normal native artifacts, then compile consumers through their exported package: + +```sh +bash scripts/package_messages.sh +cmake -S examples/episode_status/cpp -B build/episode-status/cpp \ + -DCMAKE_PREFIX_PATH="$PWD/build/message-codegen/release/install;$PWD/build/message-codegen/install" +cmake --build build/episode-status/cpp --parallel 2 +cargo build --locked --manifest-path examples/episode_status/rust/Cargo.toml +DIMOS_EPISODE_NATIVE_REQUIRED=1 uv run python -m pytest -c /dev/null \ + --rootdir . --noconftest -q examples/episode_status/test_episode_status.py +``` + +The C++ consumer uses `find_package(dimos_generated CONFIG REQUIRED)`, links +`dimos_generated::messages`, and includes ``. +The Rust consumer's ordinary Cargo path dependency selects the committed +`dimos-generated-messages` crate; it does not run another generator. Both read, +write and construct `EpisodeStatus` using the generated types. + +The 42 focused checks cover all nine Python/C++/Rust encoder/decoder pairs in +both byte orders; optional None/empty/nonempty labels; signed counters and +out-of-range/bounded-sequence rejection; domain constraints; and independent +MCAP decoding from its embedded schema. CI compiles these consumers, requires +all native cases, runs the actual worker exchange and retains its MCAP. +No generator/build implementation was changed for this message. diff --git a/examples/episode_status/cpp/CMakeLists.txt b/examples/episode_status/cpp/CMakeLists.txt new file mode 100644 index 00000000000..cd3caf0e29b --- /dev/null +++ b/examples/episode_status/cpp/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.20) +project(episode_status_consumer LANGUAGES CXX) +find_package(dimos_generated CONFIG REQUIRED) +add_executable(episode-status relay.cpp) +target_link_libraries(episode-status PRIVATE dimos_generated::messages) diff --git a/examples/episode_status/cpp/relay.cpp b/examples/episode_status/cpp/relay.cpp new file mode 100644 index 00000000000..83af7dc63a4 --- /dev/null +++ b/examples/episode_status/cpp/relay.cpp @@ -0,0 +1,50 @@ +// Copyright 2026 Dimensional Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc != 4) return 2; + try { + const std::string mode(argv[1]); + dimos_msgs::msg::EpisodeStatus status{}; + if (mode == "seed" || mode == "seed-be") { + status.ts = 17.25; + status.state = "recording"; + status.episodes_saved = 2; + status.episodes_discarded = 1; + status.last_event = "start"; + status.task_label = {""}; + } else if (mode == "echo" || mode == "echo-be") { + std::ifstream input(argv[2], std::ios::binary); + if (!input) throw std::runtime_error("Cannot open input"); + const std::vector bytes{std::istreambuf_iterator(input), {}}; + status = dimos::cdr::decode(bytes); + } else { + throw std::runtime_error("Expected seed, seed-be, echo or echo-be"); + } + const auto bytes = dimos::cdr::encode(status, mode != "seed-be" && mode != "echo-be"); + std::ofstream output(argv[3], std::ios::binary); + output.write(reinterpret_cast(bytes.data()), bytes.size()); + if (!output) throw std::runtime_error("Cannot write output"); + std::cout << "C++ EpisodeStatus: " << status.state << ", labels=" << status.task_label.size() << '\n'; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/examples/episode_status/demo_episode_status.py b/examples/episode_status/demo_episode_status.py new file mode 100644 index 00000000000..7390944f5df --- /dev/null +++ b/examples/episode_status/demo_episode_status.py @@ -0,0 +1,166 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A finite built-in EpisodeStatus exchange over typed Python workers and Zenoh.""" + +from contextlib import ExitStack +from functools import partial +import math +from pathlib import Path +import socket +import threading +from typing import Literal +from unittest.mock import patch +import uuid + +from dimos_generated.dimos_msgs.msg import EpisodeStatus + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.coordination.module_coordinator import ModuleCoordinator +from dimos.core.core import rpc +from dimos.core.module import Module +from dimos.core.stream import In, Out +from dimos.protocol.cdr_mcap import CdrMcapWriter +from dimos.protocol.service.system_configurator.base import configure_system +from dimos.protocol.service.zenohservice import ZenohConfig, ZenohSessionPool + + +def validate_status(status: EpisodeStatus) -> None: + """Apply the source model's finite timestamp and Literal-value constraints.""" + if not math.isfinite(status.ts): + raise ValueError("EpisodeStatus.ts must be finite") + if status.state not in ("idle", "recording"): + raise ValueError("EpisodeStatus.state must be idle or recording") + if status.last_event not in ("start", "save", "discard", "init"): + raise ValueError("EpisodeStatus.last_event must be start, save, discard or init") + status.validate() + + +def episode_status( + *, + ts: float, + state: Literal["idle", "recording"], + episodes_saved: int, + episodes_discarded: int, + last_event: Literal["start", "save", "discard", "init"] = "init", + task_label: str | None = None, +) -> EpisodeStatus: + """Construct the wire value, preserving required fields and nullable labels.""" + status = EpisodeStatus( + ts=ts, + state=state, + episodes_saved=episodes_saved, + episodes_discarded=episodes_discarded, + last_event=last_event, + task_label=[] if task_label is None else [task_label], + ) + validate_status(status) + return status + + +class EpisodeExchange(Module): + status: Out[EpisodeStatus] + observed: In[EpisodeStatus] + + @rpc + def start(self) -> None: + self.received: EpisodeStatus | None = None + self.done = threading.Event() + super().start() + + async def handle_observed(self, status: EpisodeStatus) -> None: + self.received = status + self.done.set() + + @rpc + def exchange(self) -> EpisodeStatus: + status = episode_status( + ts=17.25, + state="recording", + episodes_saved=2, + episodes_discarded=1, + last_event="start", + task_label="", + ) + for _ in range(60): + self.status.publish(status) + if self.done.wait(0.25): + break + if self.received is None: + raise TimeoutError("No typed EpisodeStatus reply") + if self.received.encode() != status.encode(): + raise ValueError("EpisodeStatus fields changed across the typed transport") + return self.received + + +class EpisodeInspector(Module): + status: In[EpisodeStatus] + observed: Out[EpisodeStatus] + + async def handle_status(self, status: EpisodeStatus) -> None: + validate_status(status) + self.observed.publish(status) + + +def record_status(path: Path, status: EpisodeStatus) -> None: + validate_status(status) + source_ns = round(status.ts * 1_000_000_000) + with CdrMcapWriter(path) as writer: + writer.write( + "/episode/status", + status.encode(), + schema_name=EpisodeStatus.msg_name, + schema=EpisodeStatus.schema, + log_time_ns=source_ns + 1, + publish_time_ns=source_ns, + ) + + +def main() -> None: + with ExitStack() as stack: + # Report tuning needs without applying host/system/network configuration. + stack.enter_context( + patch( + "dimos.protocol.service.system_configurator.base.configure_system", + partial(configure_system, check_only=True), + ) + ) + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + endpoint = f"tcp/127.0.0.1:{reservation.getsockname()[1]}" + pool = ZenohSessionPool() + stack.callback(pool.close_all) + pool.acquire( + ZenohConfig(mode="router", listen=[endpoint], connect=[], multicast=False, gossip=False) + ) + blueprint = autoconnect(EpisodeExchange.blueprint(), EpisodeInspector.blueprint()) + configured = blueprint.namespace("episode" + uuid.uuid4().hex[:8]).global_config( + viewer="none", + n_workers=2, + transport="zenoh", + zenoh_mode="client", + zenoh_connect=endpoint, + zenoh_multicast=False, + ) + coordinator = ModuleCoordinator.build(configured) + stack.callback(coordinator.stop) + status = coordinator.get_instance(EpisodeExchange).exchange() + path = Path("build/episode-status/status.mcap") + path.parent.mkdir(parents=True, exist_ok=True) + record_status(path, status) + print(f"PASS: typed EpisodeStatus state={status.state}, empty label preserved; {path}") + + +if __name__ == "__main__": + main() diff --git a/examples/episode_status/rust/Cargo.lock b/examples/episode_status/rust/Cargo.lock new file mode 100644 index 00000000000..b476846c266 --- /dev/null +++ b/examples/episode_status/rust/Cargo.lock @@ -0,0 +1,151 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "dimos-generated-messages" +version = "0.1.0" +dependencies = [ + "re_cdr", + "serde", + "serde-big-array", +] + +[[package]] +name = "episode-status-consumer" +version = "0.1.0" +dependencies = [ + "dimos-generated-messages", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "re_cdr" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bba10c21433b597481f366b978cdf2617e56785b38cb0dcc3833c6b8ad7e061a" +dependencies = [ + "bytemuck", + "byteorder", + "serde", + "thiserror", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" diff --git a/examples/episode_status/rust/Cargo.toml b/examples/episode_status/rust/Cargo.toml new file mode 100644 index 00000000000..9c3f274e21e --- /dev/null +++ b/examples/episode_status/rust/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "episode-status-consumer" +version = "0.1.0" +edition = "2024" +publish = false +[workspace] +[dependencies] +dimos-generated-messages = { path = "../../../packages/dimos-generated/src/dimos_generated_schemas/package/rust" } diff --git a/examples/episode_status/rust/src/main.rs b/examples/episode_status/rust/src/main.rs new file mode 100644 index 00000000000..acd75dc1483 --- /dev/null +++ b/examples/episode_status/rust/src/main.rs @@ -0,0 +1,45 @@ +// Copyright 2026 Dimensional Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use dimos_generated_messages::{codec::Message, dimos_msgs::msg::EpisodeStatus}; +use std::{env, fs}; + +fn main() -> Result<(), Box> { + let arguments: Vec<_> = env::args().collect(); + if arguments.len() != 4 { + return Err( + "usage: episode-status-consumer ".into(), + ); + } + let status = match arguments[1].as_str() { + "seed" | "seed-be" => EpisodeStatus { + ts: 17.25, + state: "recording".into(), + episodes_saved: 2, + episodes_discarded: 1, + last_event: "start".into(), + task_label: vec!["".into()], + }, + "echo" | "echo-be" => EpisodeStatus::decode(&fs::read(&arguments[2])?)?, + _ => return Err("Expected seed, seed-be, echo or echo-be".into()), + }; + let little = arguments[1] != "seed-be" && arguments[1] != "echo-be"; + fs::write(&arguments[3], status.encode_endian(little)?)?; + println!( + "Rust EpisodeStatus: {}, labels={}", + status.state, + status.task_label.len() + ); + Ok(()) +} diff --git a/examples/episode_status/test_episode_status.py b/examples/episode_status/test_episode_status.py new file mode 100644 index 00000000000..61a9fd497d9 --- /dev/null +++ b/examples/episode_status/test_episode_status.py @@ -0,0 +1,227 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from pathlib import Path +import re +import subprocess +from typing import get_args + +from dimos_generated.dimos_msgs.msg import EpisodeStatus +from mcap.reader import make_reader +import pytest +from rosbags.typesys import Stores, get_types_from_msg, get_typestore + +from examples.episode_status.demo_episode_status import ( + episode_status, + record_status, + validate_status, +) + + +def sample(label=""): + return episode_status( + ts=17.25, + state="recording", + episodes_saved=2, + episodes_discarded=1, + last_event="start", + task_label=label, + ) + + +def test_readme_definition_and_python_blocks_are_copyable(): + root = Path(__file__).resolve().parents[2] + readme = Path(__file__).with_name("README.md").read_text() + definition = ( + root / "dimos/message_codegen/schemas/dimos_msgs/msg/EpisodeStatus.msg" + ).read_text() + fields = "\n".join( + line for line in definition.splitlines() if line and not line.startswith("#") + ) + assert re.search(r"```text\n(.*?)\n```", readme, re.S).group(1) == fields + namespace = {"__name__": "episode_status_documentation"} + for block in re.findall(r"```python\n(.*?)\n```", readme, re.S): + exec(compile(block, "episode-status-README", "exec"), namespace) + assert namespace["status"] == sample() + assert get_args(namespace["EpisodeInspector"].__annotations__["status"])[0] is EpisodeStatus + + +@pytest.fixture +def reference(): + store = get_typestore(Stores.EMPTY) + store.register(get_types_from_msg(EpisodeStatus.schema, EpisodeStatus.msg_name)) + return store + + +@pytest.mark.parametrize("label", [None, "", "pick the cup"]) +@pytest.mark.parametrize("little", [True, False]) +def test_nullable_label_survives_cdr_and_independent_decode(label, little, reference): + status = sample(label) + payload = status.encode(little_endian=little) + decoded = reference.deserialize_cdr(payload, EpisodeStatus.msg_name) + assert (decoded.ts, decoded.state, decoded.episodes_saved, decoded.episodes_discarded) == ( + 17.25, + "recording", + 2, + 1, + ) + assert decoded.last_event == "start" + assert decoded.task_label == ([] if label is None else [label]) + assert ( + bytes(reference.serialize_cdr(decoded, EpisodeStatus.msg_name, little_endian=little)) + == payload + ) + assert EpisodeStatus.decode(payload) == status + + +@pytest.mark.parametrize( + "field,value", + [("ts", float("nan")), ("ts", float("inf")), ("state", "paused"), ("last_event", "stop")], +) +def test_source_domain_validation_rejects_invalid_values(field, value): + status = sample() + setattr(status, field, value) + with pytest.raises(ValueError, match=f"EpisodeStatus.{field}"): + validate_status(status) + + +def test_source_required_fields_and_defaults_are_explicit(): + with pytest.raises(TypeError, match="required keyword-only"): + episode_status(ts=1.0, state="idle") + status = episode_status(ts=1.0, state="idle", episodes_saved=0, episodes_discarded=0) + assert status.last_event == "init" + assert list(status.task_label) == [] + with pytest.raises(ValueError, match="state"): + validate_status(EpisodeStatus()) + + +def test_signed_counter_range_and_optional_bound(reference): + status = sample(None) + status.episodes_saved = -(2**63) + status.episodes_discarded = 2**63 - 1 + decoded = reference.deserialize_cdr(status.encode(), EpisodeStatus.msg_name) + assert (decoded.episodes_saved, decoded.episodes_discarded) == (-(2**63), 2**63 - 1) + with pytest.raises(ValueError): + status.episodes_saved = 2**63 + status.task_label = ["one", "two"] + with pytest.raises(ValueError): + status.encode() + + +@pytest.mark.parametrize("label", [None, "", "pick the cup"]) +def test_mcap_embeds_complete_schema_and_preserves_fields_and_source_timestamp(tmp_path, label): + path = tmp_path / "status.mcap" + status = sample(label) + record_status(path, status) + with path.open("rb") as stream: + reader = make_reader(stream, validate_crcs=True) + assert reader.get_header().profile == "ros2" + rows = list(reader.iter_messages()) + assert len(rows) == 1 + schema, channel, message = rows[0] + assert (schema.name, schema.encoding, channel.message_encoding) == ( + "dimos_msgs/msg/EpisodeStatus", + "ros2msg", + "cdr", + ) + assert (message.publish_time, message.log_time) == (17_250_000_000, 17_250_000_001) + independent = get_typestore(Stores.EMPTY) + independent.register(get_types_from_msg(schema.data.decode(), schema.name)) + decoded = independent.deserialize_cdr(message.data, schema.name) + assert decoded.task_label == ([] if label is None else [label]) + assert decoded.state == "recording" and decoded.last_event == "start" + assert message.data == status.encode() + + +@pytest.fixture(scope="module") +def native_consumers(): + root = Path(__file__).resolve().parents[2] + binaries = { + "cpp": root / "build/episode-status/cpp/episode-status", + "rust": root / "examples/episode_status/rust/target/debug/episode-status-consumer", + } + missing = [name for name, path in binaries.items() if not path.is_file()] + if missing: + reason = f"Build the documented EpisodeStatus native consumers: {missing}" + if os.environ.get("DIMOS_EPISODE_NATIVE_REQUIRED") == "1": + pytest.fail(reason) + pytest.skip(reason) + return binaries + + +@pytest.mark.parametrize("encoder", ["python", "cpp", "rust"]) +@pytest.mark.parametrize("decoder", ["python", "cpp", "rust"]) +@pytest.mark.parametrize("little", [True, False]) +def test_all_nine_codec_pairs_and_both_endians( + tmp_path, native_consumers, encoder, decoder, little +): + source, echoed = tmp_path / "source.cdr", tmp_path / "echo.cdr" + expected = sample().encode(little_endian=little) + if encoder == "python": + source.write_bytes(expected) + else: + subprocess.run( + [ + str(native_consumers[encoder]), + "seed" if little else "seed-be", + str(source), + str(source), + ], + check=True, + ) + assert source.read_bytes() == expected + if decoder == "python": + assert EpisodeStatus.decode(source.read_bytes()) == sample() + else: + subprocess.run( + [ + str(native_consumers[decoder]), + "echo" if little else "echo-be", + str(source), + str(echoed), + ], + check=True, + ) + assert echoed.read_bytes() == expected + + +@pytest.mark.parametrize("decoder", ["cpp", "rust"]) +@pytest.mark.parametrize("label", [None, "", "pick the cup"]) +def test_native_consumers_preserve_optional_labels_and_signed_counters( + tmp_path, native_consumers, decoder, label +): + status = sample(label) + status.episodes_saved = -1 + source, target = tmp_path / "source.cdr", tmp_path / "target.cdr" + source.write_bytes(status.encode()) + subprocess.run([str(native_consumers[decoder]), "echo", str(source), str(target)], check=True) + assert EpisodeStatus.decode(target.read_bytes()) == status + + +@pytest.mark.parametrize("decoder", ["cpp", "rust"]) +def test_native_consumers_reject_optional_sequence_overflow( + tmp_path, native_consumers, reference, decoder +): + value = reference.deserialize_cdr(sample().encode(), EpisodeStatus.msg_name) + value.task_label = ["one", "two"] + source, target = tmp_path / "invalid.cdr", tmp_path / "target.cdr" + source.write_bytes(bytes(reference.serialize_cdr(value, EpisodeStatus.msg_name))) + result = subprocess.run( + [str(native_consumers[decoder]), "echo", str(source), str(target)], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert not target.exists() diff --git a/packages/dimos-generated/src/dimos_generated_schemas/package/rust/schemas/dimos_msgs/msg/EpisodeStatus.msg b/packages/dimos-generated/src/dimos_generated_schemas/package/rust/schemas/dimos_msgs/msg/EpisodeStatus.msg new file mode 100644 index 00000000000..9677e2d096e --- /dev/null +++ b/packages/dimos-generated/src/dimos_generated_schemas/package/rust/schemas/dimos_msgs/msg/EpisodeStatus.msg @@ -0,0 +1,12 @@ +# EpisodeStatus internal model from robot-learning PR4343, ef5e5f2710c482fb1d43d43ec50fd73b0fbe1dde. +# Seconds; application validation requires a finite value. +float64 ts +# Application values: idle, recording. Required by the source model. +string state +# Source Python ints use signed 64-bit wire storage; negative values are retained. +int64 episodes_saved +int64 episodes_discarded +# Application values: start, save, discard, init. +string last_event "init" +# Nullable string: [] means None; [""] preserves an explicitly empty label. +string[<=1] task_label diff --git a/packages/dimos-generated/src/dimos_generated_schemas/package/schemas/dimos_msgs/msg/EpisodeStatus.msg b/packages/dimos-generated/src/dimos_generated_schemas/package/schemas/dimos_msgs/msg/EpisodeStatus.msg new file mode 100644 index 00000000000..9677e2d096e --- /dev/null +++ b/packages/dimos-generated/src/dimos_generated_schemas/package/schemas/dimos_msgs/msg/EpisodeStatus.msg @@ -0,0 +1,12 @@ +# EpisodeStatus internal model from robot-learning PR4343, ef5e5f2710c482fb1d43d43ec50fd73b0fbe1dde. +# Seconds; application validation requires a finite value. +float64 ts +# Application values: idle, recording. Required by the source model. +string state +# Source Python ints use signed 64-bit wire storage; negative values are retained. +int64 episodes_saved +int64 episodes_discarded +# Application values: start, save, discard, init. +string last_event "init" +# Nullable string: [] means None; [""] preserves an explicitly empty label. +string[<=1] task_label diff --git a/packages/dimos-generated/src/dimos_generated_schemas/schemas/dimos_msgs/msg/EpisodeStatus.msg b/packages/dimos-generated/src/dimos_generated_schemas/schemas/dimos_msgs/msg/EpisodeStatus.msg new file mode 100644 index 00000000000..9677e2d096e --- /dev/null +++ b/packages/dimos-generated/src/dimos_generated_schemas/schemas/dimos_msgs/msg/EpisodeStatus.msg @@ -0,0 +1,12 @@ +# EpisodeStatus internal model from robot-learning PR4343, ef5e5f2710c482fb1d43d43ec50fd73b0fbe1dde. +# Seconds; application validation requires a finite value. +float64 ts +# Application values: idle, recording. Required by the source model. +string state +# Source Python ints use signed 64-bit wire storage; negative values are retained. +int64 episodes_saved +int64 episodes_discarded +# Application values: start, save, discard, init. +string last_event "init" +# Nullable string: [] means None; [""] preserves an explicitly empty label. +string[<=1] task_label From d1d6a44b8ae65353ef909924dae4cd269bb71c32 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 1 Oct 2026 16:52:18 -0700 Subject: [PATCH 2/4] fix(ci): provision planner bindings for self-hosted collection --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba22f1c22c1..6a3643459b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1043,6 +1043,13 @@ jobs: - name: Build rust native modules if: contains(matrix.markers, 'self_hosted') run: cargo build --release --locked -p dimos-livox -p dimos-virtual-mid360 + # Collection imports the offline viewer even when its tests are deselected. + # Native executables do not install these Python extension modules. + - name: Build offline planner Python bindings + run: | + source .venv/bin/activate + maturin develop -m dimos/mapping/ray_tracing/rust/py/Cargo.toml + maturin develop -m dimos/navigation/nav_3d/mls_planner/rust/py/Cargo.toml - name: Run tests run: uv run pytest --cov=dimos/ --junitxml=junit.xml -m '${{ matrix.markers }}' - name: Re-run the failing tests with maximum verbosity From 8090dff90abd3bacc5e40b30c4c833e000f45362 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 1 Oct 2026 21:18:47 -0700 Subject: [PATCH 3/4] refactor(msgs): keep EpisodeStatus addition small --- examples/episode_status/README.md | 104 +------- examples/episode_status/cpp/CMakeLists.txt | 5 - examples/episode_status/cpp/relay.cpp | 50 ---- .../episode_status/demo_episode_status.py | 166 ------------- examples/episode_status/rust/Cargo.lock | 151 ------------ examples/episode_status/rust/Cargo.toml | 8 - examples/episode_status/rust/src/main.rs | 45 ---- .../episode_status/test_episode_status.py | 227 ------------------ 8 files changed, 13 insertions(+), 743 deletions(-) delete mode 100644 examples/episode_status/cpp/CMakeLists.txt delete mode 100644 examples/episode_status/cpp/relay.cpp delete mode 100644 examples/episode_status/demo_episode_status.py delete mode 100644 examples/episode_status/rust/Cargo.lock delete mode 100644 examples/episode_status/rust/Cargo.toml delete mode 100644 examples/episode_status/rust/src/main.rs delete mode 100644 examples/episode_status/test_episode_status.py diff --git a/examples/episode_status/README.md b/examples/episode_status/README.md index eabafb09357..b1db62e6018 100644 --- a/examples/episode_status/README.md +++ b/examples/episode_status/README.md @@ -1,17 +1,4 @@ -# Add EpisodeStatus as a built-in CDR message - -This sanity check adds one message to the CDR proposal. It does not migrate the -robot-learning collector or change its JSON transport. The source is the -[internal model in PR4343](https://github.com/dimensionalOS/dimos/blob/ef5e5f2710c482fb1d43d43ec50fd73b0fbe1dde/dimos/imitation/collection/episode.py). - -## Add the definition, generate, import - -Prepare the existing checkout once (Python 3.12, uv, Ruff 0.14.3 and Rust 1.92.0 -with rustfmt; native checks additionally need their toolchains/Fast CDR): - -```sh -uv sync --group tests --group message-codegen --frozen -``` +# EpisodeStatus Add `dimos/message_codegen/schemas/dimos_msgs/msg/EpisodeStatus.msg`: @@ -24,95 +11,30 @@ string last_event "init" string[<=1] task_label ``` -Run the same two commands as the built-in tutorial, without type/output flags: +Generate and commit the definition together with the generated source: ```sh uv run python -m scripts.generate_builtin_messages uv run python -m scripts.generate_builtin_messages --check ``` -Commit the definition **and** generated files in `packages/dimos-generated/src`. -The editable checkout already consumes this source; no message wheel/native -extension rebuild is needed before a new Python process imports the type: +Use the generated Python value from the editable checkout or matching message wheel: ```python from dimos_generated.dimos_msgs.msg import EpisodeStatus status = EpisodeStatus(ts=17.25, state="recording", episodes_saved=2, - episodes_discarded=1, last_event="start", task_label=[""]) + episodes_discarded=1, last_event="start", task_label=["inspection"]) assert EpisodeStatus.decode(status.encode()) == status ``` -A real typed module uses the normal stream API. This is the actual example's -inspector, with domain validation kept outside the generated value class: - -```python -from dimos.core.module import Module -from dimos.core.stream import In, Out -from dimos_generated.dimos_msgs.msg import EpisodeStatus -from examples.episode_status.demo_episode_status import validate_status - -class EpisodeInspector(Module): - status: In[EpisodeStatus] - observed: Out[EpisodeStatus] - - async def handle_status(self, status: EpisodeStatus) -> None: - validate_status(status) - self.observed.publish(status) -``` - -Run the finite two-worker exchange over an ephemeral loopback Zenoh router: - -```sh -uv run python examples/episode_status/demo_episode_status.py -``` - -It prints `PASS: typed EpisodeStatus state=recording, empty label preserved` -and writes `build/episode-status/status.mcap`. The recording embeds the complete -`ros2msg` schema and raw CDR; source and log timestamps are recorded separately. -The example reports tuning needs without applying host/network configuration. -There is no robot, controller or collection state machine in this example. - -## Semantic mapping - -| Source model | CDR value / application behavior | -|---|---| -| Required `ts: FiniteFloat` | `float64 ts`; the example validator rejects NaN/infinity. Seconds remain a float, without adding a Header or quantizing the payload. MCAP metadata uses rounded nonnegative uint64 nanoseconds. | -| Required `state: Literal["idle", "recording"]` | String spelling retained; the application validator enforces the two values. `.msg` strings do not encode a Python Literal constraint. | -| Required Python integer counters | Signed `int64`, including negative values allowed by the source model. Wire range is −2^63…2^63−1; arbitrary-size Python integers outside it cannot be represented. | -| `last_event`, default `"init"` | Same default and string spellings `start/save/discard/init`; application validation enforces the set. | -| `task_label: str \| None = None` | Bounded string sequence: `[]` means None, `[""]` means an explicitly empty label, `[label]` means a nonempty label. Construction/validation/codecs enforce at most one element; mutation can be temporarily invalid until validation. | -| Required-field presence / Pydantic coercion | The example-only `episode_status(...)` factory requires the four required inputs. Plain generated values have normal `.msg` zero/empty defaults and do not reproduce Pydantic input coercion. `EpisodeStatus()` therefore needs valid domain fields before application use. | -| JSON document `schema_version: 1` | This is added by `to_json()`, not an internal-model field. It is not added to the CDR value and no JSON recording compatibility is claimed. | - -The factory/validator are example application code, not new framework APIs. -Native users must also apply domain rules where needed; generated codecs enforce -wire types/bounds, not this model's finite/enum semantics. - -## Native package consumers and codec checks - -With Fast CDR 2.4.0 prepared as in the existing message toolchain setup, build the -normal native artifacts, then compile consumers through their exported package: - -```sh -bash scripts/package_messages.sh -cmake -S examples/episode_status/cpp -B build/episode-status/cpp \ - -DCMAKE_PREFIX_PATH="$PWD/build/message-codegen/release/install;$PWD/build/message-codegen/install" -cmake --build build/episode-status/cpp --parallel 2 -cargo build --locked --manifest-path examples/episode_status/rust/Cargo.toml -DIMOS_EPISODE_NATIVE_REQUIRED=1 uv run python -m pytest -c /dev/null \ - --rootdir . --noconftest -q examples/episode_status/test_episode_status.py -``` - -The C++ consumer uses `find_package(dimos_generated CONFIG REQUIRED)`, links -`dimos_generated::messages`, and includes ``. -The Rust consumer's ordinary Cargo path dependency selects the committed -`dimos-generated-messages` crate; it does not run another generator. Both read, -write and construct `EpisodeStatus` using the generated types. +`ts` is seconds. `task_label=[]` represents None; `[""]` preserves an empty label. +Counters use signed 64-bit storage. Applications enforce finite timestamps and +`state` (`idle/recording`) and `last_event` (`start/save/discard/init`) values. +Generated values have ordinary message defaults, rather than Pydantic's required +input/coercion rules. The source model's JSON `schema_version` envelope is not a +message field; adding this type does not migrate the robot-learning collector. -The 42 focused checks cover all nine Python/C++/Rust encoder/decoder pairs in -both byte orders; optional None/empty/nonempty labels; signed counters and -out-of-range/bounded-sequence rejection; domain constraints; and independent -MCAP decoding from its embedded schema. CI compiles these consumers, requires -all native cases, runs the actual worker exchange and retains its MCAP. -No generator/build implementation was changed for this message. +The fields follow the [EpisodeStatus model](https://github.com/dimensionalOS/dimos/blob/ef5e5f2710c482fb1d43d43ec50fd73b0fbe1dde/dimos/imitation/collection/episode.py). +See the [built-in message guide](/docs/development/messages-in-repository.md) for +checkout prerequisites and the existing package/consumer workflow. diff --git a/examples/episode_status/cpp/CMakeLists.txt b/examples/episode_status/cpp/CMakeLists.txt deleted file mode 100644 index cd3caf0e29b..00000000000 --- a/examples/episode_status/cpp/CMakeLists.txt +++ /dev/null @@ -1,5 +0,0 @@ -cmake_minimum_required(VERSION 3.20) -project(episode_status_consumer LANGUAGES CXX) -find_package(dimos_generated CONFIG REQUIRED) -add_executable(episode-status relay.cpp) -target_link_libraries(episode-status PRIVATE dimos_generated::messages) diff --git a/examples/episode_status/cpp/relay.cpp b/examples/episode_status/cpp/relay.cpp deleted file mode 100644 index 83af7dc63a4..00000000000 --- a/examples/episode_status/cpp/relay.cpp +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2026 Dimensional Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#include -#include -#include -#include -#include - -int main(int argc, char** argv) { - if (argc != 4) return 2; - try { - const std::string mode(argv[1]); - dimos_msgs::msg::EpisodeStatus status{}; - if (mode == "seed" || mode == "seed-be") { - status.ts = 17.25; - status.state = "recording"; - status.episodes_saved = 2; - status.episodes_discarded = 1; - status.last_event = "start"; - status.task_label = {""}; - } else if (mode == "echo" || mode == "echo-be") { - std::ifstream input(argv[2], std::ios::binary); - if (!input) throw std::runtime_error("Cannot open input"); - const std::vector bytes{std::istreambuf_iterator(input), {}}; - status = dimos::cdr::decode(bytes); - } else { - throw std::runtime_error("Expected seed, seed-be, echo or echo-be"); - } - const auto bytes = dimos::cdr::encode(status, mode != "seed-be" && mode != "echo-be"); - std::ofstream output(argv[3], std::ios::binary); - output.write(reinterpret_cast(bytes.data()), bytes.size()); - if (!output) throw std::runtime_error("Cannot write output"); - std::cout << "C++ EpisodeStatus: " << status.state << ", labels=" << status.task_label.size() << '\n'; - } catch (const std::exception& error) { - std::cerr << error.what() << '\n'; - return 1; - } -} diff --git a/examples/episode_status/demo_episode_status.py b/examples/episode_status/demo_episode_status.py deleted file mode 100644 index 7390944f5df..00000000000 --- a/examples/episode_status/demo_episode_status.py +++ /dev/null @@ -1,166 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""A finite built-in EpisodeStatus exchange over typed Python workers and Zenoh.""" - -from contextlib import ExitStack -from functools import partial -import math -from pathlib import Path -import socket -import threading -from typing import Literal -from unittest.mock import patch -import uuid - -from dimos_generated.dimos_msgs.msg import EpisodeStatus - -from dimos.core.coordination.blueprints import autoconnect -from dimos.core.coordination.module_coordinator import ModuleCoordinator -from dimos.core.core import rpc -from dimos.core.module import Module -from dimos.core.stream import In, Out -from dimos.protocol.cdr_mcap import CdrMcapWriter -from dimos.protocol.service.system_configurator.base import configure_system -from dimos.protocol.service.zenohservice import ZenohConfig, ZenohSessionPool - - -def validate_status(status: EpisodeStatus) -> None: - """Apply the source model's finite timestamp and Literal-value constraints.""" - if not math.isfinite(status.ts): - raise ValueError("EpisodeStatus.ts must be finite") - if status.state not in ("idle", "recording"): - raise ValueError("EpisodeStatus.state must be idle or recording") - if status.last_event not in ("start", "save", "discard", "init"): - raise ValueError("EpisodeStatus.last_event must be start, save, discard or init") - status.validate() - - -def episode_status( - *, - ts: float, - state: Literal["idle", "recording"], - episodes_saved: int, - episodes_discarded: int, - last_event: Literal["start", "save", "discard", "init"] = "init", - task_label: str | None = None, -) -> EpisodeStatus: - """Construct the wire value, preserving required fields and nullable labels.""" - status = EpisodeStatus( - ts=ts, - state=state, - episodes_saved=episodes_saved, - episodes_discarded=episodes_discarded, - last_event=last_event, - task_label=[] if task_label is None else [task_label], - ) - validate_status(status) - return status - - -class EpisodeExchange(Module): - status: Out[EpisodeStatus] - observed: In[EpisodeStatus] - - @rpc - def start(self) -> None: - self.received: EpisodeStatus | None = None - self.done = threading.Event() - super().start() - - async def handle_observed(self, status: EpisodeStatus) -> None: - self.received = status - self.done.set() - - @rpc - def exchange(self) -> EpisodeStatus: - status = episode_status( - ts=17.25, - state="recording", - episodes_saved=2, - episodes_discarded=1, - last_event="start", - task_label="", - ) - for _ in range(60): - self.status.publish(status) - if self.done.wait(0.25): - break - if self.received is None: - raise TimeoutError("No typed EpisodeStatus reply") - if self.received.encode() != status.encode(): - raise ValueError("EpisodeStatus fields changed across the typed transport") - return self.received - - -class EpisodeInspector(Module): - status: In[EpisodeStatus] - observed: Out[EpisodeStatus] - - async def handle_status(self, status: EpisodeStatus) -> None: - validate_status(status) - self.observed.publish(status) - - -def record_status(path: Path, status: EpisodeStatus) -> None: - validate_status(status) - source_ns = round(status.ts * 1_000_000_000) - with CdrMcapWriter(path) as writer: - writer.write( - "/episode/status", - status.encode(), - schema_name=EpisodeStatus.msg_name, - schema=EpisodeStatus.schema, - log_time_ns=source_ns + 1, - publish_time_ns=source_ns, - ) - - -def main() -> None: - with ExitStack() as stack: - # Report tuning needs without applying host/system/network configuration. - stack.enter_context( - patch( - "dimos.protocol.service.system_configurator.base.configure_system", - partial(configure_system, check_only=True), - ) - ) - with socket.socket() as reservation: - reservation.bind(("127.0.0.1", 0)) - endpoint = f"tcp/127.0.0.1:{reservation.getsockname()[1]}" - pool = ZenohSessionPool() - stack.callback(pool.close_all) - pool.acquire( - ZenohConfig(mode="router", listen=[endpoint], connect=[], multicast=False, gossip=False) - ) - blueprint = autoconnect(EpisodeExchange.blueprint(), EpisodeInspector.blueprint()) - configured = blueprint.namespace("episode" + uuid.uuid4().hex[:8]).global_config( - viewer="none", - n_workers=2, - transport="zenoh", - zenoh_mode="client", - zenoh_connect=endpoint, - zenoh_multicast=False, - ) - coordinator = ModuleCoordinator.build(configured) - stack.callback(coordinator.stop) - status = coordinator.get_instance(EpisodeExchange).exchange() - path = Path("build/episode-status/status.mcap") - path.parent.mkdir(parents=True, exist_ok=True) - record_status(path, status) - print(f"PASS: typed EpisodeStatus state={status.state}, empty label preserved; {path}") - - -if __name__ == "__main__": - main() diff --git a/examples/episode_status/rust/Cargo.lock b/examples/episode_status/rust/Cargo.lock deleted file mode 100644 index b476846c266..00000000000 --- a/examples/episode_status/rust/Cargo.lock +++ /dev/null @@ -1,151 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "dimos-generated-messages" -version = "0.1.0" -dependencies = [ - "re_cdr", - "serde", - "serde-big-array", -] - -[[package]] -name = "episode-status-consumer" -version = "0.1.0" -dependencies = [ - "dimos-generated-messages", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "re_cdr" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bba10c21433b597481f366b978cdf2617e56785b38cb0dcc3833c6b8ad7e061a" -dependencies = [ - "bytemuck", - "byteorder", - "serde", - "thiserror", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-big-array" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "unicode-ident" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" diff --git a/examples/episode_status/rust/Cargo.toml b/examples/episode_status/rust/Cargo.toml deleted file mode 100644 index 9c3f274e21e..00000000000 --- a/examples/episode_status/rust/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "episode-status-consumer" -version = "0.1.0" -edition = "2024" -publish = false -[workspace] -[dependencies] -dimos-generated-messages = { path = "../../../packages/dimos-generated/src/dimos_generated_schemas/package/rust" } diff --git a/examples/episode_status/rust/src/main.rs b/examples/episode_status/rust/src/main.rs deleted file mode 100644 index acd75dc1483..00000000000 --- a/examples/episode_status/rust/src/main.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2026 Dimensional Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use dimos_generated_messages::{codec::Message, dimos_msgs::msg::EpisodeStatus}; -use std::{env, fs}; - -fn main() -> Result<(), Box> { - let arguments: Vec<_> = env::args().collect(); - if arguments.len() != 4 { - return Err( - "usage: episode-status-consumer ".into(), - ); - } - let status = match arguments[1].as_str() { - "seed" | "seed-be" => EpisodeStatus { - ts: 17.25, - state: "recording".into(), - episodes_saved: 2, - episodes_discarded: 1, - last_event: "start".into(), - task_label: vec!["".into()], - }, - "echo" | "echo-be" => EpisodeStatus::decode(&fs::read(&arguments[2])?)?, - _ => return Err("Expected seed, seed-be, echo or echo-be".into()), - }; - let little = arguments[1] != "seed-be" && arguments[1] != "echo-be"; - fs::write(&arguments[3], status.encode_endian(little)?)?; - println!( - "Rust EpisodeStatus: {}, labels={}", - status.state, - status.task_label.len() - ); - Ok(()) -} diff --git a/examples/episode_status/test_episode_status.py b/examples/episode_status/test_episode_status.py deleted file mode 100644 index 61a9fd497d9..00000000000 --- a/examples/episode_status/test_episode_status.py +++ /dev/null @@ -1,227 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -from pathlib import Path -import re -import subprocess -from typing import get_args - -from dimos_generated.dimos_msgs.msg import EpisodeStatus -from mcap.reader import make_reader -import pytest -from rosbags.typesys import Stores, get_types_from_msg, get_typestore - -from examples.episode_status.demo_episode_status import ( - episode_status, - record_status, - validate_status, -) - - -def sample(label=""): - return episode_status( - ts=17.25, - state="recording", - episodes_saved=2, - episodes_discarded=1, - last_event="start", - task_label=label, - ) - - -def test_readme_definition_and_python_blocks_are_copyable(): - root = Path(__file__).resolve().parents[2] - readme = Path(__file__).with_name("README.md").read_text() - definition = ( - root / "dimos/message_codegen/schemas/dimos_msgs/msg/EpisodeStatus.msg" - ).read_text() - fields = "\n".join( - line for line in definition.splitlines() if line and not line.startswith("#") - ) - assert re.search(r"```text\n(.*?)\n```", readme, re.S).group(1) == fields - namespace = {"__name__": "episode_status_documentation"} - for block in re.findall(r"```python\n(.*?)\n```", readme, re.S): - exec(compile(block, "episode-status-README", "exec"), namespace) - assert namespace["status"] == sample() - assert get_args(namespace["EpisodeInspector"].__annotations__["status"])[0] is EpisodeStatus - - -@pytest.fixture -def reference(): - store = get_typestore(Stores.EMPTY) - store.register(get_types_from_msg(EpisodeStatus.schema, EpisodeStatus.msg_name)) - return store - - -@pytest.mark.parametrize("label", [None, "", "pick the cup"]) -@pytest.mark.parametrize("little", [True, False]) -def test_nullable_label_survives_cdr_and_independent_decode(label, little, reference): - status = sample(label) - payload = status.encode(little_endian=little) - decoded = reference.deserialize_cdr(payload, EpisodeStatus.msg_name) - assert (decoded.ts, decoded.state, decoded.episodes_saved, decoded.episodes_discarded) == ( - 17.25, - "recording", - 2, - 1, - ) - assert decoded.last_event == "start" - assert decoded.task_label == ([] if label is None else [label]) - assert ( - bytes(reference.serialize_cdr(decoded, EpisodeStatus.msg_name, little_endian=little)) - == payload - ) - assert EpisodeStatus.decode(payload) == status - - -@pytest.mark.parametrize( - "field,value", - [("ts", float("nan")), ("ts", float("inf")), ("state", "paused"), ("last_event", "stop")], -) -def test_source_domain_validation_rejects_invalid_values(field, value): - status = sample() - setattr(status, field, value) - with pytest.raises(ValueError, match=f"EpisodeStatus.{field}"): - validate_status(status) - - -def test_source_required_fields_and_defaults_are_explicit(): - with pytest.raises(TypeError, match="required keyword-only"): - episode_status(ts=1.0, state="idle") - status = episode_status(ts=1.0, state="idle", episodes_saved=0, episodes_discarded=0) - assert status.last_event == "init" - assert list(status.task_label) == [] - with pytest.raises(ValueError, match="state"): - validate_status(EpisodeStatus()) - - -def test_signed_counter_range_and_optional_bound(reference): - status = sample(None) - status.episodes_saved = -(2**63) - status.episodes_discarded = 2**63 - 1 - decoded = reference.deserialize_cdr(status.encode(), EpisodeStatus.msg_name) - assert (decoded.episodes_saved, decoded.episodes_discarded) == (-(2**63), 2**63 - 1) - with pytest.raises(ValueError): - status.episodes_saved = 2**63 - status.task_label = ["one", "two"] - with pytest.raises(ValueError): - status.encode() - - -@pytest.mark.parametrize("label", [None, "", "pick the cup"]) -def test_mcap_embeds_complete_schema_and_preserves_fields_and_source_timestamp(tmp_path, label): - path = tmp_path / "status.mcap" - status = sample(label) - record_status(path, status) - with path.open("rb") as stream: - reader = make_reader(stream, validate_crcs=True) - assert reader.get_header().profile == "ros2" - rows = list(reader.iter_messages()) - assert len(rows) == 1 - schema, channel, message = rows[0] - assert (schema.name, schema.encoding, channel.message_encoding) == ( - "dimos_msgs/msg/EpisodeStatus", - "ros2msg", - "cdr", - ) - assert (message.publish_time, message.log_time) == (17_250_000_000, 17_250_000_001) - independent = get_typestore(Stores.EMPTY) - independent.register(get_types_from_msg(schema.data.decode(), schema.name)) - decoded = independent.deserialize_cdr(message.data, schema.name) - assert decoded.task_label == ([] if label is None else [label]) - assert decoded.state == "recording" and decoded.last_event == "start" - assert message.data == status.encode() - - -@pytest.fixture(scope="module") -def native_consumers(): - root = Path(__file__).resolve().parents[2] - binaries = { - "cpp": root / "build/episode-status/cpp/episode-status", - "rust": root / "examples/episode_status/rust/target/debug/episode-status-consumer", - } - missing = [name for name, path in binaries.items() if not path.is_file()] - if missing: - reason = f"Build the documented EpisodeStatus native consumers: {missing}" - if os.environ.get("DIMOS_EPISODE_NATIVE_REQUIRED") == "1": - pytest.fail(reason) - pytest.skip(reason) - return binaries - - -@pytest.mark.parametrize("encoder", ["python", "cpp", "rust"]) -@pytest.mark.parametrize("decoder", ["python", "cpp", "rust"]) -@pytest.mark.parametrize("little", [True, False]) -def test_all_nine_codec_pairs_and_both_endians( - tmp_path, native_consumers, encoder, decoder, little -): - source, echoed = tmp_path / "source.cdr", tmp_path / "echo.cdr" - expected = sample().encode(little_endian=little) - if encoder == "python": - source.write_bytes(expected) - else: - subprocess.run( - [ - str(native_consumers[encoder]), - "seed" if little else "seed-be", - str(source), - str(source), - ], - check=True, - ) - assert source.read_bytes() == expected - if decoder == "python": - assert EpisodeStatus.decode(source.read_bytes()) == sample() - else: - subprocess.run( - [ - str(native_consumers[decoder]), - "echo" if little else "echo-be", - str(source), - str(echoed), - ], - check=True, - ) - assert echoed.read_bytes() == expected - - -@pytest.mark.parametrize("decoder", ["cpp", "rust"]) -@pytest.mark.parametrize("label", [None, "", "pick the cup"]) -def test_native_consumers_preserve_optional_labels_and_signed_counters( - tmp_path, native_consumers, decoder, label -): - status = sample(label) - status.episodes_saved = -1 - source, target = tmp_path / "source.cdr", tmp_path / "target.cdr" - source.write_bytes(status.encode()) - subprocess.run([str(native_consumers[decoder]), "echo", str(source), str(target)], check=True) - assert EpisodeStatus.decode(target.read_bytes()) == status - - -@pytest.mark.parametrize("decoder", ["cpp", "rust"]) -def test_native_consumers_reject_optional_sequence_overflow( - tmp_path, native_consumers, reference, decoder -): - value = reference.deserialize_cdr(sample().encode(), EpisodeStatus.msg_name) - value.task_label = ["one", "two"] - source, target = tmp_path / "invalid.cdr", tmp_path / "target.cdr" - source.write_bytes(bytes(reference.serialize_cdr(value, EpisodeStatus.msg_name))) - result = subprocess.run( - [str(native_consumers[decoder]), "echo", str(source), str(target)], - capture_output=True, - text=True, - ) - assert result.returncode != 0 - assert not target.exists() From 39030fe940071b2e4b0b38ae152dce2ca14efdd2 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 15 Oct 2026 00:31:19 -0700 Subject: [PATCH 4/4] fix(msgs): adapt EpisodeStatus authoring to published CDR backend --- .github/workflows/ci.yml | 7 --- dimos/message_codegen/test_builtin_catalog.py | 2 +- dimos/message_codegen/test_builtin_install.py | 2 +- .../test_builtin_preparation.py | 6 +- examples/episode_status/README.md | 16 +++-- .../episode_status/test_episode_status.py | 60 +++++++++++++++++++ packages/dimos-generated/catalog.json | 3 +- .../dimos_msgs/msg/EpisodeStatus.msg | 3 +- .../schemas/dimos_msgs/msg/EpisodeStatus.msg | 12 ---- .../schemas/dimos_msgs/msg/EpisodeStatus.msg | 12 ---- 10 files changed, 79 insertions(+), 44 deletions(-) create mode 100644 examples/episode_status/test_episode_status.py rename packages/dimos-generated/{src/dimos_generated_schemas/package/rust/schemas => custom}/dimos_msgs/msg/EpisodeStatus.msg (80%) delete mode 100644 packages/dimos-generated/src/dimos_generated_schemas/package/schemas/dimos_msgs/msg/EpisodeStatus.msg delete mode 100644 packages/dimos-generated/src/dimos_generated_schemas/schemas/dimos_msgs/msg/EpisodeStatus.msg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a3643459b6..ba22f1c22c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1043,13 +1043,6 @@ jobs: - name: Build rust native modules if: contains(matrix.markers, 'self_hosted') run: cargo build --release --locked -p dimos-livox -p dimos-virtual-mid360 - # Collection imports the offline viewer even when its tests are deselected. - # Native executables do not install these Python extension modules. - - name: Build offline planner Python bindings - run: | - source .venv/bin/activate - maturin develop -m dimos/mapping/ray_tracing/rust/py/Cargo.toml - maturin develop -m dimos/navigation/nav_3d/mls_planner/rust/py/Cargo.toml - name: Run tests run: uv run pytest --cov=dimos/ --junitxml=junit.xml -m '${{ matrix.markers }}' - name: Re-run the failing tests with maximum verbosity diff --git a/dimos/message_codegen/test_builtin_catalog.py b/dimos/message_codegen/test_builtin_catalog.py index fbc37ed8c5f..ecb824c5805 100644 --- a/dimos/message_codegen/test_builtin_catalog.py +++ b/dimos/message_codegen/test_builtin_catalog.py @@ -36,7 +36,7 @@ def test_unlisted_sources_do_not_expand_the_public_catalog(tmp_path): manifest = json.loads( (source / "dimos_generated_schemas/package/message-package.json").read_text() ) - assert len(manifest["owned"]) == 83 + assert len(manifest["owned"]) == 84 assert "extra_msgs/msg/NotExported" not in manifest["owned"] assert "builtin_interfaces/msg/Time" in manifest["owned"] diff --git a/dimos/message_codegen/test_builtin_install.py b/dimos/message_codegen/test_builtin_install.py index d5c862d7271..5f8ea7db22b 100644 --- a/dimos/message_codegen/test_builtin_install.py +++ b/dimos/message_codegen/test_builtin_install.py @@ -38,7 +38,7 @@ assert type(r.header) is Header and r == m assert type(pickle.loads(pickle.dumps(m)).header) is Header assert 'MSG: std_msgs/Header' in schema(m.__msgtype__) -assert len(message_types()) == 83 +assert len(message_types()) == 84 assert 'visualization_msgs/msg/Marker' not in message_types() root = Path(package_root()) assert (root / 'schema-sources.json').is_file() diff --git a/dimos/message_codegen/test_builtin_preparation.py b/dimos/message_codegen/test_builtin_preparation.py index 20c948ad673..8ceefba3822 100644 --- a/dimos/message_codegen/test_builtin_preparation.py +++ b/dimos/message_codegen/test_builtin_preparation.py @@ -48,12 +48,12 @@ def no_network(*args, **kwargs): monkeypatch.setattr("dimos_cdrgen.source_inputs.requests.get", no_network) inputs = prepare(cache, offline=True, project=project) - assert len(list(inputs.rglob("*.msg"))) == 83 + assert len(list(inputs.rglob("*.msg"))) == 84 output = generate(project) resources = output / "dimos_generated_schemas/package" manifest = json.loads((resources / "message-package.json").read_text()) - assert len(manifest["owned"]) == 83 - assert len(list(project.glob("custom/dimos_msgs/msg/*.msg"))) == 21 + assert len(manifest["owned"]) == 84 + assert len(list(project.glob("custom/dimos_msgs/msg/*.msg"))) == 22 lock = json.loads((project / "schema-sources.json").read_text()) assert all(len(item["sha256"]) == 64 for item in lock["sources"]) assert (resources / "dimos_msgs-LICENSE").is_file() diff --git a/examples/episode_status/README.md b/examples/episode_status/README.md index b1db62e6018..6a6719b1c51 100644 --- a/examples/episode_status/README.md +++ b/examples/episode_status/README.md @@ -1,6 +1,6 @@ # EpisodeStatus -Add `dimos/message_codegen/schemas/dimos_msgs/msg/EpisodeStatus.msg`: +Add `packages/dimos-generated/custom/dimos_msgs/msg/EpisodeStatus.msg`: ```text float64 ts @@ -8,12 +8,13 @@ string state int64 episodes_saved int64 episodes_discarded string last_event "init" -string[<=1] task_label +string[] task_label ``` -Generate and commit the definition together with the generated source: +Add the type to `packages/dimos-generated/catalog.json`, then prepare and generate locally. Commit the definition and catalog; generated output stays ignored: ```sh +uv run python -m scripts.prepare_builtin_sources uv run python -m scripts.generate_builtin_messages uv run python -m scripts.generate_builtin_messages --check ``` @@ -22,17 +23,20 @@ Use the generated Python value from the editable checkout or matching message wh ```python from dimos_generated.dimos_msgs.msg import EpisodeStatus +from dimos_cdrgen.registry import encode, decode status = EpisodeStatus(ts=17.25, state="recording", episodes_saved=2, episodes_discarded=1, last_event="start", task_label=["inspection"]) -assert EpisodeStatus.decode(status.encode()) == status +assert len(status.task_label) <= 1 +assert decode(encode(status), EpisodeStatus) == status ``` `ts` is seconds. `task_label=[]` represents None; `[""]` preserves an empty label. +The published Python backend cannot encode bounded sequences. The unbounded sequence +has the same CDR bytes; applications enforce zero or one label before encoding or after decoding. Counters use signed 64-bit storage. Applications enforce finite timestamps and `state` (`idle/recording`) and `last_event` (`start/save/discard/init`) values. -Generated values have ordinary message defaults, rather than Pydantic's required -input/coercion rules. The source model's JSON `schema_version` envelope is not a +Generated values require explicit fields and retain their wire values without Pydantic coercion. The source model's JSON `schema_version` envelope is not a message field; adding this type does not migrate the robot-learning collector. The fields follow the [EpisodeStatus model](https://github.com/dimensionalOS/dimos/blob/ef5e5f2710c482fb1d43d43ec50fd73b0fbe1dde/dimos/imitation/collection/episode.py). diff --git a/examples/episode_status/test_episode_status.py b/examples/episode_status/test_episode_status.py new file mode 100644 index 00000000000..3f4222c7e93 --- /dev/null +++ b/examples/episode_status/test_episode_status.py @@ -0,0 +1,60 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The supported sequence schema retains the nullable label wire contract.""" + +import sys + +from dimos_cdrgen.registry import decode, encode, schema +from dimos_generated.dimos_msgs.msg import EpisodeStatus +import pytest +from rosbags.typesys import Stores, get_types_from_msg, get_typestore + + +@pytest.fixture(autouse=True) +def restore_reference_types_module(): + previous = sys.modules.get("rosbags.usertypes") + try: + yield + finally: + if previous is None: + sys.modules.pop("rosbags.usertypes", None) + else: + sys.modules["rosbags.usertypes"] = previous + + +@pytest.mark.parametrize("labels", [[], [""], ["inspection"]]) +def test_nullable_labels_and_signed_counters_preserve_bounded_wire_layout(labels): + message = EpisodeStatus( + ts=17.25, + state="recording", + episodes_saved=-2, + episodes_discarded=-1, + last_event="start", + task_label=labels, + ) + reference = get_typestore(Stores.EMPTY) + bounded = schema(message.__msgtype__).replace("string[] task_label", "string[<=1] task_label") + reference.register(get_types_from_msg(bounded, message.__msgtype__)) + wire = encode(message) + assert wire == bytes(reference.serialize_cdr(message, message.__msgtype__)) + restored = decode(wire, EpisodeStatus) + assert restored.task_label == labels + assert (restored.ts, restored.state, restored.episodes_saved, restored.episodes_discarded) == ( + 17.25, + "recording", + -2, + -1, + ) + assert restored.last_event == "start" diff --git a/packages/dimos-generated/catalog.json b/packages/dimos-generated/catalog.json index af5d96e666f..0881c3d8a54 100644 --- a/packages/dimos-generated/catalog.json +++ b/packages/dimos-generated/catalog.json @@ -79,6 +79,7 @@ "dimos_msgs/msg/ControlValues": "Published runtime consumer: dimos/control/contract/validate.py", "geometry_msgs/msg/PoseWithCovarianceStamped": "Published native runtime consumer: dimos/experimental/memory/rust/src/decoding.rs", "geometry_msgs/msg/TwistWithCovarianceStamped": "Published native runtime consumer: dimos/experimental/memory/rust/src/decoding.rs", - "geometry_msgs/msg/WrenchStamped": "Published native runtime consumer: dimos/experimental/memory/rust/src/decoding.rs" + "geometry_msgs/msg/WrenchStamped": "Published native runtime consumer: dimos/experimental/memory/rust/src/decoding.rs", + "dimos_msgs/msg/EpisodeStatus": "Built-in authoring example: examples/episode_status/README.md" } } diff --git a/packages/dimos-generated/src/dimos_generated_schemas/package/rust/schemas/dimos_msgs/msg/EpisodeStatus.msg b/packages/dimos-generated/custom/dimos_msgs/msg/EpisodeStatus.msg similarity index 80% rename from packages/dimos-generated/src/dimos_generated_schemas/package/rust/schemas/dimos_msgs/msg/EpisodeStatus.msg rename to packages/dimos-generated/custom/dimos_msgs/msg/EpisodeStatus.msg index 9677e2d096e..c62d17138d7 100644 --- a/packages/dimos-generated/src/dimos_generated_schemas/package/rust/schemas/dimos_msgs/msg/EpisodeStatus.msg +++ b/packages/dimos-generated/custom/dimos_msgs/msg/EpisodeStatus.msg @@ -9,4 +9,5 @@ int64 episodes_discarded # Application values: start, save, discard, init. string last_event "init" # Nullable string: [] means None; [""] preserves an explicitly empty label. -string[<=1] task_label +# Applications validate at most one element; the published Python backend cannot encode bounded sequences. +string[] task_label diff --git a/packages/dimos-generated/src/dimos_generated_schemas/package/schemas/dimos_msgs/msg/EpisodeStatus.msg b/packages/dimos-generated/src/dimos_generated_schemas/package/schemas/dimos_msgs/msg/EpisodeStatus.msg deleted file mode 100644 index 9677e2d096e..00000000000 --- a/packages/dimos-generated/src/dimos_generated_schemas/package/schemas/dimos_msgs/msg/EpisodeStatus.msg +++ /dev/null @@ -1,12 +0,0 @@ -# EpisodeStatus internal model from robot-learning PR4343, ef5e5f2710c482fb1d43d43ec50fd73b0fbe1dde. -# Seconds; application validation requires a finite value. -float64 ts -# Application values: idle, recording. Required by the source model. -string state -# Source Python ints use signed 64-bit wire storage; negative values are retained. -int64 episodes_saved -int64 episodes_discarded -# Application values: start, save, discard, init. -string last_event "init" -# Nullable string: [] means None; [""] preserves an explicitly empty label. -string[<=1] task_label diff --git a/packages/dimos-generated/src/dimos_generated_schemas/schemas/dimos_msgs/msg/EpisodeStatus.msg b/packages/dimos-generated/src/dimos_generated_schemas/schemas/dimos_msgs/msg/EpisodeStatus.msg deleted file mode 100644 index 9677e2d096e..00000000000 --- a/packages/dimos-generated/src/dimos_generated_schemas/schemas/dimos_msgs/msg/EpisodeStatus.msg +++ /dev/null @@ -1,12 +0,0 @@ -# EpisodeStatus internal model from robot-learning PR4343, ef5e5f2710c482fb1d43d43ec50fd73b0fbe1dde. -# Seconds; application validation requires a finite value. -float64 ts -# Application values: idle, recording. Required by the source model. -string state -# Source Python ints use signed 64-bit wire storage; negative values are retained. -int64 episodes_saved -int64 episodes_discarded -# Application values: start, save, discard, init. -string last_event "init" -# Nullable string: [] means None; [""] preserves an explicitly empty label. -string[<=1] task_label