Running the human model on a robot (ROS 2)#
The decision functions are plain Python, so they run anywhere. For a robot, HumanModelService wraps
the human-model ensemble and the ask-or-act rule behind JSON messages, and the ROS 2 package
quantum_mind_ros publishes it as a node. This tutorial drives the service exactly as the node does,
then runs the node’s own callbacks with stand-in ROS modules, so it works without a ROS installation.
flowchart LR
accTitle: Message flow of the ROS 2 human-model node
accDescr: Planner and dialogue nodes publish answers and queries as JSON strings; the human model node updates its ensemble and publishes a prediction with an ask-or-act decision.
dlg["dialogue node<br/>(speech, buttons)"]:::hum -->|"/human_model/answers<br/>{order, answers}"| node["human_model_node<br/>HumanModelService"]:::op
plan["planner / behaviour tree"]:::in -->|"/human_model/query<br/>{order, ask_cost, error_cost}"| node
node -->|"/human_model/decision<br/>{prediction, uncertainty, action}"| plan
classDef in fill:#0b2a4a,stroke:#79c0ff,color:#e6edf3
classDef op fill:#2a1b3d,stroke:#d2a8ff,color:#e6edf3
classDef hum fill:#3d1414,stroke:#ff7b72,color:#e6edf3
import json, sys, types, pathlib
import numpy as np
import matplotlib.pyplot as plt
from quantum_mind.viz import use_mpl_style
from quantum_mind.applications.robotics import HumanModelService, domain_models
theme = use_mpl_style('dark')
1. The service and its messages#
Answers arrive one person at a time: the order the two clarification questions were asked in, and the two answers (1 = yes). Queries ask for a prediction and a decision.
svc = HumanModelService(refit_every=25, min_answers=40)
print(json.dumps(svc.query({'order': 'AB'}))) # nothing learnt yet: ask
people = domain_models('object_clarification')['QL'] # simulated population
rng = np.random.default_rng(0)
trace = []
for step in range(8):
batch = people.sample(None, 15, rng) # 15 people per order arrive
for order, counts in batch.items():
for cell, n in enumerate(counts):
for _ in range(n):
svc.add_answer({'order': order, 'answers': [[1, 1], [1, 0], [0, 1], [0, 0]][cell]})
reply = svc.query({'order': 'AB', 'ask_cost': 1.0, 'error_cost': 1.5})
trace.append((svc.n_answers, reply.get('p_first_yes'), reply.get('uncertainty', {}).get('model_disagreement_bits'), reply['action']))
print('%3d answers -> %s (%s)' % (svc.n_answers, reply['action'], reply['reason']))
print(json.dumps(reply, indent=1)[:600])
{"n_answers": 0, "action": "ask", "reason": "not enough data (0 of 40 answers)"}
30 answers -> ask (not enough data (30 of 40 answers))
60 answers -> act (expected error cost 0.47 <= asking cost 1.00)
90 answers -> act (expected error cost 0.62 <= asking cost 1.00)
120 answers -> act (expected error cost 0.64 <= asking cost 1.00)
150 answers -> act (expected error cost 0.62 <= asking cost 1.00)
180 answers -> act (expected error cost 0.68 <= asking cost 1.00)
210 answers -> act (expected error cost 0.67 <= asking cost 1.00)
240 answers -> act (expected error cost 0.70 <= asking cost 1.00)
{
"n_answers": 240,
"order": "AB",
"prediction": [
0.5326963089605704,
1.8109526270742035e-05,
0.1208292008554017,
0.3464563806577571
],
"p_first_yes": 0.5327144184868412,
"uncertainty": {
"entropy_bits": 1.3825242390418593,
"model_disagreement_bits": 8.970613262926364e-05,
"total_bits": 1.3826139451744885
},
"weights": {
"BayesOrderModel": 0.9331843398501966,
"QuantumOrderModel4D": 0.06681520870430395,
"AnchoringOrderModel": 4.514454994259427e-07
},
"action": "act",
"expected_cost_act": 0.7009283722697381,
"reason": "expected error cost 0.70 <= asking cost 1.00
n, p, dis, act = zip(*[t for t in trace if t[1] is not None])
fig, ax = plt.subplots(figsize=(6.6, 3.0))
ax.plot(n, dis, 'o-', color=theme['palette'][0], label='model disagreement (bits)')
ax.axhline(0.05, color=theme['palette'][0], ls=':', lw=1, label='threshold')
ax.set_xlabel('answers received'); ax.set_ylabel('bits'); ax.legend(); ax.set_title('What the node learns as answers arrive')
plt.show()
2. The node’s callbacks, without ROS#
The node subscribes to ~/answers and ~/query and publishes to ~/decision. Here rclpy and
std_msgs are replaced by minimal stand-ins (the same ones the package’s tests use), and the node’s
real callbacks are called with JSON strings.
published = []
class Node: # stand-in for rclpy.node.Node
def __init__(self, name): self.params = {}
def declare_parameter(self, name, default): self.params[name] = default
def get_parameter(self, name): return types.SimpleNamespace(value=self.params[name])
def create_publisher(self, typ, topic, depth): return types.SimpleNamespace(publish=published.append)
def create_subscription(self, typ, topic, cb, depth): pass
def get_logger(self): return types.SimpleNamespace(info=print, warning=print)
class String: # stand-in for std_msgs.msg.String
def __init__(self, data=''): self.data = data
for name, attrs in (('rclpy', {}), ('rclpy.node', {'Node': Node}), ('std_msgs', {}), ('std_msgs.msg', {'String': String})):
mod = types.ModuleType(name); mod.__dict__.update(attrs); sys.modules[name] = mod
sys.path.insert(0, str(pathlib.Path('integrations/ros2/quantum_mind_ros').resolve()))
from quantum_mind_ros.human_model_node import HumanModelNode
node = HumanModelNode()
for _ in range(60):
order = 'AB' if rng.random() < 0.5 else 'BA'
node.on_answer(String(json.dumps({'order': order, 'answers': [int(rng.random() < 0.6), int(rng.random() < 0.5)]})))
node.on_answer(String('not json')) # bad messages are logged, not raised
node.on_query(String(json.dumps({'order': 'AB', 'error_cost': 3.0})))
decision = json.loads(published[-1].data)
print({k: decision[k] for k in ('action', 'reason', 'p_first_yes')})
refitted human models on 20 answers
refitted human models on 40 answers
refitted human models on 60 answers
ignored answer message: Expecting value: line 1 column 1 (char 0)
{'action': 'ask', 'reason': 'expected error cost 1.71 > asking cost 1.00', 'p_first_yes': 0.4285335606972499}
3. On a real robot#
cp -r integrations/ros2/quantum_mind_ros ~/ros2_ws/src/
cd ~/ros2_ws && colcon build --packages-select quantum_mind_ros && source install/setup.bash
ros2 launch quantum_mind_ros human_model.launch.py
ros2 topic pub --once /human_model/answers std_msgs/String "{data: '{\"order\": \"AB\", \"answers\": [1, 0]}'}"
ros2 topic pub --once /human_model/query std_msgs/String "{data: '{\"order\": \"AB\"}'}"
ros2 topic echo /human_model/decision
A behaviour tree can call the same service through a condition node: ask the clarifying question when
action == 'ask', otherwise continue to the grasp.
The statements below are checked by this cell every time the documentation is built:
assert trace[0][3] == 'ask' and svc.n_answers == 8 * 30
assert decision['action'] in ('ask', 'act') and 0 <= decision['p_first_yes'] <= 1
json.dumps(reply) # every reply is plain JSON
print('checked')
checked
Summary. The service turns a stream of answers into predictions, an uncertainty and an ask-or-act decision, all as JSON, and the ROS 2 node is a thin wrapper around it. The node has been tested with stand-in ROS modules, as here; it has not yet been run inside a ROS 2 installation in CI (see validation status).
References: Macenski et al. (2022), Science Robotics 7, eabm6074; Colledanchise & Ögren (2018), Behavior Trees in Robotics and AI.