What it does
All outputs below are real, unedited outputs of the adapter (greedy decoding, chat template, enable_thinking=False). They are hand-picked for quality from a 20-prompt evaluation set, so they show what the model can do at its best, not what it does on average. See the evaluation section for the averages.
1. English instruction → complete, runnable-looking node
Write a ROS2 Python node that subscribes to /scan (LaserScan) and publishes a Twist to /cmd_vel to avoid obstacles closer than 0.5m.
import rclpyfrom rclpy.node import Nodefrom sensor_msgs.msg import LaserScanfrom geometry_msgs.msg import Twist class ObstacleAvoidanceNode(Node): def __init__(self): super().__init__('obstacle_avoidance_node') self.subscription = self.create_subscription( LaserScan, '/scan', self.scan_callback, 10 ) self.publisher_ = self.create_publisher(Twist, '/cmd_vel', 10) self.get_logger().info('Obstacle avoidance node started.') def scan_callback(self, msg): twist = Twist() # Check for obstacles within 0.5 meters if any(r < 0.5 for r in msg.ranges if not r == float('inf')): twist.linear.x = 0.0 twist.angular.z = 0.5 # Turn right to avoid obstacle else: twist.linear.x = 0.2 # Move forward twist.angular.z = 0.0 self.publisher_.publish(twist) def main(args=None): rclpy.init(args=args) node = ObstacleAvoidanceNode() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()
Correct imports, subscription, publisher, message fields and a proper main() with clean shutdown. A simplification worth knowing: the check covers the whole scan, where a real robot would look only at a forward cone.
2. Slovenian instruction → Slovenian node
Napiši ROS2 Python vozlišče, ki objavlja std_msgs/String s trenutnim časom na temo /cas vsako sekundo.
import rclpyfrom rclpy.node import Nodefrom std_msgs.msg import Stringfrom datetime import datetime class CasObjavljalec(Node): def __init__(self): super().__init__('cas_objavljalec') self.objavljalec = self.create_publisher(String, '/cas', 10) self.timer = self.create_timer(1.0, self.objavi_cas) def objavi_cas(self): msg = String() msg.data = datetime.now().strftime('%Y-%m-%d %H:%M:%S') self.objavljalec.publish(msg) self.get_logger().info(f'Objavljen čas: {msg.data}') def main(args=None): rclpy.init(args=args) node = CasObjavljalec() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()
It follows the language of the request: Slovenian identifiers, Slovenian log message, a 1 Hz timer as asked.
3. Project-specific interfaces: with and without retrieval
Real robots use custom message types that no model has seen. We invented a small platform ("AgroBot") with custom messages, e.g. agrobot_msgs/SoilMoisture (fields moisture_percent, sensor_id, …) and agrobot_msgs/SprayCommand (nozzle_id, flow_rate_lpm, duration_s), and asked:
Write a ROS2 Python node that subscribes to /soil (agrobot_msgs/SoilMoisture) and, when the soil moisture reading falls below 30, publishes an agrobot_msgs/SprayCommand to /spray that opens nozzle 1 at a flow rate of 2.5 for 10 seconds.
Table with columns: Without retrieval — the model guesses the field names, With the message definitions retrieved into the prompt| Without retrieval — the model guesses the field names | With the message definitions retrieved into the prompt |
|---|
msg.moisturespray_cmd.flow_rate = 2.5spray_cmd.duration = 10.0 | msg.moisture_percentspray_cmd.flow_rate_lpm = 2.5spray_cmd.duration_s = 10.0 |
Everything else in the two nodes is structurally the same. Across 12 such tasks, correct use of the custom fields went from 3/12 to 12/12 once a small retrieval step (BM25 over the interface definitions, plus a verify-and-repair loop) supplied the definitions. This is the practical way to use the adapter on a real project. Note that the unmodified base model reaches 12/12 with the same retrieval, so this is a property of the retrieval system, not of the adapter.
Honest bottom line
Under matched evaluation (same chat template, same decoding, same seed, adapter switched on and off on the same loaded weights), the adapter is statistically indistinguishable from the unmodified base model on the tasks we tested, and slightly worse on Slovenian orthography. If you want the strongest practical model, use Qwen/Qwen3.6-27B directly. The adapter is published so the experiment can be reproduced, checked and criticised.
Table with columns: Test (fixed seed, same chat template and decoding), Adapter, Base Qwen3.6-27B (adapter off)| Test (fixed seed, same chat template and decoding) | Adapter | Base Qwen3.6-27B (adapter off) |
|---|
| Python functions executed against hidden tests (20 tasks) | 16/20 | 15/20 |
| ROS2 node in reasoning mode, valid and structurally complete (4 prompts) | 4/4 | 4/4 |
| Tasks using custom ROS message fields, no retrieval (12 tasks) | 3/12 | 3/12 |
| Slovenian general prompts (24, read by hand) | more typos, a few grammar errors | slightly cleaner |
On 20 standard ROS2 node prompts the adapter produced a statically valid, structurally complete node in 19/20 cases versus 15/20 for GaMS-27B-Instruct-Nemotron under the same decoding (static check: ast.parse plus a Node subclass, main() and rclpy.init(; this does not prove the code runs on a robot). The base model was not measured on this exact test, so this comparison says nothing about a benefit of the adapter: GaMS is a Gemma-based model with no ROS training, and Qwen's coding strength explains the gap.
An earlier internal comparison that appeared to show a large "regression" of the adapter came from an unfair inference setup (different chat template and thinking mode between the two models) and was retracted. The fair comparison above shows neither regression nor gain.
Training
Table | |
|---|
| Base model | Qwen/Qwen3.6-27B (Apache-2.0; revision 6a9e13bd0fc8f0983b9b99948120bc37f49c13e9 as of 2026-10-24. The revision at training time was not recorded; the repository was last modified 2026-04-24, before training) |
| Method | QLoRA: 4-bit NF4 with double quantization, bfloat16 compute |
| LoRA | r=16, α=32, targets q_proj, k_proj, v_proj, o_proj, in_proj_qkv, in_proj_a, in_proj_b, in_proj_z, out_proj (standard and Gated-DeltaNet attention projections) |
| Data | 80 % ROS/ROS2 GitHub source code (Apache-2.0 / MIT / BSD licensed repositories), 20 % Slovenian chat (cjvt/GaMS-Nemotron-Chat), packed into 2,048-token sequences |
| Objective | plain causal-LM loss over packed text; no chat template and no completion-only masking (a weakness: this is not instruction tuning) |
| Optimizer | AdamW, LR 2e-4, cosine schedule with warmup; training script default 2,000 steps (the exact submitted value was not recorded) |
| Compute | NVIDIA A100 40 GB × 2, HPC Vega (IZUM, Slovenia) |
Later supervised fine-tuning experiments are not part of this release: training on raw corpus targets damaged the model, and cleaner targets only matched this adapter's performance.
Usage
import torchfrom transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfigfrom peft import PeftModel base_id = "Qwen/Qwen3.6-27B"tok = AutoTokenizer.from_pretrained(base_id)base = AutoModelForCausalLM.from_pretrained( base_id, device_map="auto", dtype=torch.bfloat16, quantization_config=BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16))model = PeftModel.from_pretrained(base, "AlbinoRacoon/velik-martin-27b-lora").eval() msgs = [{"role": "system", "content": "Answer directly. Output only the final answer or code."}, {"role": "user", "content": "Napiši ROS2 vozlišče, ki objavlja std_msgs/String na /greeting pri 2 Hz."}]inputs = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt", return_dict=True, enable_thinking=False).to(model.device)out = model.generate(**inputs, max_new_tokens=700, do_sample=False, repetition_penalty=1.05)print(tok.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))
Decoding notes from our experiments: use the chat template. no_repeat_ngram_size corrupts code (code legitimately repeats rclpy.init/spin/shutdown), so keep the repetition penalty near 1.05. Without a system prompt the model introduces itself as Qwen from Alibaba; the adapter does not change that.
Limitations
- It can hallucinate APIs, package names, message fields and commands (see example 3). Generated code must be reviewed and run, and must not be the sole basis for safety-critical robot behaviour.
- No demonstrated advantage over the base model, and slightly more orthographic errors in Slovenian.
- Evaluations are small (4–24 prompts) and use static checks or hand reading. They are not a rigorous benchmark.
- Training data licenses (permissive ROS repositories,
cjvt/GaMS-Nemotron-Chat) carry attribution and terms you should check for your use. The adapter weights are Apache-2.0, matching the base model.
adapter_model.safetensors SHA-256: 2eaf44570b272688d4e4e442be1a91e17fc4a3decd4c991fcb03b8f5149d2c85
Developed as part of a master's thesis project at UM FERI (University of Maribor, Slovenia) for the FarmBeast agricultural robot. Computing resources were provided by HPC Vega (IZUM). Companion model: AlbinoRacoon/martin-1.3b-base, a 1.34B-parameter model trained from scratch.