diff --git a/src/ai/FeatureSchema.cpp b/src/ai/FeatureSchema.cpp index aa661c8..05e0a17 100644 --- a/src/ai/FeatureSchema.cpp +++ b/src/ai/FeatureSchema.cpp @@ -9,6 +9,53 @@ double vectorValueOrDefault(const std::vector& values, const size_t inde return index < values.size() ? values[index] : 0.0; } +struct SemanticVersion { + int major = 0; + int minor = 0; + int patch = 0; + bool valid = false; +}; + +SemanticVersion parseVersion(const std::string& versionStr) { + SemanticVersion version; + + if (versionStr.empty()) { + return version; + } + + size_t pos = 0; + size_t dotPos = versionStr.find('.'); + + try { + // Parse major version + if (dotPos == std::string::npos) { + // Partial version with only major - not valid for strict semver + return version; + } + + version.major = std::stoi(versionStr.substr(pos, dotPos - pos)); + pos = dotPos + 1; + + // Parse minor version + dotPos = versionStr.find('.', pos); + if (dotPos == std::string::npos) { + // Partial version with major.minor only - not valid for strict semver + return version; + } + + version.minor = std::stoi(versionStr.substr(pos, dotPos - pos)); + pos = dotPos + 1; + + // Parse patch version + version.patch = std::stoi(versionStr.substr(pos)); + version.valid = true; + } catch (...) { + version.valid = false; + } + + return version; +} + } // namespace const std::vector& FeatureSchemaV1::names() { @@ -83,7 +130,33 @@ const std::vector& FeatureSchemaV1::names() { return kNames; } -bool FeatureSchemaV1::isCompatible(const std::string& version) { return version == kVersion; } +bool FeatureSchemaV1::isCompatible(const std::string& version) { + const auto current = parseVersion(kVersion); + const auto provided = parseVersion(version); + + // Both versions must be valid + if (!current.valid || !provided.valid) { + return false; + } + + // Major version must match exactly (breaking changes) + if (provided.major != current.major) { + return false; + } + + // Minor version must be >= current (backward compatible) + if (provided.minor < current.minor) { + return false; + } + + // If minor version is greater, patch doesn't matter (newer compatible version) + if (provided.minor > current.minor) { + return true; + } + + // Minor versions match, so patch must be >= current + return provided.patch >= current.patch; +} size_t FeatureSchemaV1::featureCount() { return names().size(); } diff --git a/tests/unit/AiExtensionTests.cpp b/tests/unit/AiExtensionTests.cpp index 485d11d..d0fd135 100644 --- a/tests/unit/AiExtensionTests.cpp +++ b/tests/unit/AiExtensionTests.cpp @@ -161,6 +161,40 @@ TEST_CASE("Feature schema exposes rich feature vector for AI plans", "[ai]") { REQUIRE(automix::ai::FeatureSchemaV1::featureCount() >= 20); } +TEST_CASE("Feature schema version compatibility uses semantic versioning", "[ai]") { + // Exact version match should be compatible + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.0.0")); + + // Patch version updates should be compatible (backward compatible) + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.0.1")); + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.0.2")); + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.0.99")); + + // Minor version updates should be compatible (backward compatible) + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.1.0")); + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.2.0")); + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.99.0")); + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.1.5")); + + // Different major version should be incompatible (breaking changes) + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("0.9.0")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("2.0.0")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("2.1.0")); + + // Invalid or malformed versions should be incompatible + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("invalid")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("1.x.0")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("a.b.c")); + + // Partial versions (missing components) should be rejected per strict semver + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("1")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("1.0")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("1.1")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("0")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("2")); +} + TEST_CASE("Model manager scans demo packs from assets roots", "[ai]") { automix::ai::ModelManager manager("missing_root_for_test"); const auto packs = manager.scan();