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 new file mode 100644 index 00000000000..6a6719b1c51 --- /dev/null +++ b/examples/episode_status/README.md @@ -0,0 +1,44 @@ +# EpisodeStatus + +Add `packages/dimos-generated/custom/dimos_msgs/msg/EpisodeStatus.msg`: + +```text +float64 ts +string state +int64 episodes_saved +int64 episodes_discarded +string last_event "init" +string[] task_label +``` + +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 +``` + +Use the generated Python value from the editable checkout or matching message wheel: + +```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 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 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). +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/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/custom/dimos_msgs/msg/EpisodeStatus.msg b/packages/dimos-generated/custom/dimos_msgs/msg/EpisodeStatus.msg new file mode 100644 index 00000000000..c62d17138d7 --- /dev/null +++ b/packages/dimos-generated/custom/dimos_msgs/msg/EpisodeStatus.msg @@ -0,0 +1,13 @@ +# 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. +# Applications validate at most one element; the published Python backend cannot encode bounded sequences. +string[] task_label