Skip to content

feat: rust transform support (get only)#2816

Open
aclauer wants to merge 15 commits into
mainfrom
andrew/feat/rust-transform-get
Open

feat: rust transform support (get only)#2816
aclauer wants to merge 15 commits into
mainfrom
andrew/feat/rust-transform-get

Conversation

@aclauer

@aclauer aclauer commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

We need transforms with Rust

Closes DIM-XXX

Solution

Right now just supporting get, since we don't have any publishers yet. i'll add that asap.

Add #[tf] attribute to subscribe a module to the transform topic
get_latest() -> get the latest transform between two frames
get() -> get transform at a specified time

Transforms are collected in a hash map and inter-frame transforms are built on demand

How to Test

uv run python examples/native-modules/rust_tf.py

Contributor License Agreement

  • I have read and approved the CLA.

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

❌ 2 Tests Failed:

Tests completed Failed Passed Skipped
2788 2 2786 71
View the top 1 failed test(s) by shortest run time
dimos.navigation.cmu_nav.modules.far_planner.test_far_planner_rosbag.TestFarPlannerRosbag::test_waypoint_accuracy
Stack Traces | 69.8s run time
self = <dimos.navigation.cmu_nav.modules.far_planner.test_far_planner_rosbag.TestFarPlannerRosbag object at 0x7284daa34410>

    def test_waypoint_accuracy(self) -> None:
        """Feed identical inputs at original timing and compare waypoint output."""
        if not FAR_PLANNER_BIN.exists():
            pytest.skip(f"FAR planner binary not found: {FAR_PLANNER_BIN}")
    
        window = load_rosbag_window()
        ref_wp = window.way_point
        assert len(ref_wp) > 0, "No reference waypoints in fixture"
    
        lcm = lcmlib.LCM()
        wp_collector = LcmCollector(topic=WAYPOINT_OUT_LCM, msg_type=PointStamped)
        wp_collector.start(lcm)
    
        stop_event = threading.Event()
        handle_thread = threading.Thread(
            target=lcm_handle_loop, args=(lcm, stop_event), daemon=True
        )
        handle_thread.start()
    
        runner = NativeProcessRunner(binary_path=str(FAR_PLANNER_BIN), args=_far_planner_args())
    
        try:
            runner.start()
            assert runner.is_running, "FAR planner binary failed to start"
            time.sleep(_PROCESS_STARTUP_SEC)
    
            # Feed at original timing (1:1 with rosbag)
            feed_at_original_timing(
                lcm,
                window,
                topic_map={
                    "odom": ODOM_LCM,
                    "scan": SCAN_LCM,
                    "terrain": TERRAIN_LCM,
                    "terrain_ext": TERRAIN_EXT_LCM,
                    "goal": GOAL_LCM,
                },
            )
    
            time.sleep(_POST_FEED_DRAIN_SEC)
    
        finally:
            runner.stop()
            stop_event.set()
            handle_thread.join(timeout=DEFAULT_THREAD_JOIN_TIMEOUT)
            wp_collector.stop(lcm)
    
        our_wps = [(msg.x, msg.y) for msg in wp_collector.messages]
    
        # Compute deviation score
        score = _compute_waypoint_deviation(our_wps, ref_wp)
    
        # Log score for visibility
        logger.info(f"\n{'=' * 60}")
        logger.info("FAR PLANNER DEVIATION SCORE")
        logger.info(f"  Our waypoints:     {len(our_wps)}")
        logger.info(f"  Reference:         {len(ref_wp)}")
        logger.info(f"  Count ratio:       {score['count_ratio']:.3f}")
        logger.info(f"  Mean error:        {score['mean_error_m']:.3f} m")
        logger.info(f"  Max error:         {score['max_error_m']:.3f} m")
        logger.info(f"  Mean X diff:       {score['mean_x_diff']:.3f} m")
        logger.info(f"  Mean Y diff:       {score['mean_y_diff']:.3f} m")
        logger.info(f"{'=' * 60}\n")
    
        # Assertions — generous thresholds, the point is to measure
        assert len(our_wps) > 0, "FAR planner produced no waypoints"
>       assert score["mean_error_m"] < 5.0, (
            f"Mean waypoint error {score['mean_error_m']:.2f}m exceeds 5m threshold"
        )
E       AssertionError: Mean waypoint error 5.05m exceeds 5m threshold
E       assert 5.047097408482863 < 5.0

handle_thread = <Thread(Thread-157 (lcm_handle_loop), stopped daemon 125914053940928)>
lcm        = <LCM object at 0x7284c05de010>
our_wps    = [(6.318393230438232, -3.847282886505127), (6.318393230438232, -3.847282886505127), (6.275859832763672, -3.925271987915...9832763672, -3.925271987915039), (6.275859832763672, -3.925271987915039), (6.275859832763672, -3.925271987915039), ...]
ref_wp     = array([[     5.5327,        -0.3,         2.5,        0.75],
       [     5.7342,        -0.3,         2.5,        0.7...,      7.8589,      1.4869,    0.095978],
       [     65.337,      7.8589,      1.4869,    0.095978]], shape=(300, 4))
runner     = NativeProcessRunner(binary_path='.../result/bin/far_planner_...hred', '4', '--new_point_counter', '5', '--obs_inflate_size', '1', '--visualize_ratio', '0.4', '--wp_churn_dist', '0'])
score      = {'count_ratio': 0.6533333333333333, 'max_error_m': 8.72412122502261, 'mean_error_m': 5.047097408482863, 'mean_x_diff': 3.9846499971629816, ...}
self       = <dimos.navigation.cmu_nav.modules.far_planner.test_far_planner_rosbag.TestFarPlannerRosbag object at 0x7284daa34410>
stop_event = <threading.Event at 0x7284d406ef60: set>
window     = RosbagWindow(odom=array([[     5.3674,           0,           0, ...,           0,           0,           1],
       [...,           1],
       [     0.2975,           0,           0,           0,           0,           0,           1]]))])
wp_collector = LcmCollector(topic='/rbfp_wp#geometry_msgs.PointStamped', msg_type=<class 'dimos.msgs.geometry_msgs.PointStamped.Point...99689.034212101, 99689.234577128, 99689.433446586, 99689.633332556, 99689.833259601, 99690.033226027, 99690.233110578])

.../modules/far_planner/test_far_planner_rosbag.py:276: AssertionError
View the full list of 1 ❄️ flaky test(s)
dimos.e2e_tests.test_dimsim_spatial_memory::test_go_to_the_bed

Flake rate in main: 19.05% (Passed 102 times, Failed 24 times)

Stack Traces | 547s run time
lcm_spy = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7157d0daf0b0>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7157cfaedda0>
human_input = <function human_input.<locals>.send_human_input at 0x7157cfaede40>
dim_sim = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7157d0b1a570>
explore_house = <function explore_house.<locals>.explore at 0x7157cfaee340>

    @pytest.mark.self_hosted_large
    def test_go_to_the_bed(lcm_spy, start_blueprint, human_input, dim_sim, explore_house) -> None:
        start_blueprint(
            "run",
            "unitree-go2-agentic",
            simulator="dimsim",
        )
        lcm_spy.save_topic(".../McpClient/on_system_modules/res")
        lcm_spy.wait_for_saved_topic(".../McpClient/on_system_modules/res", timeout=1200.0)
    
        explore_house()
    
        human_input("go to the bed")
    
>       lcm_spy.wait_until_odom_position(-3.567, -1.332, threshold=2, timeout=180)

dim_sim    = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7157d0b1a570>
explore_house = <function explore_house.<locals>.explore at 0x7157cfaee340>
human_input = <function human_input.<locals>.send_human_input at 0x7157cfaede40>
lcm_spy    = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7157d0daf0b0>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7157cfaedda0>

dimos/e2e_tests/test_dimsim_spatial_memory.py:32: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
dimos/e2e_tests/lcm_spy.py:167: in wait_until_odom_position
    self.wait_for_message_result(
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x7157cfaee520>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7157d0daf0b0>
        threshold  = 2
        timeout    = 180
        x          = -3.567
        y          = -1.332
dimos/e2e_tests/lcm_spy.py:153: in wait_for_message_result
    wait_until(
        event      = <threading.Event at 0x7157d0b1a090: unset>
        fail_message = 'Failed to get to position x=-3.567, y=-1.332'
        listener   = <function LcmSpy.wait_for_message_result.<locals>.listener at 0x7157cfaee3e0>
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x7157cfaee520>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7157d0daf0b0>
        timeout    = 180
        topic      = '/odom#geometry_msgs.PoseStamped'
        type       = <class 'dimos.msgs.geometry_msgs.PoseStamped.PoseStamped'>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

predicate = <bound method Event.is_set of <threading.Event at 0x7157d0b1a090: unset>>

    def wait_until(
        predicate: Callable[[], bool],
        *,
        timeout: float,
        interval: float = 0.1,
        message: str | None = None,
    ) -> None:
        """Poll ``predicate`` until it returns truthy or ``timeout`` elapses."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            if predicate():
                return
            time.sleep(interval)
>       raise TimeoutError(message or f"Timed out after {timeout}s waiting for condition")
E       TimeoutError: Failed to get to position x=-3.567, y=-1.332

deadline   = 99676.059040602
interval   = 0.1
message    = 'Failed to get to position x=-3.567, y=-1.332'
predicate  = <bound method Event.is_set of <threading.Event at 0x7157d0b1a090: unset>>
timeout    = 180

.../utils/testing/waiting.py:35: TimeoutError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@aclauer aclauer changed the title Andrew/feat/rust transform feat: rust transform get Jul 9, 2026
@aclauer aclauer changed the title feat: rust transform get feat: rust get transform Jul 9, 2026
@aclauer aclauer linked an issue Jul 9, 2026 that may be closed by this pull request
@aclauer aclauer marked this pull request as ready for review July 9, 2026 20:43
@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a consumer-side transform client (Tf) to the Rust native-module SDK. A #[tf] attribute on a struct field subscribes the module to the /tf topic and exposes get() / get_latest() for composing multi-hop transforms via BFS through the frame graph; the Python side injects the resolved tf wire channel into every native module's stdin config so Rust modules can discover the topic without declaring an explicit port.

  • native/rust/dimos-module/src/tf.rs — new TBuffer / MultiTBuffer / Tf types with BFS path composition, nearest-in-time lookup, and a test suite covering the main scenarios.
  • native/rust/dimos-module-macros/src/lib.rs and module.rs#[tf] attribute support wired through builder.tf(), which lazily subscribes and shares one graph per module instance.
  • Python navigation modules (goal_relay.py, basic_path_follower/module.py) — replace raw odometry pass-through with tf.get() to steer in the robot base frame; robot constants are extracted into new constants files.

Confidence Score: 4/5

Safe to merge for the current two-hop TF tree; the BFS bug only triggers in graphs with multiple paths where one alternate route has a tolerance-failing edge.

The BFS visited-set ordering is wrong: frames get marked visited before the edge tolerance check, so an alternative path to the same node can be silently discarded. For the robot's typical two-hop tree this never triggers, but the bug will surface as a hard-to-diagnose None return once the TF graph grows or a clock-skewed edge is introduced.

native/rust/dimos-module/src/tf.rs — the BFS function around line 263.

Important Files Changed

Filename Overview
native/rust/dimos-module/src/tf.rs New transform client: BFS path-finding has a bug where frames are marked visited before the edge time-tolerance check, causing valid alternative paths to be silently missed.
native/rust/dimos-module/src/module.rs Adds Builder::tf() method that lazily subscribes to the tf topic and returns a shared Tf handle; repeated calls share one graph instance.
native/rust/dimos-module-macros/src/lib.rs Adds #[tf] attribute support to the #[derive(Module)] macro, wiring the annotated field through builder.tf().
dimos/core/native_module.py Unconditionally injects the tf transport channel into every native module's stdin config, allowing Rust modules with #[tf] to discover the tf topic.
dimos/core/transport_factory.py New tf_channel() helper that resolves the correct wire address for the /tf topic on whichever backend (Zenoh or LCM) is active.
dimos/navigation/nav_3d/mls_planner/goal_relay.py Replaces the raw odometry pass-through with a tf-corrected base-frame pose, including a ground-projection step using the lidar mount height.
dimos/navigation/basic_path_follower/module.py Replaces direct odometry-to-pose conversion with a tf lookup to the robot base frame before steering.
dimos/hardware/sensors/lidar/fastlio2/module.py Removes the time.time() fallback for missing tf timestamps; messages with ts=0 will now publish a transform at epoch time instead of wall-clock time.
examples/native-modules/rust/src/tf_listener.rs New example Rust module demonstrating #[tf] usage by polling get_latest on a 500 ms interval.
examples/native-modules/rust_tf.py Python driver for the tf example: publishes a rotating a->b->c chain and launches the Rust tf_listener module alongside it.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Py as Python (NativeModule)
    participant Stdin as stdin JSON
    participant Builder as Rust Builder
    participant TfRoute as TfRoute (Route)
    participant Buffer as MultiTBuffer (Arc<RwLock>)
    participant Tf as Tf handle

    Py->>Stdin: "inject {"tf": tf_channel()}"
    Builder->>Builder: "tf() called (#[tf] field)"
    Builder->>TfRoute: tf_subscription(topic, buffer_size)
    TfRoute-->>Buffer: "creates shared Arc<RwLock<MultiTBuffer>>"
    Builder->>Tf: clones Tf handle (Arc ref)
    Builder->>Builder: register TfRoute under topic

    Note over TfRoute,Buffer: On every /tf message
    TfRoute->>Buffer: "write lock -> receive(parent, child, ts, iso)"

    Note over Tf,Buffer: On get_latest() / get() call
    Tf->>Buffer: "read lock -> BFS(parent, child, time, tol)"
    Buffer-->>Tf: composed Transform (nalgebra Isometry3)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Py as Python (NativeModule)
    participant Stdin as stdin JSON
    participant Builder as Rust Builder
    participant TfRoute as TfRoute (Route)
    participant Buffer as MultiTBuffer (Arc<RwLock>)
    participant Tf as Tf handle

    Py->>Stdin: "inject {"tf": tf_channel()}"
    Builder->>Builder: "tf() called (#[tf] field)"
    Builder->>TfRoute: tf_subscription(topic, buffer_size)
    TfRoute-->>Buffer: "creates shared Arc<RwLock<MultiTBuffer>>"
    Builder->>Tf: clones Tf handle (Arc ref)
    Builder->>Builder: register TfRoute under topic

    Note over TfRoute,Buffer: On every /tf message
    TfRoute->>Buffer: "write lock -> receive(parent, child, ts, iso)"

    Note over Tf,Buffer: On get_latest() / get() call
    Tf->>Buffer: "read lock -> BFS(parent, child, time, tol)"
    Buffer-->>Tf: composed Transform (nalgebra Isometry3)
Loading

Comments Outside Diff (1)

  1. native/rust/dimos-module/src/tf.rs, line 2043-2056 (link)

    P1 BFS marks frames visited before edge-tolerance check

    visited.insert(next.clone()) runs unconditionally before self.edge(...) is checked. If the edge lookup fails (no sample within tolerance), next is already in the visited set and cannot be reached again via a different path. In a graph with multiple routes where one intermediate edge fails the time-tolerance check, the BFS will return None even though a valid alternative path exists.

    Concrete failure: edges A→C (tolerance miss) and A→B→C (both within tolerance). BFS visits C while trying A→C, marks it visited, then when exploring B's neighbors, C is skipped — the correct path A→B→C is never found.

Reviews (1): Last reviewed commit: "Clean up" | Re-trigger Greptile

Comment on lines +263 to +275
for next in self.connections(&frame) {
if visited.insert(next.clone()) {
if let Some(edge) = self.edge(&frame, &next, time, tolerance) {
let mut extended = path.clone();
extended.push(edge);
queue.push_back((next, extended));
}
}
}
}
None
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The frame is inserted into visited before the edge lookup, so if the edge fails the tolerance check the frame is permanently excluded from BFS exploration. Any alternative path through a different intermediate that would reach next within tolerance is then silently skipped. Move visited.insert inside the successful-edge branch so other routes to the same frame remain explorable.

Suggested change
for next in self.connections(&frame) {
if visited.insert(next.clone()) {
if let Some(edge) = self.edge(&frame, &next, time, tolerance) {
let mut extended = path.clone();
extended.push(edge);
queue.push_back((next, extended));
}
}
}
}
None
}
}
for next in self.connections(&frame) {
if !visited.contains(&next) {
if let Some(edge) = self.edge(&frame, &next, time, tolerance) {
visited.insert(next.clone());
let mut extended = path.clone();
extended.push(edge);
queue.push_back((next, extended));
}
}
}
}
None
}
}

@@ -255,7 +256,8 @@ def start(self) -> None:
assert self._process.stdin is not None
if self.config.stdin_config:
config_dict = self.config.to_config_dict()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 "tf" key always overrides any user port named "tf"

{**topics, "tf": tf_channel()} unconditionally clobbers whatever value topics["tf"] might have had. The comment explains this is intentional (every module reserves "tf"), but if a native module ever declares a non-tf port named "tf" the override will be silent and hard to debug. Worth a brief inline note that the key is reserved and must not be used for user ports.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@aclauer aclauer changed the title feat: rust get transform feat: rust transform support (get only) Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rust Native Modules transforms

1 participant