Skip to content

Commit 18eac1e

Browse files
awoll-bdaiexploy-bot
authored andcommitted
Add exploy version to metadata and validate (#52)
# Pull Request ### What change is being made Add the exploy version to the metadata of the ONNX file and validate against supported range in controller. ### Why this change is being made Better handling of version changes. ### Tested Confirmed version is present and tests are passing. GitOrigin-RevId: f67c1a80dff003665a5119aaa1cdae3849865b9d
1 parent e01291f commit 18eac1e

8 files changed

Lines changed: 233 additions & 1 deletion

File tree

control/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ add_executable(exploy_test
5353
test/components_test.cpp
5454
test/context_test.cpp
5555
test/logging_test.cpp
56+
test/metadata_test.cpp
5657
test/onnx_runtime_test.cpp
5758
"${GEN_OUTPUT}"
5859
)

control/context.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ bool OnnxContext::createContext(OnnxRuntime& onnx_model, bool strict) {
5050
return false;
5151
}
5252

53+
if (!metadata::checkExployVersion(onnx_model.getCustomMetadata("exploy_version"))) return false;
54+
5355
std::optional<int> maybe_update_rate = parseUpdateRate(onnx_model);
5456
if (!maybe_update_rate.has_value()) return false;
5557
update_rate_ = maybe_update_rate.value();

control/metadata.hpp

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include <nlohmann/json.hpp>
66

77
#include <optional>
8+
#include <regex>
89
#include <string>
910
#include <vector>
1011

@@ -249,4 +250,78 @@ inline void from_json(const json& j, JointMetadata& jm) {
249250
j.at("joint_names").get_to(jm.names);
250251
}
251252

253+
/**
254+
* @brief Parsed semantic version (MAJOR.MINOR.PATCH).
255+
*/
256+
struct Version {
257+
int major{0};
258+
int minor{0};
259+
int patch{0};
260+
261+
std::string toString() const { return fmt::format("{}.{}.{}", major, minor, patch); }
262+
263+
bool operator<=(const Version& other) const {
264+
if (major != other.major) return major < other.major;
265+
if (minor != other.minor) return minor < other.minor;
266+
return patch <= other.patch;
267+
}
268+
};
269+
270+
constexpr Version kMinSupportedExployVersion{0, 1, 0};
271+
constexpr Version kMaxSupportedExployVersion{0, 1, 0};
272+
273+
/**
274+
* @brief Parse a "MAJOR.MINOR.PATCH" version string.
275+
*
276+
* @param s Version string to parse.
277+
* @return Parsed Version, or std::nullopt if the string is not a valid semver.
278+
*/
279+
inline std::optional<Version> parseVersion(const std::string& s) {
280+
static const std::regex kVersionRegex(R"(^(\d+)\.(\d+)\.(\d+)$)");
281+
std::smatch match;
282+
if (!std::regex_match(s, match, kVersionRegex)) return std::nullopt;
283+
return Version{std::stoi(match[1]), std::stoi(match[2]), std::stoi(match[3])};
284+
}
285+
286+
/**
287+
* @brief Check that the ONNX model's exploy_version metadata is present and within the supported
288+
* range [kMinSupportedExployVersion, kMaxSupportedExployVersion]. Logs an error if not.
289+
*
290+
* @param maybe_version_str The value of the "exploy_version" metadata key, or std::nullopt if
291+
* absent.
292+
* @return true if the version is present and within the supported range, false otherwise.
293+
*/
294+
inline bool checkExployVersion(const std::optional<std::string>& maybe_version_str) {
295+
if (!maybe_version_str.has_value()) {
296+
LOG(ERROR,
297+
"ONNX model does not contain 'exploy_version' metadata. The ONNX file is not "
298+
"compatible with this controller.");
299+
return false;
300+
}
301+
302+
std::string version_str;
303+
try {
304+
version_str = json::parse(maybe_version_str.value()).get<std::string>();
305+
} catch (const json::exception&) {
306+
LOG_STREAM(ERROR, "Failed to parse exploy_version: '" << maybe_version_str.value() << "'.");
307+
return false;
308+
}
309+
310+
auto maybe_version = parseVersion(version_str);
311+
if (!maybe_version.has_value()) {
312+
LOG_STREAM(ERROR, "Failed to parse exploy_version: '" << version_str << "'.");
313+
return false;
314+
}
315+
316+
const auto& v = maybe_version.value();
317+
const bool in_range = kMinSupportedExployVersion <= v && v <= kMaxSupportedExployVersion;
318+
if (!in_range) {
319+
LOG_STREAM(ERROR, "exploy_version '" << version_str << "' is outside the supported range ["
320+
<< kMinSupportedExployVersion.toString() << ", "
321+
<< kMaxSupportedExployVersion.toString() << "].");
322+
return false;
323+
}
324+
return true;
325+
}
326+
252327
} // namespace exploy::control::metadata

control/test/metadata_test.cpp

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright (c) 2026 Robotics and AI Institute LLC dba RAI Institute. All rights reserved.
2+
3+
#include "metadata.hpp"
4+
5+
#include <gtest/gtest.h>
6+
7+
namespace exploy::control::metadata {
8+
9+
// ========== Version::toString ==========
10+
11+
TEST(VersionTest, ToString) {
12+
EXPECT_EQ((Version{1, 2, 3}.toString()), "1.2.3");
13+
EXPECT_EQ((Version{0, 0, 0}.toString()), "0.0.0");
14+
EXPECT_EQ((Version{10, 20, 30}.toString()), "10.20.30");
15+
}
16+
17+
// ========== Version::operator<= ==========
18+
19+
TEST(VersionTest, ComparisonEqualVersions) {
20+
EXPECT_TRUE((Version{1, 2, 3} <= Version{1, 2, 3}));
21+
EXPECT_TRUE((Version{0, 0, 0} <= Version{0, 0, 0}));
22+
}
23+
24+
TEST(VersionTest, ComparisonMajorDiffers) {
25+
EXPECT_TRUE((Version{0, 9, 9} <= Version{1, 0, 0}));
26+
EXPECT_FALSE((Version{2, 0, 0} <= Version{1, 9, 9}));
27+
}
28+
29+
TEST(VersionTest, ComparisonMinorDiffers) {
30+
EXPECT_TRUE((Version{1, 1, 9} <= Version{1, 2, 0}));
31+
EXPECT_FALSE((Version{1, 3, 0} <= Version{1, 2, 9}));
32+
}
33+
34+
TEST(VersionTest, ComparisonPatchDiffers) {
35+
EXPECT_TRUE((Version{1, 2, 2} <= Version{1, 2, 3}));
36+
EXPECT_FALSE((Version{1, 2, 4} <= Version{1, 2, 3}));
37+
}
38+
39+
// ========== parseVersion ==========
40+
41+
TEST(ParseVersionTest, ValidVersion) {
42+
auto v = parseVersion("1.2.3");
43+
ASSERT_TRUE(v.has_value());
44+
EXPECT_EQ(v->major, 1);
45+
EXPECT_EQ(v->minor, 2);
46+
EXPECT_EQ(v->patch, 3);
47+
}
48+
49+
TEST(ParseVersionTest, ZeroVersion) {
50+
auto v = parseVersion("0.0.0");
51+
ASSERT_TRUE(v.has_value());
52+
EXPECT_EQ(v->major, 0);
53+
EXPECT_EQ(v->minor, 0);
54+
EXPECT_EQ(v->patch, 0);
55+
}
56+
57+
TEST(ParseVersionTest, LargeNumbers) {
58+
auto v = parseVersion("10.20.30");
59+
ASSERT_TRUE(v.has_value());
60+
EXPECT_EQ(v->major, 10);
61+
EXPECT_EQ(v->minor, 20);
62+
EXPECT_EQ(v->patch, 30);
63+
}
64+
65+
TEST(ParseVersionTest, EmptyString) {
66+
EXPECT_FALSE(parseVersion("").has_value());
67+
}
68+
69+
TEST(ParseVersionTest, MissingPatch) {
70+
EXPECT_FALSE(parseVersion("1.2").has_value());
71+
}
72+
73+
TEST(ParseVersionTest, ExtraComponent) {
74+
EXPECT_FALSE(parseVersion("1.2.3.4").has_value());
75+
}
76+
77+
TEST(ParseVersionTest, NonNumericComponents) {
78+
EXPECT_FALSE(parseVersion("a.b.c").has_value());
79+
}
80+
81+
TEST(ParseVersionTest, NegativeComponent) {
82+
EXPECT_FALSE(parseVersion("1.2.-3").has_value());
83+
}
84+
85+
TEST(ParseVersionTest, WithVPrefix) {
86+
EXPECT_FALSE(parseVersion("v1.2.3").has_value());
87+
}
88+
89+
// ========== checkExployVersion ==========
90+
91+
TEST(CheckExployVersionTest, MissingMetadata) {
92+
EXPECT_FALSE(checkExployVersion(std::nullopt));
93+
}
94+
95+
TEST(CheckExployVersionTest, InvalidJson) {
96+
EXPECT_FALSE(checkExployVersion("not_valid_json"));
97+
}
98+
99+
TEST(CheckExployVersionTest, JsonNotAString) {
100+
// JSON-encoded number instead of a string
101+
EXPECT_FALSE(checkExployVersion("42"));
102+
}
103+
104+
TEST(CheckExployVersionTest, InvalidSemver) {
105+
// JSON-encoded string that is not a valid semver
106+
EXPECT_FALSE(checkExployVersion("\"abc\""));
107+
}
108+
109+
TEST(CheckExployVersionTest, VersionBelowMinimum) {
110+
// kMinSupportedExployVersion is 0.1.0, so 0.0.9 is below the minimum
111+
EXPECT_FALSE(checkExployVersion("\"0.0.9\""));
112+
}
113+
114+
TEST(CheckExployVersionTest, VersionAboveMaximum) {
115+
// kMaxSupportedExployVersion is 0.1.0, so 0.2.0 is above the maximum
116+
EXPECT_FALSE(checkExployVersion("\"0.2.0\""));
117+
}
118+
119+
TEST(CheckExployVersionTest, SupportedVersion) {
120+
// kMinSupportedExployVersion == kMaxSupportedExployVersion == 0.1.0
121+
EXPECT_TRUE(checkExployVersion("\"0.1.0\""));
122+
}
123+
124+
} // namespace exploy::control::metadata

control/test/testdata/test_onnx_generator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ def get_articulation_metadata() -> dict:
214214
def get_env_metadata() -> dict:
215215
"""Returns metadata for environment configuration."""
216216
return {
217+
"exploy_version": "0.1.0",
217218
"update_rate": 10.0,
218219
}
219220

exploy/exporter/core/exporter.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from exploy.exporter.core.exportable_environment import ExportableEnvironment
1313
from exploy.exporter.core.utils.onnx import construct_decimation_wrapper
14-
from exploy.exporter.core.utils.paths import prepare_onnx_paths
14+
from exploy.exporter.core.utils.paths import get_exploy_version, prepare_onnx_paths
1515

1616

1717
def export_environment_as_onnx(
@@ -239,6 +239,8 @@ def export(
239239
wrapper_model = construct_decimation_wrapper(
240240
model_a=onnx.load(str(export_paths.get_debug_path("default"))),
241241
model_b=onnx.load(str(export_paths.get_debug_path("process_actions"))),
242+
name_a="default",
243+
name_b="process_actions",
242244
decimation=self._env.decimation,
243245
opset_version=self._opset_version,
244246
ir_version=self._ir_version,
@@ -258,6 +260,11 @@ def export(
258260
meta.key = "date_exported (YYMMDD.HHMMSS)"
259261
meta.value = str(datetime.datetime.now().strftime("%y%m%d.%H%M%S"))
260262

263+
# Exploy version.
264+
meta = onnx_model.metadata_props.add()
265+
meta.key = "exploy_version"
266+
meta.value = json.dumps(get_exploy_version())
267+
261268
# Environment metadata.
262269
for key, value in self._env.metadata().items():
263270
meta = onnx_model.metadata_props.add()

exploy/exporter/core/utils/onnx.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ def _copy_value_info(value_info: onnx.ValueInfoProto) -> onnx.ValueInfoProto:
2121
def construct_decimation_wrapper(
2222
model_a: onnx.ModelProto,
2323
model_b: onnx.ModelProto,
24+
name_a: str,
25+
name_b: str,
2426
decimation: int,
2527
opset_version: int,
2628
ir_version: int,
@@ -30,7 +32,11 @@ def construct_decimation_wrapper(
3032
Args:
3133
model_a: ONNX submodel for decimation event.
3234
model_b: ONNX submodel for other steps.
35+
name_a: Name for model_a graph.
36+
name_b: Name for model_b graph.
3337
decimation: Decimation factor.
38+
opset_version: ONNX opset version to use.
39+
ir_version: ONNX IR version to use.
3440
Returns:
3541
An ONNX ModelProto with fixed periodic conditional branching.
3642
"""
@@ -51,6 +57,9 @@ def construct_decimation_wrapper(
5157
mod_node = onnx.helper.make_node("Mod", ["ctx.step_count", "decimation"], ["is_event"])
5258
eq_node = onnx.helper.make_node("Equal", ["is_event", "zero"], ["cond"])
5359

60+
model_a.graph.name = name_a
61+
model_b.graph.name = name_b
62+
5463
# Remove submodel inputs (will be passed by parent graph)
5564
for g in (model_a.graph, model_b.graph):
5665
del g.input[:]

exploy/exporter/core/utils/paths.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright (c) 2026 Robotics and AI Institute LLC dba RAI Institute. All rights reserved.
22

3+
import importlib.metadata
34
import pathlib
45
from dataclasses import dataclass
56

@@ -16,6 +17,18 @@
1617
ONNX_EXTENSION = ".onnx"
1718

1819

20+
def get_exploy_version() -> str:
21+
"""Get the installed exploy-exporter-core package version.
22+
23+
Returns:
24+
Version string, or "unknown" if the package metadata is not available.
25+
"""
26+
try:
27+
return importlib.metadata.version("exploy-exporter-core")
28+
except importlib.metadata.PackageNotFoundError:
29+
return "unknown"
30+
31+
1932
def _ensure_onnx_extension(filename: str) -> pathlib.Path:
2033
"""Ensure filename has .onnx extension.
2134

0 commit comments

Comments
 (0)