From 61674315e25ca8c61ba9f88fced4532de22f120d Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Wed, 22 Apr 2026 07:59:50 -0400 Subject: [PATCH 01/28] WIP: Updating MultiThresholdObjects and unit tests * Updated MultiThresholdObjects algorithm to account for the IsInverted state allowed by individual thresholds. * WIP: Replacing unit tests with smaller datasets and standardized testing functions. Integer and floating point single component DataArrays are tested for all comparison types and inversion states using a single threshold. Multicomponent arrays are in the process of being tested and the filter was updated for assumptions that may be wrong. The documentation and GUI need to be referenced before moving forward. Multicomponent threshold tests and threshold creation will likely need to be adjusted based on new information. * TODO: Create tests for entire threshold sets and even nested sets. --- .../Algorithms/MultiThresholdObjects.cpp | 16 +- .../Filters/MultiThresholdObjectsFilter.cpp | 4 +- .../test/MultiThresholdObjectsTest.cpp | 557 +++++++++++++++++- 3 files changed, 554 insertions(+), 23 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index 48ea54b9d7..aac4fece04 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -15,11 +15,12 @@ template class ThresholdFilterHelper { public: - ThresholdFilterHelper(ArrayThreshold::ComparisonType compType, ArrayThreshold::ComparisonValue compValue, usize componentIndex, std::vector& output) + ThresholdFilterHelper(ArrayThreshold::ComparisonType compType, ArrayThreshold::ComparisonValue compValue, usize componentIndex, std::vector& output, bool isInverted) : m_ComparisonOperator(compType) , m_ComparisonValue(compValue) , m_ComponentIndex(componentIndex) , m_Output(output) + , m_IsInverted(isInverted) { } @@ -31,7 +32,12 @@ class ThresholdFilterHelper for(size_t tupleIndex = 0; tupleIndex < numTuples; ++tupleIndex) { T inputValue = m_Input.getComponentValue(tupleIndex, m_ComponentIndex); - T outputValue = CompT{}(inputValue, value) ? trueValue : falseValue; + bool comparison = CompT{}(inputValue, value); + if (m_IsInverted) + { + comparison = !comparison; + } + T outputValue = comparison ? trueValue : falseValue; m_Output[tupleIndex] = outputValue; } } @@ -67,6 +73,7 @@ class ThresholdFilterHelper ArrayThreshold::ComparisonValue m_ComparisonValue; usize m_ComponentIndex = 0; std::vector& m_Output; + bool m_IsInverted = false; }; struct ExecuteThresholdHelper @@ -120,12 +127,13 @@ void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& nx::core::ArrayThreshold::ComparisonType compOperator = comparisonValue.getComparisonType(); nx::core::ArrayThreshold::ComparisonValue compValue = comparisonValue.getComparisonValue(); nx::core::IArrayThreshold::UnionOperator unionOperator = comparisonValue.getUnionOperator(); + bool isInverted = comparisonValue.isInverted(); DataPath inputDataArrayPath = comparisonValue.getArrayPath(); usize componentIndex = comparisonValue.getComponentIndex(); - ThresholdFilterHelper helper(compOperator, compValue, componentIndex, tempResultVector); + ThresholdFilterHelper helper(compOperator, compValue, componentIndex, tempResultVector, isInverted); const auto& iDataArray = dataStructure.getDataRefAs(inputDataArrayPath); @@ -259,12 +267,14 @@ Result<> MultiThresholdObjects::operator()() const IArrayThreshold* thresholdPtr = threshold.get(); if(const auto* comparisonSet = dynamic_cast(thresholdPtr); comparisonSet != nullptr) { + // Do not replace values on first threshold, update firstValueFound to reflect that a threshold has been run. ExecuteDataFunction(ThresholdSetFunctor{}, maskArrayType, *comparisonSet, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), err, !firstValueFound, thresholdsObject.isInverted(), trueValue, falseValue); firstValueFound = true; } else if(const auto* comparisonValue = dynamic_cast(thresholdPtr); comparisonValue != nullptr) { + // Do not replace values on first threshold, update firstValueFound to reflect that a threshold has been run. ExecuteDataFunction(ThresholdValueFunctor{}, maskArrayType, *comparisonValue, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), err, !firstValueFound, thresholdsObject.isInverted(), trueValue, falseValue); firstValueFound = true; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp index 85c1535dc6..d0840085b3 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp @@ -223,8 +223,8 @@ IFilter::PreflightResult MultiThresholdObjectsFilter::preflightImpl(const DataSt } // Create the output boolean array - auto action = - std::make_unique(maskArrayType, dataArray.getIDataStoreRef().getTupleShape(), std::vector{1}, firstDataPath.replaceName(maskArrayName), dataArray.getDataFormat()); + const auto& dataStore = dataArray.getIDataStoreRef(); + auto action = std::make_unique(maskArrayType, dataStore.getTupleShape(), dataStore.getComponentShape(), firstDataPath.replaceName(maskArrayName), dataArray.getDataFormat()); OutputActions actions; actions.appendAction(std::move(action)); diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index da9ded6ad8..e3fad80dae 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -31,20 +31,39 @@ const DataPath k_ThresholdArrayPath = k_ImageCellDataName.createChildPath(k_Thre const DataPath k_MismatchingComponentsArrayPath = k_ImageCellDataName.createChildPath("MismatchingComponentsArray"); const DataPath k_MismatchingTuplesArrayPath({"MismatchingTuplesArray"}); +constexpr int8 k_TupleCount = 5; +constexpr int8 k_MultiComponentCount = 3; + +constexpr float64 k_FloatValueIncrement = 0.01; + +constexpr int32 InputIntValue(int32 index) +{ + return index; +} + +constexpr int32 InputIntComponentValue(int32 tuple, int32 component) +{ + return (tuple + component) % 2 == 0 ? -tuple : tuple; +} + +constexpr float64 InputFloatValue(int32 index) +{ + return (index + 1) * k_FloatValueIncrement; +} + DataStructure CreateTestDataStructure() { DataStructure dataStructure; // Create two test arrays, a float array and a int array // Set up geometry for tuples, a cuboid with dimensions 20, 10, 1 ImageGeom* image = ImageGeom::Create(dataStructure, k_ImageGeometry); - std::vector dims = {20, 1, 1}; + std::vector dims = {k_TupleCount, 1, 1}; image->setDimensions(dims); - ShapeType tDims = {20}; + ShapeType tDims = {k_TupleCount}; ShapeType cDims = {1}; - ShapeType cDimsMulti = {3}; - float fnum = 0.0f; - int inum = 0; + ShapeType cDimsMulti = {k_MultiComponentCount}; + AttributeMatrix* am = AttributeMatrix::Create(dataStructure, k_CellData, tDims, image->getId()); Float32Array* data = Float32Array::CreateWithStore(dataStructure, k_TestArrayFloatName, tDims, cDims, am->getId()); Int32Array* data1 = Int32Array::CreateWithStore(dataStructure, k_TestArrayIntName, tDims, cDims, am->getId()); @@ -56,25 +75,329 @@ DataStructure CreateTestDataStructure() invalid2->fill(2.0); usize numComponents = multiComponentData->getNumberOfComponents(); - int32 sign = 1; - // Fill the float array with {.01,.02,.03,.04,.05,.06,.07,.08,.09,.10,.11,.12,.13,.14,.15.,16,.17,.18,.19,.20} - // Fill the int array with { 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 } - // Fill multi-component array with {{0, 0, 0}, {1, -1, 1}, {-2, 2, -2}, ..., {17, -17, 17}, {-18, 18, -18}, {19, -19, 19}} - for(usize i = 0; i < 20; i++) + // Fill the float array with {.01,.02,.03,.04,.05} + // Fill the int array with { 0,1,2,3,4} + // Fill multi-component array with {{0, 0, 0}, {1, -1, 1}, {-2, 2, -2}, {3, -3, 3}, {-4, 4, -4}} + for(usize i = 0; i < k_TupleCount; i++) { - fnum += 0.01f; - (*data)[i] = fnum; // float array - (*data1)[i] = inum; // int array - multiComponentData->setComponent(i, 0, i * -sign); - multiComponentData->setComponent(i, 1, i * sign); - multiComponentData->setComponent(i, 2, i * -sign); - sign *= -1; - ++inum; + (*data)[i] = InputFloatValue(i); // float array + (*data1)[i] = InputIntValue(i); // int array + + for(usize j = 0; j < k_MultiComponentCount; j++) + { + multiComponentData->setComponent(i, j, InputIntComponentValue(i, j)); + } } return dataStructure; } +ArrayThresholdSet CreateSingleThreshold(const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted) +{ + ArrayThresholdSet thresholdSet; + auto threshold = std::make_shared(); + threshold->setArrayPath(arrayPath); + threshold->setComparisonType(comparisonType); + threshold->setComparisonValue(value); + threshold->setInverted(isInverted); + thresholdSet.setArrayThresholds({threshold}); + + return thresholdSet; +} + +void RunSingleThresholdTest(DataStructure& dataStructure, const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted) +{ + MultiThresholdObjectsFilter filter; + Arguments args; + + auto thresholdSet = CreateSingleThreshold(arrayPath, comparisonType, value, isInverted); + + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); + args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::boolean)); + + // Preflight the filter and check result + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + // Execute the filter and check the result + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + // Require the input and output arrays to have an equal number of components + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + const auto* inputArrayPtr = dataStructure.getDataAs(arrayPath); + REQUIRE(inputArrayPtr->getNumberOfComponents() == thresholdArrayPtr->getNumberOfComponents()); +} + +void CheckIntTestDataGreaterThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool value = thresholdStore[i]; + bool expected = InputIntValue(i) > thresholdValue; + + if(isInverted) + { + expected = !expected; + } + + REQUIRE(value == expected); + } +} + +void CheckFloatTestDataGreaterThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool value = thresholdStore[i]; + bool expected = InputFloatValue(i) > thresholdValue; + + if(isInverted) + { + expected = !expected; + } + + REQUIRE(value == expected); + } +} + +void CheckIntTestDataGreaterThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + for(usize j = 0; j < k_MultiComponentCount; j++) + { + bool value = thresholdStore[i * k_MultiComponentCount + j]; + bool expected = InputIntComponentValue(i, j) > thresholdValue; + + if(isInverted) + { + expected = !expected; + } + + REQUIRE(value == expected); + } + } +} + +void CheckIntTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool value = thresholdStore[i]; + bool expected = i < static_cast(thresholdValue); + + if(isInverted) + { + expected = !expected; + } + + REQUIRE(value == expected); + } +} + +void CheckFloatTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + float64 expectedValue = InputFloatValue(i); + bool value = thresholdStore[i]; + bool expected = InputFloatValue(i) < thresholdValue; + + if(isInverted) + { + expected = !expected; + } + + REQUIRE(value == expected); + } +} + +void CheckIntTestDataLessThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + for(usize j = 0; j < k_MultiComponentCount; j++) + { + bool value = thresholdStore[i * k_MultiComponentCount + j]; + bool expected = InputIntComponentValue(i, j) < thresholdValue; + + if(isInverted) + { + expected = !expected; + } + + REQUIRE(value == expected); + } + } +} + +void CheckIntTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool value = thresholdStore[i]; + bool expected = i == static_cast(thresholdValue); + + if(isInverted) + { + expected = !expected; + } + + REQUIRE(value == expected); + } +} + +void CheckIntTestDataEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + const auto& inputStore = dataStructure.getDataAs(k_MultiComponentArrayPath)->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + for(usize j = 0; j < k_MultiComponentCount; j++) + { + int32 inputValue = inputStore[i * k_MultiComponentCount + j]; + bool value = thresholdStore[i * k_MultiComponentCount + j]; + bool expected = InputIntComponentValue(i, j) == thresholdValue; + + if(isInverted) + { + expected = !expected; + } + + //REQUIRE(value == expected); + } + } +} + +void CheckFloatTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool value = thresholdStore[i]; + bool expected = InputFloatValue(i) == thresholdValue; + + if(isInverted) + { + expected = !expected; + } + + REQUIRE(value == expected); + } +} + +void CheckIntTestDataNotEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool value = thresholdStore[i]; + bool expected = i != thresholdValue; + + if(isInverted) + { + expected = !expected; + } + + REQUIRE(value == expected); + } +} + +void CheckIntTestDataNotEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + for(usize j = 0; j < k_MultiComponentCount; j++) + { + bool value = thresholdStore[i * k_MultiComponentCount + j]; + bool expected = InputIntComponentValue(i, j) != thresholdValue; + if(isInverted) + { + expected = !expected; + } + + REQUIRE(value == expected); + } + } +} + +void CheckFloatTestDataNotEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool value = thresholdStore[i]; + bool expected = InputFloatValue(i) != thresholdValue; + if(isInverted) + { + expected = !expected; + } + + REQUIRE(value == expected); + } +} + template float64 GetOutOfBoundsMinimumValue() { @@ -97,6 +420,203 @@ float64 GetOutOfBoundsMaximumValue() } } // namespace +TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int", "[SimplnxCore][MultiThresholdObjectsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = CreateTestDataStructure(); + const DataPath targetArray = k_TestArrayIntPath; + bool isInverted = false; + + SECTION("ArrayThreshold: >") + { + const double thresholdValue = 2.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); + CheckIntTestDataGreaterThanSingleComponent(dataStructure, thresholdValue, isInverted); + } + + SECTION("ArrayThreshold: <") + { + const double thresholdValue = 3.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); + CheckIntTestDataLessThanSingleComponent(dataStructure, thresholdValue, isInverted); + } + + SECTION("ArrayThreshold: ==") + { + const double thresholdValue = 3.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); + CheckIntTestDataEqualToSingleComponent(dataStructure, thresholdValue, isInverted); + CheckIntTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); + } + SECTION("ArrayThreshold: !=") + { + const double thresholdValue = 4.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); + CheckIntTestDataEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); + CheckIntTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, isInverted); + } +} + +TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int Inverted", "[SimplnxCore][MultiThresholdObjectsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = CreateTestDataStructure(); + const DataPath targetArray = k_TestArrayIntPath; + bool isInverted = true; + + SECTION("ArrayThreshold: >") + { + const double thresholdValue = 2.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); + CheckIntTestDataGreaterThanSingleComponent(dataStructure, thresholdValue, isInverted); + } + + SECTION("ArrayThreshold: <") + { + const double thresholdValue = 3.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); + CheckIntTestDataLessThanSingleComponent(dataStructure, thresholdValue, isInverted); + } + + SECTION("ArrayThreshold: ==") + { + const double thresholdValue = 3.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); + CheckIntTestDataEqualToSingleComponent(dataStructure, thresholdValue, isInverted); + CheckIntTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); + } + SECTION("ArrayThreshold: !=") + { + const double thresholdValue = 4.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); + CheckIntTestDataEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); + CheckIntTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, isInverted); + } +} + +TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Float", "[SimplnxCore][MultiThresholdObjectsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = CreateTestDataStructure(); + const DataPath targetArray = k_TestArrayFloatPath; + bool isInverted = false; + + // RunSingleComponentThresholdTests(dataStructure, k_TestArrayIntPath, 3.0, false); + SECTION("ArrayThreshold: >") + { + const double thresholdValue = 0.04; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); + CheckFloatTestDataGreaterThanSingleComponent(dataStructure, thresholdValue, isInverted); + } + + SECTION("ArrayThreshold: <") + { + const double thresholdValue = 0.02; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); + CheckFloatTestDataLessThanSingleComponent(dataStructure, thresholdValue, isInverted); + } + + SECTION("ArrayThreshold: ==") + { + const double thresholdValue = 0.03; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); + CheckFloatTestDataEqualToSingleComponent(dataStructure, thresholdValue, isInverted); + CheckFloatTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); + } + SECTION("ArrayThreshold: !=") + { + const double thresholdValue = 0.02; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); + CheckFloatTestDataEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); + CheckFloatTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, isInverted); + } +} + +TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Float Inverted", "[SimplnxCore][MultiThresholdObjectsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = CreateTestDataStructure(); + const DataPath targetArray = k_TestArrayFloatPath; + bool isInverted = true; + + // RunSingleComponentThresholdTests(dataStructure, k_TestArrayIntPath, 3.0, false); + SECTION("ArrayThreshold: >") + { + const double thresholdValue = 0.02; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); + CheckFloatTestDataGreaterThanSingleComponent(dataStructure, thresholdValue, isInverted); + } + + SECTION("ArrayThreshold: <") + { + const double thresholdValue = 0.03; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); + CheckFloatTestDataLessThanSingleComponent(dataStructure, thresholdValue, isInverted); + } + + SECTION("ArrayThreshold: ==") + { + const double thresholdValue = 0.02; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); + CheckFloatTestDataEqualToSingleComponent(dataStructure, thresholdValue, isInverted); + CheckFloatTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); + } + SECTION("ArrayThreshold: !=") + { + const double thresholdValue = 0.01; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); + CheckFloatTestDataEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); + CheckFloatTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, isInverted); + } +} + +TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int Multi-Component", "[SimplnxCore][MultiThresholdObjectsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = CreateTestDataStructure(); + const DataPath targetArray = k_MultiComponentArrayPath; + bool isInverted = false; + + SECTION("ArrayThreshold: >") + { + const double thresholdValue = 2.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); + CheckIntTestDataGreaterThanMultiComponent(dataStructure, thresholdValue, isInverted); + } + + SECTION("ArrayThreshold: <") + { + const double thresholdValue = 3.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); + CheckIntTestDataLessThanMultiComponent(dataStructure, thresholdValue, isInverted); + } + + SECTION("ArrayThreshold: ==") + { + const double thresholdValue = 3.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); + CheckIntTestDataEqualToMultiComponent(dataStructure, thresholdValue, isInverted); + CheckIntTestDataNotEqualToMultiComponent(dataStructure, thresholdValue, !isInverted); + } + SECTION("ArrayThreshold: !=") + { + const double thresholdValue = 4.0; + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); + CheckIntTestDataEqualToMultiComponent(dataStructure, thresholdValue, !isInverted); + CheckIntTestDataNotEqualToMultiComponent(dataStructure, thresholdValue, isInverted); + } +} + +/// +/// /////// +/// + +#if false TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution", "[SimplnxCore][MultiThresholdObjectsFilter]") { UnitTest::LoadPlugins(); @@ -779,6 +1299,7 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution - Multicomponent" UnitTest::CheckArraysInheritTupleDims(dataStructure); } +#endif TEST_CASE("SimplnxCore::MultiThresholdObjectsFilter: SIMPL Backwards Compatibility", "[SimplnxCore][MultiThresholdObjectsFilter][BackwardsCompatibility]") { From 4d322757df49dda0ff07120a3013d71d037ef12a Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Wed, 22 Apr 2026 12:01:53 -0400 Subject: [PATCH 02/28] Multi-component count correction * Mask array is always 1 component. * Update unit tests for multicomponent array thresholds --- .../Filters/MultiThresholdObjectsFilter.cpp | 2 +- .../test/MultiThresholdObjectsTest.cpp | 141 +++++++++--------- 2 files changed, 70 insertions(+), 73 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp index d0840085b3..9ebe5b283d 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp @@ -224,7 +224,7 @@ IFilter::PreflightResult MultiThresholdObjectsFilter::preflightImpl(const DataSt // Create the output boolean array const auto& dataStore = dataArray.getIDataStoreRef(); - auto action = std::make_unique(maskArrayType, dataStore.getTupleShape(), dataStore.getComponentShape(), firstDataPath.replaceName(maskArrayName), dataArray.getDataFormat()); + auto action = std::make_unique(maskArrayType, dataStore.getTupleShape(), std::vector{1}, firstDataPath.replaceName(maskArrayName), dataArray.getDataFormat()); OutputActions actions; actions.appendAction(std::move(action)); diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index e3fad80dae..519bb174e2 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -92,25 +92,26 @@ DataStructure CreateTestDataStructure() return dataStructure; } -ArrayThresholdSet CreateSingleThreshold(const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted) +ArrayThresholdSet CreateSingleThreshold(const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted, int componentIndex) { ArrayThresholdSet thresholdSet; auto threshold = std::make_shared(); threshold->setArrayPath(arrayPath); threshold->setComparisonType(comparisonType); threshold->setComparisonValue(value); + threshold->setComponentIndex(componentIndex); threshold->setInverted(isInverted); thresholdSet.setArrayThresholds({threshold}); return thresholdSet; } -void RunSingleThresholdTest(DataStructure& dataStructure, const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted) +void RunSingleThresholdTest(DataStructure& dataStructure, const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted, int32 componentIndex = 0) { MultiThresholdObjectsFilter filter; Arguments args; - auto thresholdSet = CreateSingleThreshold(arrayPath, comparisonType, value, isInverted); + auto thresholdSet = CreateSingleThreshold(arrayPath, comparisonType, value, isInverted, componentIndex); args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); @@ -124,12 +125,11 @@ void RunSingleThresholdTest(DataStructure& dataStructure, const DataPath& arrayP auto executeResult = filter.execute(dataStructure, args); SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - // Require the input and output arrays to have an equal number of components + // Require that the mask array only has one component const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); - const auto* inputArrayPtr = dataStructure.getDataAs(arrayPath); - REQUIRE(inputArrayPtr->getNumberOfComponents() == thresholdArrayPtr->getNumberOfComponents()); + REQUIRE(thresholdArrayPtr->getNumberOfComponents() == 1); } void CheckIntTestDataGreaterThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) @@ -174,7 +174,7 @@ void CheckFloatTestDataGreaterThanSingleComponent(const DataStructure& dataStruc } } -void CheckIntTestDataGreaterThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckIntTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -183,22 +183,19 @@ void CheckIntTestDataGreaterThanMultiComponent(const DataStructure& dataStructur for(usize i = 0; i < k_TupleCount; i++) { - for(usize j = 0; j < k_MultiComponentCount; j++) - { - bool value = thresholdStore[i * k_MultiComponentCount + j]; - bool expected = InputIntComponentValue(i, j) > thresholdValue; - - if(isInverted) - { - expected = !expected; - } + bool value = thresholdStore[i]; + bool expected = i < static_cast(thresholdValue); - REQUIRE(value == expected); + if(isInverted) + { + expected = !expected; } + + REQUIRE(value == expected); } } -void CheckIntTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckFloatTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -207,8 +204,9 @@ void CheckIntTestDataLessThanSingleComponent(const DataStructure& dataStructure, for(usize i = 0; i < k_TupleCount; i++) { + float64 expectedValue = InputFloatValue(i); bool value = thresholdStore[i]; - bool expected = i < static_cast(thresholdValue); + bool expected = InputFloatValue(i) < thresholdValue; if(isInverted) { @@ -219,7 +217,7 @@ void CheckIntTestDataLessThanSingleComponent(const DataStructure& dataStructure, } } -void CheckFloatTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckIntTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -228,9 +226,8 @@ void CheckFloatTestDataLessThanSingleComponent(const DataStructure& dataStructur for(usize i = 0; i < k_TupleCount; i++) { - float64 expectedValue = InputFloatValue(i); bool value = thresholdStore[i]; - bool expected = InputFloatValue(i) < thresholdValue; + bool expected = i == static_cast(thresholdValue); if(isInverted) { @@ -241,7 +238,7 @@ void CheckFloatTestDataLessThanSingleComponent(const DataStructure& dataStructur } } -void CheckIntTestDataLessThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckFloatTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -250,22 +247,19 @@ void CheckIntTestDataLessThanMultiComponent(const DataStructure& dataStructure, for(usize i = 0; i < k_TupleCount; i++) { - for(usize j = 0; j < k_MultiComponentCount; j++) - { - bool value = thresholdStore[i * k_MultiComponentCount + j]; - bool expected = InputIntComponentValue(i, j) < thresholdValue; - - if(isInverted) - { - expected = !expected; - } + bool value = thresholdStore[i]; + bool expected = InputFloatValue(i) == thresholdValue; - REQUIRE(value == expected); + if(isInverted) + { + expected = !expected; } + + REQUIRE(value == expected); } } -void CheckIntTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckIntTestDataNotEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -275,7 +269,7 @@ void CheckIntTestDataEqualToSingleComponent(const DataStructure& dataStructure, for(usize i = 0; i < k_TupleCount; i++) { bool value = thresholdStore[i]; - bool expected = i == static_cast(thresholdValue); + bool expected = i != thresholdValue; if(isInverted) { @@ -286,34 +280,31 @@ void CheckIntTestDataEqualToSingleComponent(const DataStructure& dataStructure, } } -void CheckIntTestDataEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +// Multi-component checks + +void CheckIntTestDataGreaterThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); - const auto& inputStore = dataStructure.getDataAs(k_MultiComponentArrayPath)->getDataStoreRef(); - for(usize i = 0; i < k_TupleCount; i++) { - for(usize j = 0; j < k_MultiComponentCount; j++) - { - int32 inputValue = inputStore[i * k_MultiComponentCount + j]; - bool value = thresholdStore[i * k_MultiComponentCount + j]; - bool expected = InputIntComponentValue(i, j) == thresholdValue; + usize arrayIndex = i * k_MultiComponentCount + componentIndex; + bool value = thresholdStore[arrayIndex]; + bool expected = InputIntComponentValue(i, componentIndex) > thresholdValue; - if(isInverted) - { - expected = !expected; - } - - //REQUIRE(value == expected); + if(isInverted) + { + expected = !expected; } + + REQUIRE(value == expected); } } -void CheckFloatTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckIntTestDataLessThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -322,8 +313,9 @@ void CheckFloatTestDataEqualToSingleComponent(const DataStructure& dataStructure for(usize i = 0; i < k_TupleCount; i++) { - bool value = thresholdStore[i]; - bool expected = InputFloatValue(i) == thresholdValue; + usize arrayIndex = i * k_MultiComponentCount + componentIndex; + bool value = thresholdStore[arrayIndex]; + bool expected = InputIntComponentValue(i, componentIndex) < thresholdValue; if(isInverted) { @@ -334,17 +326,21 @@ void CheckFloatTestDataEqualToSingleComponent(const DataStructure& dataStructure } } -void CheckIntTestDataNotEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckIntTestDataEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + const auto& inputStore = dataStructure.getDataAs(k_MultiComponentArrayPath)->getDataStoreRef(); + for(usize i = 0; i < k_TupleCount; i++) { - bool value = thresholdStore[i]; - bool expected = i != thresholdValue; + usize arrayIndex = i * k_MultiComponentCount + componentIndex; + int32 inputValue = inputStore[arrayIndex]; // store value for breakpoint testing purposes. + bool value = thresholdStore[arrayIndex]; + bool expected = InputIntComponentValue(i, componentIndex) == thresholdValue; if(isInverted) { @@ -355,7 +351,7 @@ void CheckIntTestDataNotEqualToSingleComponent(const DataStructure& dataStructur } } -void CheckIntTestDataNotEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckIntTestDataNotEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -364,17 +360,16 @@ void CheckIntTestDataNotEqualToMultiComponent(const DataStructure& dataStructure for(usize i = 0; i < k_TupleCount; i++) { - for(usize j = 0; j < k_MultiComponentCount; j++) - { - bool value = thresholdStore[i * k_MultiComponentCount + j]; - bool expected = InputIntComponentValue(i, j) != thresholdValue; - if(isInverted) - { - expected = !expected; - } + usize arrayIndex = i * k_MultiComponentCount + componentIndex; + bool value = thresholdStore[arrayIndex]; + bool expected = InputIntComponentValue(i, componentIndex) != thresholdValue; - REQUIRE(value == expected); + if(isInverted) + { + expected = !expected; } + + REQUIRE(value == expected); } } @@ -389,6 +384,7 @@ void CheckFloatTestDataNotEqualToSingleComponent(const DataStructure& dataStruct { bool value = thresholdStore[i]; bool expected = InputFloatValue(i) != thresholdValue; + if(isInverted) { expected = !expected; @@ -581,34 +577,35 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int Mult DataStructure dataStructure = CreateTestDataStructure(); const DataPath targetArray = k_MultiComponentArrayPath; bool isInverted = false; + int32 componentIndex = GENERATE(0, 1, 2); SECTION("ArrayThreshold: >") { const double thresholdValue = 2.0; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); - CheckIntTestDataGreaterThanMultiComponent(dataStructure, thresholdValue, isInverted); + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted, componentIndex); + CheckIntTestDataGreaterThanMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); } SECTION("ArrayThreshold: <") { const double thresholdValue = 3.0; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); - CheckIntTestDataLessThanMultiComponent(dataStructure, thresholdValue, isInverted); + CheckIntTestDataLessThanMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); } SECTION("ArrayThreshold: ==") { const double thresholdValue = 3.0; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); - CheckIntTestDataEqualToMultiComponent(dataStructure, thresholdValue, isInverted); - CheckIntTestDataNotEqualToMultiComponent(dataStructure, thresholdValue, !isInverted); + CheckIntTestDataEqualToMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); + CheckIntTestDataNotEqualToMultiComponent(dataStructure, thresholdValue, !isInverted, componentIndex); } SECTION("ArrayThreshold: !=") { const double thresholdValue = 4.0; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); - CheckIntTestDataEqualToMultiComponent(dataStructure, thresholdValue, !isInverted); - CheckIntTestDataNotEqualToMultiComponent(dataStructure, thresholdValue, isInverted); + CheckIntTestDataEqualToMultiComponent(dataStructure, thresholdValue, !isInverted, componentIndex); + CheckIntTestDataNotEqualToMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); } } From cf61eacbde23aed8988e645529796566224d5177 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Wed, 22 Apr 2026 12:46:49 -0400 Subject: [PATCH 03/28] Updated single threshold tests * Consolidated unit tests of the same array type and component count using GENERATE. * Added additional value checks. * All single threshold tests pass. --- .../test/MultiThresholdObjectsTest.cpp | 189 ++++++------------ 1 file changed, 58 insertions(+), 131 deletions(-) diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index 519bb174e2..25728ada0d 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -92,6 +92,14 @@ DataStructure CreateTestDataStructure() return dataStructure; } +/** +* @brief Creates a single threshold for the filter to use. +* @param arrayPath Input DataArray path +* @param comparisonType type of comparison +* @param value Value to threshold against +* @param isInverted Should the threshold output be inverted +* componentIndex Component index of the array to threshold against. +*/ ArrayThresholdSet CreateSingleThreshold(const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted, int componentIndex) { ArrayThresholdSet thresholdSet; @@ -106,6 +114,15 @@ ArrayThresholdSet CreateSingleThreshold(const DataPath& arrayPath, ArrayThreshol return thresholdSet; } +/** + * @brief + * @param dataStructure + * @param arrayPath Path to use for the threshold DataArray + * @param comparisonType Type of comparison to perform + * @param value Value to threshold against + * @param isInverted should the output mask value be inverted + * @param componentIndex Which component of the array the threshold should use. + */ void RunSingleThresholdTest(DataStructure& dataStructure, const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted, int32 componentIndex = 0) { MultiThresholdObjectsFilter filter; @@ -132,6 +149,7 @@ void RunSingleThresholdTest(DataStructure& dataStructure, const DataPath& arrayP REQUIRE(thresholdArrayPtr->getNumberOfComponents() == 1); } +// Integer checks void CheckIntTestDataGreaterThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); @@ -153,7 +171,7 @@ void CheckIntTestDataGreaterThanSingleComponent(const DataStructure& dataStructu } } -void CheckFloatTestDataGreaterThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckIntTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -163,7 +181,7 @@ void CheckFloatTestDataGreaterThanSingleComponent(const DataStructure& dataStruc for(usize i = 0; i < k_TupleCount; i++) { bool value = thresholdStore[i]; - bool expected = InputFloatValue(i) > thresholdValue; + bool expected = InputIntValue(i) < thresholdValue; if(isInverted) { @@ -174,7 +192,7 @@ void CheckFloatTestDataGreaterThanSingleComponent(const DataStructure& dataStruc } } -void CheckIntTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckIntTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -184,7 +202,7 @@ void CheckIntTestDataLessThanSingleComponent(const DataStructure& dataStructure, for(usize i = 0; i < k_TupleCount; i++) { bool value = thresholdStore[i]; - bool expected = i < static_cast(thresholdValue); + bool expected = InputIntValue(i) == thresholdValue; if(isInverted) { @@ -195,7 +213,7 @@ void CheckIntTestDataLessThanSingleComponent(const DataStructure& dataStructure, } } -void CheckFloatTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckIntTestDataNotEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -204,9 +222,8 @@ void CheckFloatTestDataLessThanSingleComponent(const DataStructure& dataStructur for(usize i = 0; i < k_TupleCount; i++) { - float64 expectedValue = InputFloatValue(i); bool value = thresholdStore[i]; - bool expected = InputFloatValue(i) < thresholdValue; + bool expected = InputIntValue(i) != thresholdValue; if(isInverted) { @@ -217,7 +234,9 @@ void CheckFloatTestDataLessThanSingleComponent(const DataStructure& dataStructur } } -void CheckIntTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +// Floating point checks + +void CheckFloatTestDataGreaterThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -227,7 +246,7 @@ void CheckIntTestDataEqualToSingleComponent(const DataStructure& dataStructure, for(usize i = 0; i < k_TupleCount; i++) { bool value = thresholdStore[i]; - bool expected = i == static_cast(thresholdValue); + bool expected = InputFloatValue(i) > thresholdValue; if(isInverted) { @@ -238,7 +257,7 @@ void CheckIntTestDataEqualToSingleComponent(const DataStructure& dataStructure, } } -void CheckFloatTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckFloatTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -247,8 +266,9 @@ void CheckFloatTestDataEqualToSingleComponent(const DataStructure& dataStructure for(usize i = 0; i < k_TupleCount; i++) { + float64 expectedValue = InputFloatValue(i); bool value = thresholdStore[i]; - bool expected = InputFloatValue(i) == thresholdValue; + bool expected = InputFloatValue(i) < thresholdValue; if(isInverted) { @@ -259,7 +279,7 @@ void CheckFloatTestDataEqualToSingleComponent(const DataStructure& dataStructure } } -void CheckIntTestDataNotEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckFloatTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -269,7 +289,7 @@ void CheckIntTestDataNotEqualToSingleComponent(const DataStructure& dataStructur for(usize i = 0; i < k_TupleCount; i++) { bool value = thresholdStore[i]; - bool expected = i != thresholdValue; + bool expected = InputFloatValue(i) == thresholdValue; if(isInverted) { @@ -280,9 +300,7 @@ void CheckIntTestDataNotEqualToSingleComponent(const DataStructure& dataStructur } } -// Multi-component checks - -void CheckIntTestDataGreaterThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) +void CheckFloatTestDataNotEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -291,9 +309,8 @@ void CheckIntTestDataGreaterThanMultiComponent(const DataStructure& dataStructur for(usize i = 0; i < k_TupleCount; i++) { - usize arrayIndex = i * k_MultiComponentCount + componentIndex; - bool value = thresholdStore[arrayIndex]; - bool expected = InputIntComponentValue(i, componentIndex) > thresholdValue; + bool value = thresholdStore[i]; + bool expected = InputFloatValue(i) != thresholdValue; if(isInverted) { @@ -304,7 +321,9 @@ void CheckIntTestDataGreaterThanMultiComponent(const DataStructure& dataStructur } } -void CheckIntTestDataLessThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) +// Multi-component checks + +void CheckIntTestDataGreaterThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -313,9 +332,8 @@ void CheckIntTestDataLessThanMultiComponent(const DataStructure& dataStructure, for(usize i = 0; i < k_TupleCount; i++) { - usize arrayIndex = i * k_MultiComponentCount + componentIndex; - bool value = thresholdStore[arrayIndex]; - bool expected = InputIntComponentValue(i, componentIndex) < thresholdValue; + bool value = thresholdStore[i]; + bool expected = InputIntComponentValue(i, componentIndex) > thresholdValue; if(isInverted) { @@ -326,21 +344,17 @@ void CheckIntTestDataLessThanMultiComponent(const DataStructure& dataStructure, } } -void CheckIntTestDataEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) +void CheckIntTestDataLessThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); - const auto& inputStore = dataStructure.getDataAs(k_MultiComponentArrayPath)->getDataStoreRef(); - for(usize i = 0; i < k_TupleCount; i++) { - usize arrayIndex = i * k_MultiComponentCount + componentIndex; - int32 inputValue = inputStore[arrayIndex]; // store value for breakpoint testing purposes. - bool value = thresholdStore[arrayIndex]; - bool expected = InputIntComponentValue(i, componentIndex) == thresholdValue; + bool value = thresholdStore[i]; + bool expected = InputIntComponentValue(i, componentIndex) < thresholdValue; if(isInverted) { @@ -351,7 +365,7 @@ void CheckIntTestDataEqualToMultiComponent(const DataStructure& dataStructure, d } } -void CheckIntTestDataNotEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) +void CheckIntTestDataEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -360,9 +374,8 @@ void CheckIntTestDataNotEqualToMultiComponent(const DataStructure& dataStructure for(usize i = 0; i < k_TupleCount; i++) { - usize arrayIndex = i * k_MultiComponentCount + componentIndex; - bool value = thresholdStore[arrayIndex]; - bool expected = InputIntComponentValue(i, componentIndex) != thresholdValue; + bool value = thresholdStore[i]; + bool expected = InputIntComponentValue(i, componentIndex) == thresholdValue; if(isInverted) { @@ -373,7 +386,7 @@ void CheckIntTestDataNotEqualToMultiComponent(const DataStructure& dataStructure } } -void CheckFloatTestDataNotEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckIntTestDataNotEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -383,7 +396,7 @@ void CheckFloatTestDataNotEqualToSingleComponent(const DataStructure& dataStruct for(usize i = 0; i < k_TupleCount; i++) { bool value = thresholdStore[i]; - bool expected = InputFloatValue(i) != thresholdValue; + bool expected = InputIntComponentValue(i, componentIndex) != thresholdValue; if(isInverted) { @@ -422,70 +435,29 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int", "[ DataStructure dataStructure = CreateTestDataStructure(); const DataPath targetArray = k_TestArrayIntPath; - bool isInverted = false; - - SECTION("ArrayThreshold: >") - { - const double thresholdValue = 2.0; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); - CheckIntTestDataGreaterThanSingleComponent(dataStructure, thresholdValue, isInverted); - } - - SECTION("ArrayThreshold: <") - { - const double thresholdValue = 3.0; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); - CheckIntTestDataLessThanSingleComponent(dataStructure, thresholdValue, isInverted); - } - - SECTION("ArrayThreshold: ==") - { - const double thresholdValue = 3.0; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); - CheckIntTestDataEqualToSingleComponent(dataStructure, thresholdValue, isInverted); - CheckIntTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); - } - SECTION("ArrayThreshold: !=") - { - const double thresholdValue = 4.0; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); - CheckIntTestDataEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); - CheckIntTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, isInverted); - } -} - -TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int Inverted", "[SimplnxCore][MultiThresholdObjectsFilter]") -{ - UnitTest::LoadPlugins(); - - DataStructure dataStructure = CreateTestDataStructure(); - const DataPath targetArray = k_TestArrayIntPath; - bool isInverted = true; + double thresholdValue = GENERATE(-1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 22.0, 5.5); + bool isInverted = GENERATE(false, true); SECTION("ArrayThreshold: >") { - const double thresholdValue = 2.0; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); CheckIntTestDataGreaterThanSingleComponent(dataStructure, thresholdValue, isInverted); } SECTION("ArrayThreshold: <") { - const double thresholdValue = 3.0; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); CheckIntTestDataLessThanSingleComponent(dataStructure, thresholdValue, isInverted); } SECTION("ArrayThreshold: ==") { - const double thresholdValue = 3.0; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); CheckIntTestDataEqualToSingleComponent(dataStructure, thresholdValue, isInverted); CheckIntTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); } SECTION("ArrayThreshold: !=") { - const double thresholdValue = 4.0; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); CheckIntTestDataEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); CheckIntTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, isInverted); @@ -498,72 +470,30 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Float", DataStructure dataStructure = CreateTestDataStructure(); const DataPath targetArray = k_TestArrayFloatPath; - bool isInverted = false; + double thresholdValue = GENERATE(0.0, 0.01, 0.02, 0.03, 0.04, 26.2); + bool isInverted = GENERATE(false, true); // RunSingleComponentThresholdTests(dataStructure, k_TestArrayIntPath, 3.0, false); SECTION("ArrayThreshold: >") { - const double thresholdValue = 0.04; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); CheckFloatTestDataGreaterThanSingleComponent(dataStructure, thresholdValue, isInverted); } SECTION("ArrayThreshold: <") { - const double thresholdValue = 0.02; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); CheckFloatTestDataLessThanSingleComponent(dataStructure, thresholdValue, isInverted); } SECTION("ArrayThreshold: ==") { - const double thresholdValue = 0.03; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); CheckFloatTestDataEqualToSingleComponent(dataStructure, thresholdValue, isInverted); CheckFloatTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); } SECTION("ArrayThreshold: !=") { - const double thresholdValue = 0.02; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); - CheckFloatTestDataEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); - CheckFloatTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, isInverted); - } -} - -TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Float Inverted", "[SimplnxCore][MultiThresholdObjectsFilter]") -{ - UnitTest::LoadPlugins(); - - DataStructure dataStructure = CreateTestDataStructure(); - const DataPath targetArray = k_TestArrayFloatPath; - bool isInverted = true; - - // RunSingleComponentThresholdTests(dataStructure, k_TestArrayIntPath, 3.0, false); - SECTION("ArrayThreshold: >") - { - const double thresholdValue = 0.02; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); - CheckFloatTestDataGreaterThanSingleComponent(dataStructure, thresholdValue, isInverted); - } - - SECTION("ArrayThreshold: <") - { - const double thresholdValue = 0.03; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); - CheckFloatTestDataLessThanSingleComponent(dataStructure, thresholdValue, isInverted); - } - - SECTION("ArrayThreshold: ==") - { - const double thresholdValue = 0.02; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); - CheckFloatTestDataEqualToSingleComponent(dataStructure, thresholdValue, isInverted); - CheckFloatTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); - } - SECTION("ArrayThreshold: !=") - { - const double thresholdValue = 0.01; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); CheckFloatTestDataEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); CheckFloatTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, isInverted); @@ -576,34 +506,31 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int Mult DataStructure dataStructure = CreateTestDataStructure(); const DataPath targetArray = k_MultiComponentArrayPath; - bool isInverted = false; + double thresholdValue = GENERATE(-1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 22.0, 5.5); + bool isInverted = GENERATE(false, true); int32 componentIndex = GENERATE(0, 1, 2); SECTION("ArrayThreshold: >") { - const double thresholdValue = 2.0; RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted, componentIndex); CheckIntTestDataGreaterThanMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); } SECTION("ArrayThreshold: <") { - const double thresholdValue = 3.0; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted, componentIndex); CheckIntTestDataLessThanMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); } SECTION("ArrayThreshold: ==") { - const double thresholdValue = 3.0; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted, componentIndex); CheckIntTestDataEqualToMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); CheckIntTestDataNotEqualToMultiComponent(dataStructure, thresholdValue, !isInverted, componentIndex); } SECTION("ArrayThreshold: !=") { - const double thresholdValue = 4.0; - RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted, componentIndex); CheckIntTestDataEqualToMultiComponent(dataStructure, thresholdValue, !isInverted, componentIndex); CheckIntTestDataNotEqualToMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); } From 254e87461924b6b59998355965a093c6e095fec2 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Wed, 22 Apr 2026 14:07:03 -0400 Subject: [PATCH 04/28] Removed component requirement for multiple input arrays * Removed requirement for input arrays to all have the same number of components, Each threshold specifies the target component. --- .../SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp index 9ebe5b283d..34dc6bff18 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp @@ -164,7 +164,6 @@ IFilter::PreflightResult MultiThresholdObjectsFilter::preflightImpl(const DataSt // Check for same number of tuples and components usize numTuples = dataArray.getNumberOfTuples(); - usize numComponents = dataArray.getNumberOfComponents(); for(const auto& dataPath : thresholdPaths) { const auto& currentDataArray = dataStructure.getDataRefAs(dataPath); @@ -174,13 +173,6 @@ IFilter::PreflightResult MultiThresholdObjectsFilter::preflightImpl(const DataSt auto errorMessage = fmt::format("Data Arrays do not have same equal number of tuples. '{}:{}' and '{}:{}'", firstDataPath.toString(), numTuples, dataPath.toString(), currentNumTuples); return MakePreflightErrorResult(to_underlying(ErrorCodes::UnequalTuples), errorMessage); } - usize currentNumComponents = currentDataArray.getNumberOfComponents(); - if(currentNumComponents != numComponents) - { - auto errorMessage = - fmt::format("Data Arrays do not have same equal number of components. '{}:{}' and '{}:{}'", firstDataPath.toString(), numComponents, dataPath.toString(), currentNumComponents); - return MakePreflightErrorResult(to_underlying(ErrorCodes::UnequalComponents), errorMessage); - } } Result<> componentIndicesResult = CheckComponentIndicesInThresholds(thresholdsObject, dataStructure); From 15ef05b861bfb9d416c16065e9def6e846a86d11 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Thu, 23 Apr 2026 09:04:31 -0400 Subject: [PATCH 05/28] Restructuring MultiThresholdObjects for Set support * MultiThresholdObjects no longer writes directly to the DataStore when running Thresholds. Instead Sets and Thresholds both store temporary vectors that are copied to the parent set's vector. The topmost ThresholdSet copies the vector to the DataStore upon completion. * Added ThresholdSet unit tests. --- .../Algorithms/MultiThresholdObjects.cpp | 66 +- .../test/MultiThresholdObjectsTest.cpp | 593 +++++++++++------- 2 files changed, 414 insertions(+), 245 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index aac4fece04..8a5555aab5 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -33,7 +33,7 @@ class ThresholdFilterHelper { T inputValue = m_Input.getComponentValue(tupleIndex, m_ComponentIndex); bool comparison = CompT{}(inputValue, value); - if (m_IsInverted) + if(m_IsInverted) { comparison = !comparison; } @@ -95,33 +95,33 @@ struct ExecuteThresholdHelper * @param inverse */ template -void InsertThreshold(usize numItems, AbstractDataStore& currentStore, nx::core::IArrayThreshold::UnionOperator unionOperator, std::vector& newArrayPtr, bool inverse, T trueValue, T falseValue) +void InsertThreshold(usize numItems, std::vector& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, std::vector& newVector, bool inverse, T trueValue, T falseValue) { for(usize i = 0; i < numItems; i++) { // invert the current comparison if necessary if(inverse) { - newArrayPtr[i] = (newArrayPtr[i] == trueValue) ? falseValue : trueValue; + newVector[i] = (newVector[i] == trueValue) ? falseValue : trueValue; } if(nx::core::IArrayThreshold::UnionOperator::Or == unionOperator) { - currentStore[i] = (currentStore[i] == trueValue || newArrayPtr[i] == trueValue) ? trueValue : falseValue; + currentVector[i] = (currentVector[i] == trueValue || newVector[i] == trueValue) ? trueValue : falseValue; } - else if(currentStore[i] == falseValue || newArrayPtr[i] == falseValue) + else if(currentVector[i] == falseValue || newVector[i] == falseValue) { - currentStore[i] = falseValue; + currentVector[i] = falseValue; } } } template -void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, AbstractDataStore& outputResultStore, int32_t& err, bool replaceInput, bool inverse, T trueValue, +void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, std::vector& outputResultVector, int32_t& err, bool replaceInput, bool inverse, T trueValue, T falseValue) { // Get the total number of tuples, create and initialize an array with FALSE to use for these results - size_t totalTuples = outputResultStore.getNumberOfTuples(); + size_t totalTuples = outputResultVector.size(); std::vector tempResultVector(totalTuples, falseValue); nx::core::ArrayThreshold::ComparisonType compOperator = comparisonValue.getComparisonType(); @@ -148,13 +148,13 @@ void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& // copy the temp uint8 vector to the final uint8 result array for(size_t i = 0; i < totalTuples; i++) { - outputResultStore[i] = tempResultVector[i]; + outputResultVector[i] = tempResultVector[i]; } } else { // insert into current threshold - InsertThreshold(totalTuples, outputResultStore, unionOperator, tempResultVector, inverse, trueValue, falseValue); + InsertThreshold(totalTuples, outputResultVector, unionOperator, tempResultVector, inverse, trueValue, falseValue); } } @@ -165,16 +165,24 @@ struct ThresholdValueFunctor { // Traditionally we would do a check to ensure we get a valid pointer, I'm forgoing that check because it // was essentially done in the preflight part. - ThresholdValue(comparisonValue, dataStructure, outputResultArray.template getIDataStoreRefAs>(), err, replaceInput, inverse, trueValue, falseValue); + auto& outputDataStore = outputResultArray.template getIDataStoreRefAs>(); + usize totalTuples = outputDataStore.getNumberOfTuples(); + std::vector tmpVector(totalTuples, falseValue); + ThresholdValue(comparisonValue, dataStructure, tmpVector, err, replaceInput, inverse, trueValue, falseValue); + + for(size_t i = 0; i < totalTuples; i++) + { + outputDataStore[i] = tmpVector[i]; + } } }; template -void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, AbstractDataStore& outputResultStore, int32_t& err, bool replaceInput, bool inverse, T trueValue, +void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, std::vector& outputResultVector, int32_t& err, bool replaceInput, bool inverse, T trueValue, T falseValue) { // Get the total number of tuples, create and initialize an array with FALSE to use for these results - size_t totalTuples = outputResultStore.getNumberOfTuples(); + size_t totalTuples = outputResultVector.size(); std::vector tempResultVector(totalTuples, falseValue); bool firstValueFound = false; @@ -185,16 +193,25 @@ void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructu const IArrayThreshold* thresholdPtr = threshold.get(); if(const auto* comparisonSet = dynamic_cast(thresholdPtr); comparisonSet != nullptr) { - ThresholdSet(*comparisonSet, dataStructure, outputResultStore, err, !firstValueFound, false, trueValue, falseValue); + ThresholdSet(*comparisonSet, dataStructure, tempResultVector, err, !firstValueFound, false, trueValue, falseValue); firstValueFound = true; } else if(const auto* comparisonValue = dynamic_cast(thresholdPtr); comparisonValue != nullptr) { - ThresholdValue(*comparisonValue, dataStructure, outputResultStore, err, !firstValueFound, false, trueValue, falseValue); + ThresholdValue(*comparisonValue, dataStructure, tempResultVector, err, !firstValueFound, false, trueValue, falseValue); firstValueFound = true; } } + // Allow ThresholdSets to be invertable + if(inputComparisonSet.isInverted()) + { + for(size_t i = 0; i < totalTuples; i++) + { + tempResultVector[i] = (tempResultVector[i] == trueValue) ? falseValue : trueValue; + } + } + if(replaceInput) { if(inverse) @@ -204,13 +221,13 @@ void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructu // copy the temp uint8 vector to the final uint8 result array for(size_t i = 0; i < totalTuples; i++) { - outputResultStore[i] = tempResultVector[i]; + outputResultVector[i] = tempResultVector[i]; } } else { // insert into current threshold - InsertThreshold(totalTuples, outputResultStore, inputComparisonSet.getUnionOperator(), tempResultVector, inverse, trueValue, falseValue); + InsertThreshold(totalTuples, outputResultVector, inputComparisonSet.getUnionOperator(), tempResultVector, inverse, trueValue, falseValue); } } @@ -222,7 +239,15 @@ struct ThresholdSetFunctor { // Traditionally we would do a check to ensure we get a valid pointer, I'm forgoing that check because it // was essentially done in the preflight part. - ThresholdSet(inputComparisonSet, dataStructure, outputResultArray.template getIDataStoreRefAs>(), err, replaceInput, inverse, trueValue, falseValue); + auto& outputDataStore = outputResultArray.template getIDataStoreRefAs>(); + usize totalTuples = outputDataStore.getNumberOfTuples(); + std::vector tmpVector(totalTuples, falseValue); + ThresholdSet(inputComparisonSet, dataStructure, tmpVector, err, replaceInput, inverse, trueValue, falseValue); + + for(size_t i = 0; i < totalTuples; i++) + { + outputDataStore[i] = tmpVector[i]; + } } }; } // namespace @@ -258,6 +283,10 @@ Result<> MultiThresholdObjects::operator()() DataPath maskArrayPath = (*thresholdsObject.getRequiredPaths().begin()).replaceName(maskArrayName); int32_t err = 0; ArrayThresholdSet::CollectionType thresholdSet = thresholdsObject.getArrayThresholds(); + + ExecuteDataFunction(ThresholdSetFunctor{}, maskArrayType, thresholdsObject, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), err, !firstValueFound, + thresholdsObject.isInverted(), trueValue, falseValue); + #if 0 for(const std::shared_ptr& threshold : thresholdSet) { if(m_ShouldCancel) @@ -280,6 +309,7 @@ Result<> MultiThresholdObjects::operator()() firstValueFound = true; } } + #endif return {}; } diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index 25728ada0d..46d3fc8c81 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -93,13 +93,13 @@ DataStructure CreateTestDataStructure() } /** -* @brief Creates a single threshold for the filter to use. -* @param arrayPath Input DataArray path -* @param comparisonType type of comparison -* @param value Value to threshold against -* @param isInverted Should the threshold output be inverted -* componentIndex Component index of the array to threshold against. -*/ + * @brief Creates a single threshold for the filter to use. + * @param arrayPath Input DataArray path + * @param comparisonType type of comparison + * @param value Value to threshold against + * @param isInverted Should the threshold output be inverted + * componentIndex Component index of the array to threshold against. + */ ArrayThresholdSet CreateSingleThreshold(const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted, int componentIndex) { ArrayThresholdSet thresholdSet; @@ -115,21 +115,15 @@ ArrayThresholdSet CreateSingleThreshold(const DataPath& arrayPath, ArrayThreshol } /** - * @brief + * @brief Runs the MultiThresholdObjects filter on the provided threshold set * @param dataStructure - * @param arrayPath Path to use for the threshold DataArray - * @param comparisonType Type of comparison to perform - * @param value Value to threshold against - * @param isInverted should the output mask value be inverted - * @param componentIndex Which component of the array the threshold should use. + * @param thresholdSet ThresholdSet to use for the MultiThresholdObjectsFilter */ -void RunSingleThresholdTest(DataStructure& dataStructure, const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted, int32 componentIndex = 0) +void RunThresholdSetTest(DataStructure& dataStructure, ArrayThresholdSet thresholdSet) { MultiThresholdObjectsFilter filter; Arguments args; - auto thresholdSet = CreateSingleThreshold(arrayPath, comparisonType, value, isInverted, componentIndex); - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::boolean)); @@ -149,71 +143,50 @@ void RunSingleThresholdTest(DataStructure& dataStructure, const DataPath& arrayP REQUIRE(thresholdArrayPtr->getNumberOfComponents() == 1); } -// Integer checks -void CheckIntTestDataGreaterThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +/** + * @brief Runs the MultiThresholdObjects filter on the provided DataStructure using a single array threshold. + * @param dataStructure + * @param arrayPath Path to use for the threshold DataArray + * @param comparisonType Type of comparison to perform + * @param value Value to threshold against + * @param isInverted should the output mask value be inverted + * @param componentIndex Which component of the array the threshold should use. + */ +void RunSingleThresholdTest(DataStructure& dataStructure, const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted, int32 componentIndex = 0) { - const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArrayPtr != nullptr); - - auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); - - for(usize i = 0; i < k_TupleCount; i++) - { - bool value = thresholdStore[i]; - bool expected = InputIntValue(i) > thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); - } + auto thresholdSet = CreateSingleThreshold(arrayPath, comparisonType, value, isInverted, componentIndex); + RunThresholdSetTest(dataStructure, thresholdSet); } -void CheckIntTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +// Integer checks +bool ExpectedIntSingleComponentMask(ArrayThreshold::ComparisonType comparisonType, int32 i, double thresholdValue, bool isInverted) { - const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArrayPtr != nullptr); + bool expected = false; - auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); - - for(usize i = 0; i < k_TupleCount; i++) + switch(comparisonType) { - bool value = thresholdStore[i]; - bool expected = InputIntValue(i) < thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); + case ArrayThreshold::ComparisonType::GreaterThan: + expected = InputIntValue(i) > thresholdValue; + break; + case ArrayThreshold::ComparisonType::LessThan: + expected = InputIntValue(i) < thresholdValue; + break; + case ArrayThreshold::ComparisonType::Operator_Equal: + expected = InputIntValue(i) == thresholdValue; + break; + case ArrayThreshold::ComparisonType::Operator_NotEqual: + expected = InputIntValue(i) != thresholdValue; + break; } -} - -void CheckIntTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) -{ - const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArrayPtr != nullptr); - - auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); - for(usize i = 0; i < k_TupleCount; i++) + if(isInverted) { - bool value = thresholdStore[i]; - bool expected = InputIntValue(i) == thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); + expected = !expected; } + return expected; } -void CheckIntTestDataNotEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckIntTestDataSingleComponent(const DataStructure& dataStructure, ArrayThreshold::ComparisonType comparisonType, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -222,85 +195,39 @@ void CheckIntTestDataNotEqualToSingleComponent(const DataStructure& dataStructur for(usize i = 0; i < k_TupleCount; i++) { - bool value = thresholdStore[i]; - bool expected = InputIntValue(i) != thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); + REQUIRE(thresholdStore[i] == ExpectedIntSingleComponentMask(comparisonType, i, thresholdValue, isInverted)); } } // Floating point checks - -void CheckFloatTestDataGreaterThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +bool ExpectedFloatSingleComponentMask(ArrayThreshold::ComparisonType comparisonType, int32 i, double thresholdValue, bool isInverted) { - const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArrayPtr != nullptr); - - auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + bool expected = false; - for(usize i = 0; i < k_TupleCount; i++) + switch(comparisonType) { - bool value = thresholdStore[i]; - bool expected = InputFloatValue(i) > thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); + case ArrayThreshold::ComparisonType::GreaterThan: + expected = InputFloatValue(i) > thresholdValue; + break; + case ArrayThreshold::ComparisonType::LessThan: + expected = InputFloatValue(i) < thresholdValue; + break; + case ArrayThreshold::ComparisonType::Operator_Equal: + expected = InputFloatValue(i) == thresholdValue; + break; + case ArrayThreshold::ComparisonType::Operator_NotEqual: + expected = InputFloatValue(i) != thresholdValue; + break; } -} -void CheckFloatTestDataLessThanSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) -{ - const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArrayPtr != nullptr); - - auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); - - for(usize i = 0; i < k_TupleCount; i++) - { - float64 expectedValue = InputFloatValue(i); - bool value = thresholdStore[i]; - bool expected = InputFloatValue(i) < thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); - } -} - -void CheckFloatTestDataEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) -{ - const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArrayPtr != nullptr); - - auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); - - for(usize i = 0; i < k_TupleCount; i++) + if(isInverted) { - bool value = thresholdStore[i]; - bool expected = InputFloatValue(i) == thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); + expected = !expected; } + return expected; } -void CheckFloatTestDataNotEqualToSingleComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted) +void CheckFloatTestDataSingleComponent(const DataStructure& dataStructure, ArrayThreshold::ComparisonType comparisonType, double thresholdValue, bool isInverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -309,84 +236,39 @@ void CheckFloatTestDataNotEqualToSingleComponent(const DataStructure& dataStruct for(usize i = 0; i < k_TupleCount; i++) { - bool value = thresholdStore[i]; - bool expected = InputFloatValue(i) != thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); + REQUIRE(thresholdStore[i] == ExpectedFloatSingleComponentMask(comparisonType, i, thresholdValue, isInverted)); } } // Multi-component checks - -void CheckIntTestDataGreaterThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) -{ - const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArrayPtr != nullptr); - - auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); - - for(usize i = 0; i < k_TupleCount; i++) - { - bool value = thresholdStore[i]; - bool expected = InputIntComponentValue(i, componentIndex) > thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); - } -} - -void CheckIntTestDataLessThanMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) +bool ExpectedIntMultiComponentMask(ArrayThreshold::ComparisonType comparisonType, int32 i, double thresholdValue, bool isInverted, int32 componentIndex) { - const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArrayPtr != nullptr); + bool expected = false; - auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); - - for(usize i = 0; i < k_TupleCount; i++) + switch(comparisonType) { - bool value = thresholdStore[i]; - bool expected = InputIntComponentValue(i, componentIndex) < thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); + case ArrayThreshold::ComparisonType::GreaterThan: + expected = InputIntComponentValue(i, componentIndex) > thresholdValue; + break; + case ArrayThreshold::ComparisonType::LessThan: + expected = InputIntComponentValue(i, componentIndex) < thresholdValue; + break; + case ArrayThreshold::ComparisonType::Operator_Equal: + expected = InputIntComponentValue(i, componentIndex) == thresholdValue; + break; + case ArrayThreshold::ComparisonType::Operator_NotEqual: + expected = InputIntComponentValue(i, componentIndex) != thresholdValue; + break; } -} - -void CheckIntTestDataEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) -{ - const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArrayPtr != nullptr); - - auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); - for(usize i = 0; i < k_TupleCount; i++) + if(isInverted) { - bool value = thresholdStore[i]; - bool expected = InputIntComponentValue(i, componentIndex) == thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); + expected = !expected; } + return expected; } -void CheckIntTestDataNotEqualToMultiComponent(const DataStructure& dataStructure, double thresholdValue, bool isInverted, int32 componentIndex) +void CheckIntTestDataMultiComponent(const DataStructure& dataStructure, ArrayThreshold::ComparisonType comparisonType, double thresholdValue, bool isInverted, int32 componentIndex) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); @@ -395,15 +277,7 @@ void CheckIntTestDataNotEqualToMultiComponent(const DataStructure& dataStructure for(usize i = 0; i < k_TupleCount; i++) { - bool value = thresholdStore[i]; - bool expected = InputIntComponentValue(i, componentIndex) != thresholdValue; - - if(isInverted) - { - expected = !expected; - } - - REQUIRE(value == expected); + REQUIRE(thresholdStore[i] == ExpectedIntMultiComponentMask(comparisonType, i, thresholdValue, isInverted, componentIndex)); } } @@ -441,26 +315,26 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int", "[ SECTION("ArrayThreshold: >") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); - CheckIntTestDataGreaterThanSingleComponent(dataStructure, thresholdValue, isInverted); + CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); } SECTION("ArrayThreshold: <") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); - CheckIntTestDataLessThanSingleComponent(dataStructure, thresholdValue, isInverted); + CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); } SECTION("ArrayThreshold: ==") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); - CheckIntTestDataEqualToSingleComponent(dataStructure, thresholdValue, isInverted); - CheckIntTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); + CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); + CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, !isInverted); } SECTION("ArrayThreshold: !=") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); - CheckIntTestDataEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); - CheckIntTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, isInverted); + CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, !isInverted); + CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); } } @@ -477,26 +351,26 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Float", SECTION("ArrayThreshold: >") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); - CheckFloatTestDataGreaterThanSingleComponent(dataStructure, thresholdValue, isInverted); + CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); } SECTION("ArrayThreshold: <") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); - CheckFloatTestDataLessThanSingleComponent(dataStructure, thresholdValue, isInverted); + CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted); } SECTION("ArrayThreshold: ==") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); - CheckFloatTestDataEqualToSingleComponent(dataStructure, thresholdValue, isInverted); - CheckFloatTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); + CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); + CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, !isInverted); } SECTION("ArrayThreshold: !=") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); - CheckFloatTestDataEqualToSingleComponent(dataStructure, thresholdValue, !isInverted); - CheckFloatTestDataNotEqualToSingleComponent(dataStructure, thresholdValue, isInverted); + CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, !isInverted); + CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); } } @@ -513,26 +387,291 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int Mult SECTION("ArrayThreshold: >") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted, componentIndex); - CheckIntTestDataGreaterThanMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); + CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted, componentIndex); } SECTION("ArrayThreshold: <") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted, componentIndex); - CheckIntTestDataLessThanMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); + CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::LessThan, thresholdValue, isInverted, componentIndex); } SECTION("ArrayThreshold: ==") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted, componentIndex); - CheckIntTestDataEqualToMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); - CheckIntTestDataNotEqualToMultiComponent(dataStructure, thresholdValue, !isInverted, componentIndex); + CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted, componentIndex); + CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, !isInverted, componentIndex); } SECTION("ArrayThreshold: !=") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted, componentIndex); - CheckIntTestDataEqualToMultiComponent(dataStructure, thresholdValue, !isInverted, componentIndex); - CheckIntTestDataNotEqualToMultiComponent(dataStructure, thresholdValue, isInverted, componentIndex); + CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, !isInverted, componentIndex); + CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted, componentIndex); + } +} + +/** + * @brief Creates a single threshold for the filter to use. + * @param arrayPath Input DataArray path + * @param comparisonType type of comparison + * @param value Value to threshold against + * @param isInverted Should the threshold output be inverted + * componentIndex Component index of the array to threshold against. + * unionOperator Union operator to apply on the threshold. Defaults to And + */ +std::shared_ptr CreateArrayThreshold(const DataPath& arrayPath, ArrayThreshold::ComparisonType comparisonType, double value, bool isInverted, int componentIndex, + ArrayThreshold::UnionOperator unionOperator = ArrayThreshold::UnionOperator::And) +{ + auto threshold = std::make_shared(); + threshold->setArrayPath(arrayPath); + threshold->setComparisonType(comparisonType); + threshold->setComparisonValue(value); + threshold->setComponentIndex(componentIndex); + threshold->setInverted(isInverted); + threshold->setUnionOperator(unionOperator); + + return threshold; +} + +ArrayThresholdSet CreateThresholdSet1() +{ + ArrayThresholdSet thresholdSet; + + // Threshold: Int > 2 + auto threshold1 = CreateArrayThreshold(k_TestArrayIntPath, ArrayThreshold::ComparisonType::GreaterThan, 2.0, false, 0, ArrayThreshold::UnionOperator::And); + // Threshold: Float < 0.025 + auto threshold2 = CreateArrayThreshold(k_TestArrayFloatPath, ArrayThreshold::ComparisonType::LessThan, 0.025, false, 0, ArrayThreshold::UnionOperator::And); + // Threshold: Int[1] > 0.0 : inverted + auto threshold3 = CreateArrayThreshold(k_MultiComponentArrayPath, ArrayThreshold::ComparisonType::GreaterThan, 0.0, true, 1, ArrayThreshold::UnionOperator::And); + + thresholdSet.setArrayThresholds({threshold1, threshold2, threshold3}); + + return thresholdSet; +} + +bool ExpectedThresholdSet1Mask(usize index, bool inverted) +{ + bool expectedThreshold1 = ExpectedIntSingleComponentMask(ArrayThreshold::ComparisonType::GreaterThan, index, 2.0, false); + bool expectedThreshold2 = ExpectedFloatSingleComponentMask(ArrayThreshold::ComparisonType::LessThan, index, 0.025, false); + bool expectedThreshold3 = ExpectedIntMultiComponentMask(ArrayThreshold::ComparisonType::GreaterThan, index, 0.0, true, 1); + + bool expected = expectedThreshold1 && expectedThreshold2 && expectedThreshold3; + if(inverted) + { + expected = !expected; + } + return expected; +} + +ArrayThresholdSet CreateThresholdSet2() +{ + ArrayThresholdSet thresholdSet; + + // Threshold: Int == 1 + auto threshold1 = CreateArrayThreshold(k_TestArrayIntPath, ArrayThreshold::ComparisonType::Operator_Equal, 1.0, false, 0, ArrayThreshold::UnionOperator::And); + // Threshold: Float != 5.0 + auto threshold2 = CreateArrayThreshold(k_TestArrayFloatPath, ArrayThreshold::ComparisonType::Operator_NotEqual, 5.0, false, 0, ArrayThreshold::UnionOperator::Or); + // Threshold: Int[0] < 0.0 : inverted + auto threshold3 = CreateArrayThreshold(k_MultiComponentArrayPath, ArrayThreshold::ComparisonType::LessThan, 0.0, true, 0, ArrayThreshold::UnionOperator::And); + + thresholdSet.setArrayThresholds({threshold1, threshold2, threshold3}); + + return thresholdSet; +} + +bool ExpectedThresholdSet2Mask(usize index, bool inverted) +{ + bool expectedThreshold1 = ExpectedIntSingleComponentMask(ArrayThreshold::ComparisonType::Operator_Equal, index, 1.0, false); + bool expectedThreshold2 = ExpectedFloatSingleComponentMask(ArrayThreshold::ComparisonType::Operator_NotEqual, index, 5.0, false); + bool expectedThreshold3 = ExpectedIntMultiComponentMask(ArrayThreshold::ComparisonType::LessThan, index, 0.0, true, 0); + + bool expected = (expectedThreshold1 || expectedThreshold2) && expectedThreshold3; + if(inverted) + { + expected = !expected; + } + return expected; +} + +ArrayThresholdSet CreateThresholdSet3() +{ + ArrayThresholdSet thresholdSet; + + auto set1 = std::make_shared(CreateThresholdSet1()); + auto set2 = std::make_shared(CreateThresholdSet2()); + + thresholdSet.setArrayThresholds({set1, set2}); + + return thresholdSet; +} + +ArrayThresholdSet CreateThresholdSet4() +{ + ArrayThresholdSet thresholdSet; + + auto set1 = std::make_shared(CreateThresholdSet1()); + auto set2 = std::make_shared(CreateThresholdSet2()); + set2->setUnionOperator(ArrayThreshold::UnionOperator::Or); + + thresholdSet.setArrayThresholds({set1, set2}); + + return thresholdSet; +} + +ArrayThresholdSet CreateThresholdSet5() +{ + ArrayThresholdSet thresholdSet; + + auto set1 = std::make_shared(CreateThresholdSet1()); + auto set2 = std::make_shared(CreateThresholdSet2()); + set2->setUnionOperator(ArrayThreshold::UnionOperator::Or); + set2->setInverted(true); + + thresholdSet.setArrayThresholds({set1, set2}); + + return thresholdSet; +} + +void CheckThresholdSet1(DataStructure& dataStructure, bool inverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + REQUIRE(thresholdStore[i] == ExpectedThresholdSet1Mask(i, inverted)); + } +} + +void CheckThresholdSet2(DataStructure& dataStructure, bool inverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool value = thresholdStore[i]; + bool expected = ExpectedThresholdSet2Mask(i, inverted); + REQUIRE(thresholdStore[i] == ExpectedThresholdSet2Mask(i, inverted)); + } +} + +void CheckThresholdSet3(DataStructure& dataStructure, bool inverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool expectedMask1 = ExpectedThresholdSet1Mask(i, false); + bool expectedMask2 = ExpectedThresholdSet2Mask(i, false); + + bool expected = expectedMask1 && expectedMask2; + if (inverted) + { + expected = !expected; + } + + REQUIRE(thresholdStore[i] == expected); + } +} + +void CheckThresholdSet4(DataStructure& dataStructure, bool inverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool expectedMask1 = ExpectedThresholdSet1Mask(i, false); + bool expectedMask2 = ExpectedThresholdSet2Mask(i, false); + + bool expected = expectedMask1 || expectedMask2; + if(inverted) + { + expected = !expected; + } + + REQUIRE(thresholdStore[i] == expected); + } +} + +void CheckThresholdSet5(DataStructure& dataStructure, bool inverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool expectedMask1 = ExpectedThresholdSet1Mask(i, false); + bool expectedMask2 = ExpectedThresholdSet2Mask(i, true); + + bool expected = expectedMask1 || expectedMask2; + if(inverted) + { + expected = !expected; + } + + REQUIRE(thresholdStore[i] == expected); + } +} + +TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Threshold Sets", "[SimplnxCore][MultiThresholdObjectsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = CreateTestDataStructure(); + //bool isInverted = GENERATE(false, true); + bool isInverted = true; + + SECTION("ArraySet 1") + { + auto thresholdSet = CreateThresholdSet1(); + thresholdSet.setInverted(isInverted); + RunThresholdSetTest(dataStructure, thresholdSet); + CheckThresholdSet1(dataStructure, isInverted); + } + + SECTION("ArraySet 2") + { + auto thresholdSet = CreateThresholdSet2(); + thresholdSet.setInverted(isInverted); + RunThresholdSetTest(dataStructure, thresholdSet); + CheckThresholdSet2(dataStructure, isInverted); + } + + SECTION("ArraySet 3") + { + auto thresholdSet = CreateThresholdSet3(); + thresholdSet.setInverted(isInverted); + RunThresholdSetTest(dataStructure, thresholdSet); + CheckThresholdSet3(dataStructure, isInverted); + } + + SECTION("ArraySet 4") + { + auto thresholdSet = CreateThresholdSet4(); + thresholdSet.setInverted(isInverted); + RunThresholdSetTest(dataStructure, thresholdSet); + CheckThresholdSet4(dataStructure, isInverted); + } + + SECTION("ArraySet 5") + { + auto thresholdSet = CreateThresholdSet5(); + thresholdSet.setInverted(isInverted); + RunThresholdSetTest(dataStructure, thresholdSet); + CheckThresholdSet5(dataStructure, isInverted); } } From a78129f469d87d5e0a5aa0c5aeaf252bc7ba9b70 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Thu, 23 Apr 2026 10:44:31 -0400 Subject: [PATCH 06/28] Fixed MultiThresholdObjects ThresholdSets algorithm * Standardized apply threshold values between thresholds and sets. * Removed unnecessary inversion parameter in threshold and set algorithm --- .../Algorithms/MultiThresholdObjects.cpp | 115 +++++------------- .../test/MultiThresholdObjectsTest.cpp | 5 +- 2 files changed, 30 insertions(+), 90 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index 8a5555aab5..a0d11462be 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -11,16 +11,31 @@ using namespace nx::core; namespace { +template +void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, std::vector& outputResultVector, std::vector& inputThresholdVector, bool replaceInput, T trueValue, T falseValue) +{ + usize totalTuples = outputResultVector.size(); + auto unionOperator = arrayThreshold.getUnionOperator(); + bool inverse = arrayThreshold.isInverted(); + + if(replaceInput) + { + unionOperator = IArrayThreshold::UnionOperator::Or; + } + + // insert into current threshold + InsertThreshold(totalTuples, outputResultVector, unionOperator, inputThresholdVector, inverse, trueValue, falseValue); +} + template class ThresholdFilterHelper { public: - ThresholdFilterHelper(ArrayThreshold::ComparisonType compType, ArrayThreshold::ComparisonValue compValue, usize componentIndex, std::vector& output, bool isInverted) + ThresholdFilterHelper(ArrayThreshold::ComparisonType compType, ArrayThreshold::ComparisonValue compValue, usize componentIndex, std::vector& output) : m_ComparisonOperator(compType) , m_ComparisonValue(compValue) , m_ComponentIndex(componentIndex) , m_Output(output) - , m_IsInverted(isInverted) { } @@ -33,10 +48,6 @@ class ThresholdFilterHelper { T inputValue = m_Input.getComponentValue(tupleIndex, m_ComponentIndex); bool comparison = CompT{}(inputValue, value); - if(m_IsInverted) - { - comparison = !comparison; - } T outputValue = comparison ? trueValue : falseValue; m_Output[tupleIndex] = outputValue; } @@ -73,7 +84,6 @@ class ThresholdFilterHelper ArrayThreshold::ComparisonValue m_ComparisonValue; usize m_ComponentIndex = 0; std::vector& m_Output; - bool m_IsInverted = false; }; struct ExecuteThresholdHelper @@ -117,8 +127,7 @@ void InsertThreshold(usize numItems, std::vector& currentVector, nx::core::IA } template -void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, std::vector& outputResultVector, int32_t& err, bool replaceInput, bool inverse, T trueValue, - T falseValue) +void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, std::vector& outputResultVector, int32_t& err, bool replaceInput, T trueValue, T falseValue) { // Get the total number of tuples, create and initialize an array with FALSE to use for these results size_t totalTuples = outputResultVector.size(); @@ -127,35 +136,18 @@ void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& nx::core::ArrayThreshold::ComparisonType compOperator = comparisonValue.getComparisonType(); nx::core::ArrayThreshold::ComparisonValue compValue = comparisonValue.getComparisonValue(); nx::core::IArrayThreshold::UnionOperator unionOperator = comparisonValue.getUnionOperator(); - bool isInverted = comparisonValue.isInverted(); DataPath inputDataArrayPath = comparisonValue.getArrayPath(); usize componentIndex = comparisonValue.getComponentIndex(); - ThresholdFilterHelper helper(compOperator, compValue, componentIndex, tempResultVector, isInverted); + ThresholdFilterHelper helper(compOperator, compValue, componentIndex, tempResultVector); const auto& iDataArray = dataStructure.getDataRefAs(inputDataArrayPath); ExecuteDataFunction(ExecuteThresholdHelper{}, iDataArray.getDataType(), helper, iDataArray, trueValue, falseValue); - if(replaceInput) - { - if(inverse) - { - std::reverse(tempResultVector.begin(), tempResultVector.end()); - } - // copy the temp uint8 vector to the final uint8 result array - for(size_t i = 0; i < totalTuples; i++) - { - outputResultVector[i] = tempResultVector[i]; - } - } - else - { - // insert into current threshold - InsertThreshold(totalTuples, outputResultVector, unionOperator, tempResultVector, inverse, trueValue, falseValue); - } + ApplyThresholdValues(comparisonValue, outputResultVector, tempResultVector, replaceInput, trueValue, falseValue); } struct ThresholdValueFunctor @@ -178,8 +170,7 @@ struct ThresholdValueFunctor }; template -void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, std::vector& outputResultVector, int32_t& err, bool replaceInput, bool inverse, T trueValue, - T falseValue) +void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, std::vector& outputResultVector, int32_t& err, bool replaceInput, T trueValue, T falseValue) { // Get the total number of tuples, create and initialize an array with FALSE to use for these results size_t totalTuples = outputResultVector.size(); @@ -193,56 +184,31 @@ void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructu const IArrayThreshold* thresholdPtr = threshold.get(); if(const auto* comparisonSet = dynamic_cast(thresholdPtr); comparisonSet != nullptr) { - ThresholdSet(*comparisonSet, dataStructure, tempResultVector, err, !firstValueFound, false, trueValue, falseValue); + ThresholdSet(*comparisonSet, dataStructure, tempResultVector, err, !firstValueFound, trueValue, falseValue); firstValueFound = true; } else if(const auto* comparisonValue = dynamic_cast(thresholdPtr); comparisonValue != nullptr) { - ThresholdValue(*comparisonValue, dataStructure, tempResultVector, err, !firstValueFound, false, trueValue, falseValue); + ThresholdValue(*comparisonValue, dataStructure, tempResultVector, err, !firstValueFound, trueValue, falseValue); firstValueFound = true; } } - // Allow ThresholdSets to be invertable - if(inputComparisonSet.isInverted()) - { - for(size_t i = 0; i < totalTuples; i++) - { - tempResultVector[i] = (tempResultVector[i] == trueValue) ? falseValue : trueValue; - } - } - - if(replaceInput) - { - if(inverse) - { - std::reverse(tempResultVector.begin(), tempResultVector.end()); - } - // copy the temp uint8 vector to the final uint8 result array - for(size_t i = 0; i < totalTuples; i++) - { - outputResultVector[i] = tempResultVector[i]; - } - } - else - { - // insert into current threshold - InsertThreshold(totalTuples, outputResultVector, inputComparisonSet.getUnionOperator(), tempResultVector, inverse, trueValue, falseValue); - } + // Apply resulting values to output + ApplyThresholdValues(inputComparisonSet, outputResultVector, tempResultVector, replaceInput, trueValue, falseValue); } struct ThresholdSetFunctor { template - void operator()(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, IDataArray& outputResultArray, int32_t& err, bool replaceInput, bool inverse, T trueValue, - T falseValue) + void operator()(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, IDataArray& outputResultArray, int32_t& err, bool replaceInput, T trueValue, T falseValue) { // Traditionally we would do a check to ensure we get a valid pointer, I'm forgoing that check because it // was essentially done in the preflight part. auto& outputDataStore = outputResultArray.template getIDataStoreRefAs>(); usize totalTuples = outputDataStore.getNumberOfTuples(); std::vector tmpVector(totalTuples, falseValue); - ThresholdSet(inputComparisonSet, dataStructure, tmpVector, err, replaceInput, inverse, trueValue, falseValue); + ThresholdSet(inputComparisonSet, dataStructure, tmpVector, err, replaceInput, trueValue, falseValue); for(size_t i = 0; i < totalTuples; i++) { @@ -284,32 +250,7 @@ Result<> MultiThresholdObjects::operator()() int32_t err = 0; ArrayThresholdSet::CollectionType thresholdSet = thresholdsObject.getArrayThresholds(); - ExecuteDataFunction(ThresholdSetFunctor{}, maskArrayType, thresholdsObject, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), err, !firstValueFound, - thresholdsObject.isInverted(), trueValue, falseValue); - #if 0 - for(const std::shared_ptr& threshold : thresholdSet) - { - if(m_ShouldCancel) - { - return {}; - } - const IArrayThreshold* thresholdPtr = threshold.get(); - if(const auto* comparisonSet = dynamic_cast(thresholdPtr); comparisonSet != nullptr) - { - // Do not replace values on first threshold, update firstValueFound to reflect that a threshold has been run. - ExecuteDataFunction(ThresholdSetFunctor{}, maskArrayType, *comparisonSet, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), err, !firstValueFound, - thresholdsObject.isInverted(), trueValue, falseValue); - firstValueFound = true; - } - else if(const auto* comparisonValue = dynamic_cast(thresholdPtr); comparisonValue != nullptr) - { - // Do not replace values on first threshold, update firstValueFound to reflect that a threshold has been run. - ExecuteDataFunction(ThresholdValueFunctor{}, maskArrayType, *comparisonValue, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), err, !firstValueFound, - thresholdsObject.isInverted(), trueValue, falseValue); - firstValueFound = true; - } - } - #endif + ExecuteDataFunction(ThresholdSetFunctor{}, maskArrayType, thresholdsObject, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), err, !firstValueFound, trueValue, falseValue); return {}; } diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index 46d3fc8c81..d9d0907ca8 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -573,7 +573,7 @@ void CheckThresholdSet3(DataStructure& dataStructure, bool inverted) bool expectedMask2 = ExpectedThresholdSet2Mask(i, false); bool expected = expectedMask1 && expectedMask2; - if (inverted) + if(inverted) { expected = !expected; } @@ -631,8 +631,7 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Threshold Sets", "[SimplnxC UnitTest::LoadPlugins(); DataStructure dataStructure = CreateTestDataStructure(); - //bool isInverted = GENERATE(false, true); - bool isInverted = true; + bool isInverted = GENERATE(false, true); SECTION("ArraySet 1") { From 80723688c9ceae7b55adfb08fe1d2c25c62e02c5 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Thu, 23 Apr 2026 12:24:18 -0400 Subject: [PATCH 07/28] Re-enabled Invalid Execution unit test * Re-enabled unit test without the Mismatched components section. That case is no longer an error. --- .../test/MultiThresholdObjectsTest.cpp | 143 ++++++++---------- 1 file changed, 66 insertions(+), 77 deletions(-) diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index d9d0907ca8..ba2c11a384 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -674,6 +674,72 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Threshold Sets", "[SimplnxC } } +// Invalid executions + +TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution", "[SimplnxCore][MultiThresholdObjectsFilter]") +{ + UnitTest::LoadPlugins(); + + MultiThresholdObjectsFilter filter; + DataStructure dataStructure = CreateTestDataStructure(); + Arguments args; + args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); + + SECTION("Empty ArrayThresholdSet") + { + ArrayThresholdSet thresholdSet; + + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + SECTION("Empty ArrayThreshold DataPath") + { + ArrayThresholdSet thresholdSet; + auto threshold = std::make_shared(); + threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); + threshold->setComparisonValue(0.1); + thresholdSet.setArrayThresholds({threshold}); + + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + SECTION("Out of Bounds Component Index") + { + ArrayThresholdSet thresholdSet; + auto threshold = std::make_shared(); + threshold->setArrayPath(k_TestArrayFloatPath); + threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); + threshold->setComparisonValue(0.1); + threshold->setComponentIndex(1); + thresholdSet.setArrayThresholds({threshold}); + + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + SECTION("Mismatching Tuples in Threshold Arrays") + { + ArrayThresholdSet thresholdSet; + auto threshold1 = std::make_shared(); + threshold1->setArrayPath(k_TestArrayFloatPath); + threshold1->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); + threshold1->setComparisonValue(0.1); + auto threshold2 = std::make_shared(); + threshold2->setArrayPath(k_MismatchingTuplesArrayPath); + threshold2->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); + threshold2->setComparisonValue(0.1); + thresholdSet.setArrayThresholds({threshold1, threshold2}); + + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + + // Preflight the filter and check result + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions) + + // Execute the filter and check the result + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(executeResult.result) + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + /// /// /////// /// @@ -822,84 +888,7 @@ TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution - Custom } } -TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution", "[SimplnxCore][MultiThresholdObjectsFilter]") -{ - UnitTest::LoadPlugins(); - - MultiThresholdObjectsFilter filter; - DataStructure dataStructure = CreateTestDataStructure(); - Arguments args; - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); - - SECTION("Empty ArrayThresholdSet") - { - ArrayThresholdSet thresholdSet; - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - } - SECTION("Empty ArrayThreshold DataPath") - { - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - } - SECTION("Mismatching Components in Threshold Arrays") - { - ArrayThresholdSet thresholdSet; - auto threshold1 = std::make_shared(); - threshold1->setArrayPath(k_TestArrayFloatPath); - threshold1->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold1->setComparisonValue(0.1); - auto threshold2 = std::make_shared(); - threshold2->setArrayPath(k_MismatchingComponentsArrayPath); - threshold2->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold2->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold1, threshold2}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - } - SECTION("Out of Bounds Component Index") - { - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - threshold->setComponentIndex(1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - } - SECTION("Mismatching Tuples in Threshold Arrays") - { - ArrayThresholdSet thresholdSet; - auto threshold1 = std::make_shared(); - threshold1->setArrayPath(k_TestArrayFloatPath); - threshold1->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold1->setComparisonValue(0.1); - auto threshold2 = std::make_shared(); - threshold2->setArrayPath(k_MismatchingTuplesArrayPath); - threshold2->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold2->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold1, threshold2}); - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - } - - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_INVALID(executeResult.result) - - UnitTest::CheckArraysInheritTupleDims(dataStructure); -} TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution - Out of Bounds Custom Values", "[SimplnxCore][MultiThresholdObjectsFilter]", int8, uint8, int16, uint16, int32, uint32, int64, uint64, float32) From 1438e624960192c951a3dd3128fff358789de857 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Thu, 23 Apr 2026 12:49:53 -0400 Subject: [PATCH 08/28] Cleaned up MultiThresholdObjects algorithm * Added function documentation for ApplyThresholdValues * Simplified InsertThreshold parameters. * Deleted unused ThresholdValueFunctor struct. --- .../Algorithms/MultiThresholdObjects.cpp | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index a0d11462be..695621ea9d 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -11,10 +11,18 @@ using namespace nx::core; namespace { +/** + * @brief Consolidate all assignment calls to a single method to prevent unintended diverging behavior. + * @param arrayThreshold Current threshold to pull settings from. + * @param outputResultVector Output vector for the current ThresholdSet. + * @param inputThresholdVector Resulting output for the target array threshold. + * @param replaceInput The first threshould in every set has its output applied to the output regardless of union operator. + * @param trueValue Output mask value when the threshold is satisfied. + * @param falseValue Output mask value when the threshold is not satisfied. + */ template void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, std::vector& outputResultVector, std::vector& inputThresholdVector, bool replaceInput, T trueValue, T falseValue) { - usize totalTuples = outputResultVector.size(); auto unionOperator = arrayThreshold.getUnionOperator(); bool inverse = arrayThreshold.isInverted(); @@ -24,7 +32,7 @@ void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, std::vector& } // insert into current threshold - InsertThreshold(totalTuples, outputResultVector, unionOperator, inputThresholdVector, inverse, trueValue, falseValue); + InsertThreshold(outputResultVector, unionOperator, inputThresholdVector, inverse, trueValue, falseValue); } template @@ -105,8 +113,10 @@ struct ExecuteThresholdHelper * @param inverse */ template -void InsertThreshold(usize numItems, std::vector& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, std::vector& newVector, bool inverse, T trueValue, T falseValue) +void InsertThreshold(std::vector& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, std::vector& newVector, bool inverse, T trueValue, T falseValue) { + usize numItems = currentVector.size(); + for(usize i = 0; i < numItems; i++) { // invert the current comparison if necessary @@ -150,25 +160,6 @@ void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& ApplyThresholdValues(comparisonValue, outputResultVector, tempResultVector, replaceInput, trueValue, falseValue); } -struct ThresholdValueFunctor -{ - template - void operator()(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, IDataArray& outputResultArray, int32_t& err, bool replaceInput, bool inverse, T trueValue, T falseValue) - { - // Traditionally we would do a check to ensure we get a valid pointer, I'm forgoing that check because it - // was essentially done in the preflight part. - auto& outputDataStore = outputResultArray.template getIDataStoreRefAs>(); - usize totalTuples = outputDataStore.getNumberOfTuples(); - std::vector tmpVector(totalTuples, falseValue); - ThresholdValue(comparisonValue, dataStructure, tmpVector, err, replaceInput, inverse, trueValue, falseValue); - - for(size_t i = 0; i < totalTuples; i++) - { - outputDataStore[i] = tmpVector[i]; - } - } -}; - template void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, std::vector& outputResultVector, int32_t& err, bool replaceInput, T trueValue, T falseValue) { From 6f2075d085ba99e33a10d9d1b068041e9bd83a3e Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Thu, 23 Apr 2026 12:59:47 -0400 Subject: [PATCH 09/28] Re-enabled. updated, and cleaned up unit tests * Re-enabled invalid execution and mask DataType unit tests and updated for new tuple counts. * Simplified mask DataType unit tests to remove duplicated code. * Removed unused legacy unit tests. --- .../test/MultiThresholdObjectsTest.cpp | 506 ++---------------- 1 file changed, 48 insertions(+), 458 deletions(-) diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index ba2c11a384..fb32bfd070 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -347,7 +347,6 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Float", double thresholdValue = GENERATE(0.0, 0.01, 0.02, 0.03, 0.04, 26.2); bool isInverted = GENERATE(false, true); - // RunSingleComponentThresholdTests(dataStructure, k_TestArrayIntPath, 3.0, false); SECTION("ArrayThreshold: >") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); @@ -740,158 +739,8 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution", "[SimplnxCore UnitTest::CheckArraysInheritTupleDims(dataStructure); } -/// -/// /////// -/// - -#if false -TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution", "[SimplnxCore][MultiThresholdObjectsFilter]") -{ - UnitTest::LoadPlugins(); - - DataStructure dataStructure = CreateTestDataStructure(); - - SECTION("Float Array Threshold") - { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::boolean)); - - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - auto* thresholdArray = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArray != nullptr); - - // For the comparison value of 0.1, the threshold array elements 0 to 9 should be false and 10 through 19 should be true - for(usize i = 0; i < 20; i++) - { - if(i < 10) - { - REQUIRE((*thresholdArray)[i] == false); - } - else - { - REQUIRE((*thresholdArray)[i] == true); - } - } - } - - SECTION("Int Array Threshold") - { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayIntPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(15); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::boolean)); - - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - auto* thresholdArray = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArray != nullptr); - - // For the comparison value of 0.1, the threshold array elements 0 to 9 should be false and 10 through 19 should be true - for(usize i = 0; i < 20; i++) - { - if(i <= 15) - { - REQUIRE((*thresholdArray)[i] == false); - } - else - { - REQUIRE((*thresholdArray)[i] == true); - } - } - } - - UnitTest::CheckArraysInheritTupleDims(dataStructure); -} - -TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution - Custom Values", "[SimplnxCore][MultiThresholdObjectsFilter]", int8, uint8, int16, uint16, int32, uint32, int64, uint64, - float32, float64) -{ - UnitTest::LoadPlugins(); - - MultiThresholdObjectsFilter filter; - DataStructure dataStructure = CreateTestDataStructure(); - Arguments args; - - float64 trueValue = 25; - float64 falseValue = 10; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayIntPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(15); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_UseCustomTrueValue, std::make_any(true)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CustomTrueValue, std::make_any(trueValue)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_UseCustomFalseValue, std::make_any(true)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CustomFalseValue, std::make_any(falseValue)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(GetDataType())); - - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - auto* thresholdArray = dataStructure.getDataAs>(k_ThresholdArrayPath); - REQUIRE(thresholdArray != nullptr); - - // For the comparison value of 0.1, the threshold array elements 0 to 9 should be false and 10 through 19 should be true - for(usize i = 0; i < 20; i++) - { - if(i <= 15) - { - REQUIRE((*thresholdArray)[i] == falseValue); - } - else - { - REQUIRE((*thresholdArray)[i] == trueValue); - } - } -} - - - -TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution - Out of Bounds Custom Values", "[SimplnxCore][MultiThresholdObjectsFilter]", int8, uint8, int16, uint16, int32, uint32, - int64, uint64, float32) +TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution - Out of Bounds Custom Values", "[SimplnxCore][MultiThresholdObjectsFilter]", int8, uint8, int16, uint16, int32, uint32, int64, + uint64, float32) { UnitTest::LoadPlugins(); @@ -995,395 +844,136 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution - Boolean Custo UnitTest::CheckArraysInheritTupleDims(dataStructure); } +// DataType checks + template void checkMaskValues(const DataStructure& dataStructure, const DataPath& thresholdArrayPath) { auto* thresholdArrayPtr = dataStructure.getDataAs>(thresholdArrayPath); REQUIRE(thresholdArrayPtr != nullptr); - auto& thresholdArray = (*thresholdArrayPtr); + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); // For the comparison value of 0.1, the threshold array elements 0 to 9 should be false and 10 through 19 should be true - for(usize i = 0; i < 20; i++) + for(usize i = 0; i < k_TupleCount; i++) { - if(i < 10) + if(i < 5) { - REQUIRE(thresholdArray[i] == static_cast(0)); + REQUIRE(thresholdStore[i] == static_cast(0)); } else { - REQUIRE(thresholdArray[i] == static_cast(1)); + REQUIRE(thresholdStore[i] == static_cast(1)); } } } -TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution, DataType", "[SimplnxCore][MultiThresholdObjectsFilter]") +template +void runMaskTypeFilter(MultiThresholdObjectsFilter& filter, Arguments& args, DataStructure& dataStructure) +{ + // Preflight the filter and check result + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + // Execute the filter and check the result + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + checkMaskValues(dataStructure, k_ThresholdArrayPath); +} + +TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution, Mask DataType", "[SimplnxCore][MultiThresholdObjectsFilter]") { UnitTest::LoadPlugins(); DataStructure dataStructure = CreateTestDataStructure(); + // Shared filter setup + MultiThresholdObjectsFilter filter; + Arguments args; + + ArrayThresholdSet thresholdSet; + auto threshold = std::make_shared(); + threshold->setArrayPath(k_TestArrayFloatPath); + threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); + threshold->setComparisonValue(0.05); + thresholdSet.setArrayThresholds({threshold}); + + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); + // Signed SECTION("Int8 Threshold") { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::int8)); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - checkMaskValues(dataStructure, k_ThresholdArrayPath); + runMaskTypeFilter(filter, args, dataStructure); } SECTION("Int16 Threshold") { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::int16)); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - checkMaskValues(dataStructure, k_ThresholdArrayPath); + runMaskTypeFilter(filter, args, dataStructure); } SECTION("Int32 Threshold") { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::int32)); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - checkMaskValues(dataStructure, k_ThresholdArrayPath); + runMaskTypeFilter(filter, args, dataStructure); } SECTION("Int64 Threshold") { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::int64)); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - checkMaskValues(dataStructure, k_ThresholdArrayPath); + runMaskTypeFilter(filter, args, dataStructure); } // Unsigned SECTION("UInt8 Threshold") { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::uint8)); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - checkMaskValues(dataStructure, k_ThresholdArrayPath); + runMaskTypeFilter(filter, args, dataStructure); } SECTION("UInt16 Threshold") { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::uint16)); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - checkMaskValues(dataStructure, k_ThresholdArrayPath); + runMaskTypeFilter(filter, args, dataStructure); } SECTION("UInt32 Threshold") { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::uint32)); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - checkMaskValues(dataStructure, k_ThresholdArrayPath); + runMaskTypeFilter(filter, args, dataStructure); } SECTION("UInt64 Threshold") { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::uint64)); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - checkMaskValues(dataStructure, k_ThresholdArrayPath); + runMaskTypeFilter(filter, args, dataStructure); } // Floating Point SECTION("Float32 Threshold") { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::float32)); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - checkMaskValues(dataStructure, k_ThresholdArrayPath); + runMaskTypeFilter(filter, args, dataStructure); } SECTION("Float64 Threshold") { - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_TestArrayFloatPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::float64)); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - checkMaskValues(dataStructure, k_ThresholdArrayPath); - } - - UnitTest::CheckArraysInheritTupleDims(dataStructure); -} - -TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution - Multicomponent", "[SimplnxCore][MultiThresholdObjectsFilter]") -{ - DataStructure dataStructure = CreateTestDataStructure(); - - MultiThresholdObjectsFilter filter; - Arguments args; - - ArrayThresholdSet thresholdSet; - auto threshold = std::make_shared(); - threshold->setArrayPath(k_MultiComponentArrayPath); - threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0); - threshold->setComponentIndex(1); - thresholdSet.setArrayThresholds({threshold}); - - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); - args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::boolean)); - - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) - - auto* thresholdArray = dataStructure.getDataAs(k_ThresholdArrayPath); - REQUIRE(thresholdArray != nullptr); - - usize numTuples = thresholdArray->getNumberOfTuples(); - - // (x, y, z) - // y > 0 - // even tuple indices should be true except 0 - REQUIRE_FALSE((*thresholdArray)[0]); - for(usize i = 1; i < numTuples; i++) - { - bool value = (*thresholdArray)[i]; - if(i % 2 == 0) - { - REQUIRE(value); - } - else - { - REQUIRE_FALSE(value); - } + runMaskTypeFilter(filter, args, dataStructure); } UnitTest::CheckArraysInheritTupleDims(dataStructure); } -#endif - -TEST_CASE("SimplnxCore::MultiThresholdObjectsFilter: SIMPL Backwards Compatibility", "[SimplnxCore][MultiThresholdObjectsFilter][BackwardsCompatibility]") -{ - auto app = Application::GetOrCreateInstance(); - UnitTest::LoadPlugins(); - auto filterList = app->getFilterList(); - - const fs::path conversionDir = fs::path(nx::core::unit_test::k_SourceDir.view()) / "test" / "simpl_conversion"; - - const std::vector> fixtures = { - {"SIMPL 6.5 (UUID)", conversionDir / "6_5" / "MultiThresholdObjectsFilter.json"}, - {"SIMPL 6.4 (Filter_Name)", conversionDir / "6_4" / "MultiThresholdObjectsFilter.json"}, - }; - - for(const auto& [label, fixturePath] : fixtures) - { - DYNAMIC_SECTION(label) - { - auto pipelineResult = Pipeline::FromSIMPLFile(fixturePath, filterList); - REQUIRE(pipelineResult.valid()); - - auto& pipeline = pipelineResult.value(); - REQUIRE(pipeline.size() == 1); - - auto* pipelineFilter = dynamic_cast(pipeline.at(0)); - REQUIRE(pipelineFilter != nullptr); - - const IFilter* filter = pipelineFilter->getFilter(); - REQUIRE(filter != nullptr); - REQUIRE(filter->uuid() == FilterTraits::uuid); - - const Arguments args = pipelineFilter->getArguments(); - CHECK(args.value(MultiThresholdObjectsFilter::k_CreatedDataName_Key) == "TestName"); - } - } -} From 69ae773d35ddfc8932c047fb6fb8775b2e6e0954 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Fri, 24 Apr 2026 10:16:43 -0400 Subject: [PATCH 10/28] Clang format unit test file --- src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index fb32bfd070..cb0fd4dc1b 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -739,8 +739,8 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution", "[SimplnxCore UnitTest::CheckArraysInheritTupleDims(dataStructure); } -TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution - Out of Bounds Custom Values", "[SimplnxCore][MultiThresholdObjectsFilter]", int8, uint8, int16, uint16, int32, uint32, int64, - uint64, float32) +TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution - Out of Bounds Custom Values", "[SimplnxCore][MultiThresholdObjectsFilter]", int8, uint8, int16, uint16, int32, uint32, + int64, uint64, float32) { UnitTest::LoadPlugins(); From e008748a7aad80c1d47da41d8fc5f36c7e3b3a62 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Fri, 24 Apr 2026 10:37:08 -0400 Subject: [PATCH 11/28] Fixed macOS / Linux compile error --- .../Algorithms/MultiThresholdObjects.cpp | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index 695621ea9d..d539154123 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -11,6 +11,38 @@ using namespace nx::core; namespace { +/** + * @brief InsertThreshold + * @param numItems + * @param currentArrayPtr + * @param unionOperator + * @param newArrayPtr + * @param inverse + */ +template +void InsertThreshold(std::vector& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, std::vector& newVector, bool inverse, T trueValue, T falseValue) +{ + usize numItems = currentVector.size(); + + for(usize i = 0; i < numItems; i++) + { + // invert the current comparison if necessary + if(inverse) + { + newVector[i] = (newVector[i] == trueValue) ? falseValue : trueValue; + } + + if(nx::core::IArrayThreshold::UnionOperator::Or == unionOperator) + { + currentVector[i] = (currentVector[i] == trueValue || newVector[i] == trueValue) ? trueValue : falseValue; + } + else if(currentVector[i] == falseValue || newVector[i] == falseValue) + { + currentVector[i] = falseValue; + } + } +} + /** * @brief Consolidate all assignment calls to a single method to prevent unintended diverging behavior. * @param arrayThreshold Current threshold to pull settings from. @@ -104,38 +136,6 @@ struct ExecuteThresholdHelper } }; -/** - * @brief InsertThreshold - * @param numItems - * @param currentArrayPtr - * @param unionOperator - * @param newArrayPtr - * @param inverse - */ -template -void InsertThreshold(std::vector& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, std::vector& newVector, bool inverse, T trueValue, T falseValue) -{ - usize numItems = currentVector.size(); - - for(usize i = 0; i < numItems; i++) - { - // invert the current comparison if necessary - if(inverse) - { - newVector[i] = (newVector[i] == trueValue) ? falseValue : trueValue; - } - - if(nx::core::IArrayThreshold::UnionOperator::Or == unionOperator) - { - currentVector[i] = (currentVector[i] == trueValue || newVector[i] == trueValue) ? trueValue : falseValue; - } - else if(currentVector[i] == falseValue || newVector[i] == falseValue) - { - currentVector[i] = falseValue; - } - } -} - template void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, std::vector& outputResultVector, int32_t& err, bool replaceInput, T trueValue, T falseValue) { From b9d41b98419af74f1f6ca81d1f24db56683d8845 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Tue, 28 Apr 2026 13:19:33 -0400 Subject: [PATCH 12/28] Updated for large data * Converted std::vector data to AbstractDataStore using DataStoreUtilities. --- .../Algorithms/MultiThresholdObjects.cpp | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index d539154123..c606f58230 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -3,6 +3,7 @@ #include "simplnx/Common/TypeTraits.hpp" #include "simplnx/DataStructure/DataArray.hpp" #include "simplnx/Utilities/ArrayThreshold.hpp" +#include "simplnx/Utilities/DataStoreUtilities.hpp" #include "simplnx/Utilities/FilterUtilities.hpp" #include @@ -20,9 +21,9 @@ namespace * @param inverse */ template -void InsertThreshold(std::vector& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, std::vector& newVector, bool inverse, T trueValue, T falseValue) +void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, AbstractDataStore& newVector, bool inverse, T trueValue, T falseValue) { - usize numItems = currentVector.size(); + usize numItems = currentVector.getNumberOfTuples(); for(usize i = 0; i < numItems; i++) { @@ -46,14 +47,14 @@ void InsertThreshold(std::vector& currentVector, nx::core::IArrayThreshold::U /** * @brief Consolidate all assignment calls to a single method to prevent unintended diverging behavior. * @param arrayThreshold Current threshold to pull settings from. - * @param outputResultVector Output vector for the current ThresholdSet. - * @param inputThresholdVector Resulting output for the target array threshold. + * @param outputResultStore Output DataStore for the current ThresholdSet. + * @param inputThresholdStore Resulting output for the target array threshold. * @param replaceInput The first threshould in every set has its output applied to the output regardless of union operator. * @param trueValue Output mask value when the threshold is satisfied. * @param falseValue Output mask value when the threshold is not satisfied. */ template -void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, std::vector& outputResultVector, std::vector& inputThresholdVector, bool replaceInput, T trueValue, T falseValue) +void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, AbstractDataStore& outputResultStore, AbstractDataStore& inputThresholdStore, bool replaceInput, T trueValue, T falseValue) { auto unionOperator = arrayThreshold.getUnionOperator(); bool inverse = arrayThreshold.isInverted(); @@ -64,14 +65,14 @@ void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, std::vector& } // insert into current threshold - InsertThreshold(outputResultVector, unionOperator, inputThresholdVector, inverse, trueValue, falseValue); + InsertThreshold(outputResultStore, unionOperator, inputThresholdStore, inverse, trueValue, falseValue); } template class ThresholdFilterHelper { public: - ThresholdFilterHelper(ArrayThreshold::ComparisonType compType, ArrayThreshold::ComparisonValue compValue, usize componentIndex, std::vector& output) + ThresholdFilterHelper(ArrayThreshold::ComparisonType compType, ArrayThreshold::ComparisonValue compValue, usize componentIndex, AbstractDataStore& output) : m_ComparisonOperator(compType) , m_ComparisonValue(compValue) , m_ComponentIndex(componentIndex) @@ -123,7 +124,7 @@ class ThresholdFilterHelper ArrayThreshold::ComparisonType m_ComparisonOperator; ArrayThreshold::ComparisonValue m_ComparisonValue; usize m_ComponentIndex = 0; - std::vector& m_Output; + AbstractDataStore& m_Output; }; struct ExecuteThresholdHelper @@ -137,11 +138,13 @@ struct ExecuteThresholdHelper }; template -void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, std::vector& outputResultVector, int32_t& err, bool replaceInput, T trueValue, T falseValue) +void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, int32_t& err, bool replaceInput, T trueValue, T falseValue) { // Get the total number of tuples, create and initialize an array with FALSE to use for these results - size_t totalTuples = outputResultVector.size(); - std::vector tempResultVector(totalTuples, falseValue); + size_t totalTuples = outputResultVector.getNumberOfTuples(); + auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); + AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); + std::fill(tempResultStore.begin(), tempResultStore.end(), falseValue); nx::core::ArrayThreshold::ComparisonType compOperator = comparisonValue.getComparisonType(); nx::core::ArrayThreshold::ComparisonValue compValue = comparisonValue.getComparisonValue(); @@ -151,21 +154,23 @@ void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& usize componentIndex = comparisonValue.getComponentIndex(); - ThresholdFilterHelper helper(compOperator, compValue, componentIndex, tempResultVector); + ThresholdFilterHelper helper(compOperator, compValue, componentIndex, tempResultStore); const auto& iDataArray = dataStructure.getDataRefAs(inputDataArrayPath); ExecuteDataFunction(ExecuteThresholdHelper{}, iDataArray.getDataType(), helper, iDataArray, trueValue, falseValue); - ApplyThresholdValues(comparisonValue, outputResultVector, tempResultVector, replaceInput, trueValue, falseValue); + ApplyThresholdValues(comparisonValue, outputResultVector, tempResultStore, replaceInput, trueValue, falseValue); } template -void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, std::vector& outputResultVector, int32_t& err, bool replaceInput, T trueValue, T falseValue) +void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, int32_t& err, bool replaceInput, T trueValue, T falseValue) { // Get the total number of tuples, create and initialize an array with FALSE to use for these results - size_t totalTuples = outputResultVector.size(); - std::vector tempResultVector(totalTuples, falseValue); + size_t totalTuples = outputResultVector.getNumberOfTuples(); + auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); + AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); + std::fill(tempResultStore.begin(), tempResultStore.end(), falseValue); bool firstValueFound = false; @@ -175,18 +180,18 @@ void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructu const IArrayThreshold* thresholdPtr = threshold.get(); if(const auto* comparisonSet = dynamic_cast(thresholdPtr); comparisonSet != nullptr) { - ThresholdSet(*comparisonSet, dataStructure, tempResultVector, err, !firstValueFound, trueValue, falseValue); + ThresholdSet(*comparisonSet, dataStructure, tempResultStore, err, !firstValueFound, trueValue, falseValue); firstValueFound = true; } else if(const auto* comparisonValue = dynamic_cast(thresholdPtr); comparisonValue != nullptr) { - ThresholdValue(*comparisonValue, dataStructure, tempResultVector, err, !firstValueFound, trueValue, falseValue); + ThresholdValue(*comparisonValue, dataStructure, tempResultStore, err, !firstValueFound, trueValue, falseValue); firstValueFound = true; } } // Apply resulting values to output - ApplyThresholdValues(inputComparisonSet, outputResultVector, tempResultVector, replaceInput, trueValue, falseValue); + ApplyThresholdValues(inputComparisonSet, outputResultVector, tempResultStore, replaceInput, trueValue, falseValue); } struct ThresholdSetFunctor @@ -198,12 +203,14 @@ struct ThresholdSetFunctor // was essentially done in the preflight part. auto& outputDataStore = outputResultArray.template getIDataStoreRefAs>(); usize totalTuples = outputDataStore.getNumberOfTuples(); - std::vector tmpVector(totalTuples, falseValue); - ThresholdSet(inputComparisonSet, dataStructure, tmpVector, err, replaceInput, trueValue, falseValue); + auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); + AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); + std::fill(tempResultStore.begin(), tempResultStore.end(), falseValue); + ThresholdSet(inputComparisonSet, dataStructure, tempResultStore, err, replaceInput, trueValue, falseValue); for(size_t i = 0; i < totalTuples; i++) { - outputDataStore[i] = tmpVector[i]; + outputDataStore[i] = tempResultStore[i]; } } }; From 4b05356c8bc8c676d051ff8a2ce11de9f01d26bc Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Thu, 23 Jul 2026 19:17:12 -0400 Subject: [PATCH 13/28] Add input array DataType coverage tests --- .../test/MultiThresholdObjectsTest.cpp | 166 +++++++++++++++++- 1 file changed, 162 insertions(+), 4 deletions(-) diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index cb0fd4dc1b..2a209c1e35 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -69,10 +69,10 @@ DataStructure CreateTestDataStructure() Int32Array* data1 = Int32Array::CreateWithStore(dataStructure, k_TestArrayIntName, tDims, cDims, am->getId()); Int32Array* multiComponentData = Int32Array::CreateWithStore(dataStructure, k_MultiComponentArrayName, tDims, cDimsMulti, am->getId()); - Float32Array* invalid1 = Float32Array::CreateWithStore(dataStructure, k_MismatchingComponentsArrayPath.getTargetName(), tDims, cDimsMulti, am->getId()); - invalid1->fill(1.0); - Float32Array* invalid2 = Float32Array::CreateWithStore(dataStructure, k_MismatchingTuplesArrayPath.getTargetName(), std::vector{10}, cDims); - invalid2->fill(2.0); + // Mismatched tuple count throws an error. + // This is not true for mismatched component shapes. + Float32Array* problemArray = Float32Array::CreateWithStore(dataStructure, k_MismatchingTuplesArrayPath.getTargetName(), std::vector{10}, cDims); + problemArray->fill(2.0); usize numComponents = multiComponentData->getNumberOfComponents(); @@ -92,6 +92,58 @@ DataStructure CreateTestDataStructure() return dataStructure; } +template +void SetArrayValues(DataArray& dataArray) +{ + auto& dataStore = dataArray.getDataStoreRef(); + usize count = dataStore.size(); + for(usize i = 0; i < count; i++) + { + dataStore[i] = static_cast(i); + } +} + +DataStructure CreateTestDataStructure2() +{ + DataStructure dataStructure; + // Create two test arrays, a float array and a int array + // Set up geometry for tuples, a cuboid with dimensions 20, 10, 1 + ImageGeom* image = ImageGeom::Create(dataStructure, k_ImageGeometry); + std::vector dims = {k_TupleCount, 1, 1}; + image->setDimensions(dims); + + ShapeType tDims = {k_TupleCount}; + ShapeType cDims = {1}; + ShapeType cDimsMulti = {k_MultiComponentCount}; + + AttributeMatrix* am = AttributeMatrix::Create(dataStructure, k_CellData, tDims, image->getId()); + auto* int8Array = Int8Array::CreateWithStore(dataStructure, "int8", tDims, cDims, am->getId()); + auto* int16Array = Int16Array::CreateWithStore(dataStructure, "int16", tDims, cDims, am->getId()); + auto* int32Array = Int32Array::CreateWithStore(dataStructure, "int32", tDims, cDims, am->getId()); + auto* int64Array = Int64Array::CreateWithStore(dataStructure, "int64", tDims, cDims, am->getId()); + auto* uint8Array = UInt8Array::CreateWithStore(dataStructure, "uint8", tDims, cDims, am->getId()); + auto* uint16Array = UInt16Array::CreateWithStore(dataStructure, "uint16", tDims, cDims, am->getId()); + auto* uint32Array = UInt32Array::CreateWithStore(dataStructure, "uint32", tDims, cDims, am->getId()); + auto* uint64Array = UInt64Array::CreateWithStore(dataStructure, "uint64", tDims, cDims, am->getId()); + auto* float32Array = Float32Array::CreateWithStore(dataStructure, "float32", tDims, cDims, am->getId()); + auto* float64Array = Float64Array::CreateWithStore(dataStructure, "float64", tDims, cDims, am->getId()); + auto* boolArray = BoolArray::CreateWithStore(dataStructure, "bool", tDims, cDims, am->getId()); + + SetArrayValues(*int8Array); + SetArrayValues(*int16Array); + SetArrayValues(*int32Array); + SetArrayValues(*int64Array); + SetArrayValues(*uint8Array); + SetArrayValues(*uint16Array); + SetArrayValues(*uint32Array); + SetArrayValues(*uint64Array); + SetArrayValues(*float32Array); + SetArrayValues(*float64Array); + SetArrayValues(*boolArray); + + return dataStructure; +} + /** * @brief Creates a single threshold for the filter to use. * @param arrayPath Input DataArray path @@ -977,3 +1029,109 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution, Mask DataType", UnitTest::CheckArraysInheritTupleDims(dataStructure); } + +void TestMaskOutputForInputType(Int8AbstractDataStore& mask, float64 comparisonValue) +{ + usize count = mask.size(); + for(usize i = 0; i < count; i++) + { + int8 targetValue = (i < comparisonValue) ? 1 : 0; + REQUIRE(mask[i] == targetValue); + } +} + +TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution, Input Array DataType", "[SimplnxCore][MultiThresholdObjectsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = CreateTestDataStructure2(); + + float64 comparisonValue = 3.0; + DataPath matrixPath({k_ImageGeometry, k_CellData}); + + // Shared filter setup + MultiThresholdObjectsFilter filter; + Arguments args; + + ArrayThresholdSet thresholdSet; + auto threshold = std::make_shared(); + threshold->setComparisonType(ArrayThreshold::ComparisonType::LessThan); + threshold->setComparisonValue(comparisonValue); + thresholdSet.setArrayThresholds({threshold}); + + // Signed + SECTION("Int8") + { + threshold->setArrayPath(matrixPath.createChildPath("int8")); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + SECTION("Int16") + { + threshold->setArrayPath(matrixPath.createChildPath("int16")); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + SECTION("Int32") + { + threshold->setArrayPath(matrixPath.createChildPath("int32")); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + SECTION("Int64") + { + threshold->setArrayPath(matrixPath.createChildPath("int64")); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + // Unsigned + SECTION("UInt8") + { + threshold->setArrayPath(matrixPath.createChildPath("uint8")); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + SECTION("UInt16") + { + threshold->setArrayPath(matrixPath.createChildPath("uint16")); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + SECTION("UInt32") + { + threshold->setArrayPath(matrixPath.createChildPath("uint32")); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + SECTION("UInt64") + { + threshold->setArrayPath(matrixPath.createChildPath("uint64")); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + // Floating Point + SECTION("Float32") + { + threshold->setArrayPath(matrixPath.createChildPath("float32")); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + SECTION("Float64") + { + threshold->setArrayPath(matrixPath.createChildPath("float64")); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + } + // Bool + SECTION("Boolean") + { + threshold->setArrayPath(matrixPath.createChildPath("bool")); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + comparisonValue = 0.9; + } + + args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); + args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(DataType::int8)); + + // Preflight the filter and check result + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + // Execute the filter and check the result + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + auto* maskArray = dataStructure.getDataAs(matrixPath.createChildPath(k_ThresholdArrayName)); + auto& maskStore = maskArray->getDataStoreRef(); + TestMaskOutputForInputType(maskStore, comparisonValue); +} From 13921f5ec4be1651d3e578bae8bda91ab5f9ba23 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Thu, 23 Jul 2026 19:59:39 -0400 Subject: [PATCH 14/28] Add: V&V docs Removed stale testing constant. --- .../test/MultiThresholdObjectsTest.cpp | 1 - .../vv/MultiThresholdObjectsFilter.md | 131 ++++++++++++++++++ .../deviations/MultiThresholdObjectsFilter.md | 41 ++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md create mode 100644 src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index 2a209c1e35..bc54c0481f 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -28,7 +28,6 @@ const DataPath k_TestArrayIntPath = k_ImageCellDataName.createChildPath(k_TestAr const DataPath k_MultiComponentArrayPath = k_ImageCellDataName.createChildPath(k_MultiComponentArrayName); const DataPath k_ThresholdArrayPath = k_ImageCellDataName.createChildPath(k_ThresholdArrayName); -const DataPath k_MismatchingComponentsArrayPath = k_ImageCellDataName.createChildPath("MismatchingComponentsArray"); const DataPath k_MismatchingTuplesArrayPath({"MismatchingTuplesArray"}); constexpr int8 k_TupleCount = 5; diff --git a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md new file mode 100644 index 0000000000..0b9577ecd8 --- /dev/null +++ b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md @@ -0,0 +1,131 @@ +# V&V Report: MultiThresholdObjectsFilter + +| | | +|--------|--------------| +| Plugin | SimplnxCore | +| SIMPLNX UUID | `4246245e-1011-4add-8436-0af6bed19228` | +| DREAM3D 6.5.171 equivalent | Two separate legacy filters, consolidated: **Threshold Objects** (`MultiThresholdObjects`, SIMPL UUID `014b7300-cf36-5ede-a751-5faf9b119dae`) and **Threshold Objects (Advanced)** (`MultiThresholdObjects2`, SIMPL UUID `686d5393-2b02-5c86-b887-dd81a8ae80f2`) — both mapped to this single filter's UUID in `SimplnxCoreLegacyUUIDMapping.hpp` (see Algorithm Relationship) | +| Verified commit | ** | +| Status | READY FOR REVIEW | +| Sign-off | *pending — DRAFT, not yet reviewed* | + +## At a glance + +| Aspect | Current state | +|------------------------|------------------------------------------------------------------------------------------------------------------------------| +| Algorithm Relationship | **Rewrite.** Consolidates two independently-shipped legacy filters — **Threshold Objects** (flat, AND-only) and **Threshold Objects (Advanced)** (nested AND/OR sets) — into one SIMPLNX filter under one new UUID, unified around a single `ArrayThresholdSet` model. Not a line-by-line translation of either legacy source. | +| Oracle (confirmed) | **Class 1 (Analytical) — confirmed.** `expected[i] = COMPARISON(input[i], value)`, hand-combined via AND/OR/invert boolean algebra. Encoded as 9 `TEST_CASE` groups (17 ctest entries) in `MultiThresholdObjectsTest.cpp`, all pass. | +| Code paths enumerated | **23 of 24 exercised.** Only the unreachable comparison-operator `else`-throw is untested (all four `ComparisonType` enumerators are covered elsewhere). | +| Tests today | **9 `TEST_CASE` groups / 17 ctest entries.** Exhaustive sweeps over comparison operator × invert × union operator × set nesting × mask `DataType` (11 types) × source-array `DataType` (11 types), plus 4 negative/error-path groups. All fixtures built in-memory. | +| Exemplar archive | **None.** All fixtures are constructed in-memory by `CreateTestDataStructure()` / `CreateTestDataStructure2()`; no `.dream3d` exemplar or `download_test_data()` entry exists for this filter. | +| Legacy comparison | **Not run** — no local build of legacy DREAM3D 6.5.171 (or its Advanced comparison-set variant) is available in this environment. Deferred; see `vv/deviations/MultiThresholdObjectsFilter.md`. | +| Bug flags | None identified. Provisional — no legacy comparison has been run yet. | +| V&V phase | Oracle chosen and applied (Class 1), code paths enumerated (23/24), test inventory documented. **Outstanding:** legacy 6.5.171 runtime A/B comparison, second-engineer oracle review, status promotion DRAFT → READY FOR REVIEW. | + +For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrientationCheckFilter.md` and `src/Plugins/OrientationAnalysis/vv/ComputeAvgCAxesFilter.md` (on `topic/vv/compute_avg_caxis`). + +## Summary + +`MultiThresholdObjectsFilter` builds a typed mask array by elementwise-comparing one or more input arrays against user-supplied thresholds, combined through an arbitrarily-nested tree of AND/OR/invert `ArrayThresholdSet`s. Verification uses a **Class 1 (Analytical) oracle**: every comparison operator, invert flag, union operator, nesting depth, and both the mask-output and source-input `DataType` are exhaustively hand-derived and asserted in `MultiThresholdObjectsTest.cpp` (9 `TEST_CASE` groups, all passing). 23 of 24 algorithm/preflight code paths are exercised; the filter has not yet been diffed against legacy DREAM3D 6.5.171. + +## Algorithm Relationship + +*Classification:* **Rewrite** + +*Evidence:* `SimplnxCoreLegacyUUIDMapping.hpp` maps **two distinct legacy SIMPL UUIDs** to this single SIMPLNX filter's UUID: + +``` +014b7300-cf36-5ede-a751-5faf9b119dae → MultiThresholdObjectsFilter // MultiThresholdObjects ("Threshold Objects") +686d5393-2b02-5c86-b887-dd81a8ae80f2 → MultiThresholdObjectsFilter // MultiThresholdObjects2 ("Threshold Objects (Advanced)") +``` + +`FromSIMPLJson()` correspondingly branches on which legacy UUID (or, for 6.4 pipelines lacking a UUID, which legacy class name) produced the incoming JSON: the basic `MultiThresholdObjects` source is read through `ComparisonSelectionFilterParameterConverter` (flat, AND-only comparison list) and the advanced `MultiThresholdObjects2` source is read through `ComparisonSelectionAdvancedFilterParameterConverter` (nested AND/OR comparison sets). Both are converted into the same `ArrayThresholdSet` argument. This is **not** a line-by-line port of a single legacy algorithm — it's a consolidation of two independently-shipped legacy filters into one, which is why the classification is **Rewrite** rather than Port, per `vv_policy.md`: *"keeping [a UUID relationship] is a claim of functional equivalence... The Deviations file must defend the claim."* Here the claim is stronger than usual — that the merged filter reproduces each of the two legacy filters' behavior when configured equivalently to it. SIMPL 6.4/6.5 conversion fixtures exist at `test/simpl_conversion/6_4/MultiThresholdObjectsFilter.json` and `test/simpl_conversion/6_5/MultiThresholdObjectsFilter.json`, but these only assert the *argument conversion* round-trips correctly, not that execution output matches either legacy filter — that's the outstanding legacy A/B (see Deviations file). + +*Structural differences from each legacy source (none independently confirmed by a legacy A/B yet):* + +1. **Consolidation itself** — one `ArrayThresholdSet` tree replaces two separate legacy parameter models (flat list vs. nested set); the flat legacy model is representable as a one-level `ArrayThresholdSet`, but this equivalence has not been verified end-to-end against either legacy filter's actual output. +2. Multi-component index selection added (`#1184`, `32837a30f`) — not present in the legacy `Threshold Objects` filter (scalar-only comparison); unclear whether `Threshold Objects (Advanced)` had an equivalent. +3. Custom TRUE/FALSE mask output values added (`#669`, `b65210cf3`) — additive parameter; unconfirmed whether either legacy filter had this option or if it's SIMPLNX-only. +4. Default mask output `DataType` changed to `uint8` (`#1502`, `49919b086`) — unconfirmed against either legacy filter's default. +5. `executeImpl()` body moved into `Algorithms/MultiThresholdObjects.{hpp,cpp}` (`#1544`, `8381d1dd5`) — structural only, no behavior change (internal to SIMPLNX, not a legacy-relationship concern). + +*Material PRs since baseline (2025-10-01):* none identified beyond the deltas above and this branch's `vv/MultiThresholdObjects` restructuring + test work. + +## Oracle + +*Class:* **1 (Analytical)** + +*Applied:* For a single threshold, `expected[i] = COMPARISON(input[i], value)` (optionally inverted); for a component-indexed array, `input[i]` is replaced by `input[i][componentIndex]`. For a threshold set, `expected` is the boolean combination of each member's own `expected` value: the first member always seeds the accumulator, and the configured `UnionOperator` (AND/OR) combines each subsequent member; the whole set's `expected` is inverted again if the set itself is marked inverted. Every free variable in this formula — comparison operator, invert flag, union operator, set nesting, component index, mask output `DataType`, and source-array `DataType` — is enumerated directly against this closed-form definition in the test file's `Expected*Mask` helper functions, independent of the algorithm's own C++ control flow (`ThresholdFilterHelper`, `InsertThreshold`, `ApplyThresholdValues`). + +*Encoded:* `test/MultiThresholdObjectsTest.cpp` — + +- `Valid Single Thresholds: Int` / `: Float` / `: Int Multi-Component` — comparison operator × invert × (component index for multi-component) sweep, via `ExpectedIntSingleComponentMask` / `ExpectedFloatSingleComponentMask` / `ExpectedIntMultiComponentMask` +- `Valid Threshold Sets` — 5 hand-built AND / OR / nested-set / nested-set-with-OR / nested-set-with-OR+invert configurations (`CreateThresholdSet1`–`5`), via `ExpectedThresholdSet1Mask`–`5` +- `Valid Execution, Mask DataType` — 11 mask-output `DataType`s +- `Valid Execution, Input Array DataType` — 11 source-array `DataType`s +- `Invalid Execution`, `Invalid Execution - Out of Bounds Custom Values` (9 numeric types), `Invalid Execution - Boolean Custom Values` — negative-path fixtures + +9 `TEST_CASE` groups (17 ctest entries, counting the 9 `TEMPLATE_TEST_CASE` type instantiations separately), all pass at HEAD. + +*Second-engineer review:* Skipped — recorded reason: the oracle is elementwise comparison plus boolean set algebra (AND/OR/invert), and the test matrix enumerates it exhaustively (every operator × invert × union operator × nesting × both `DataType` axes) rather than sampling a single hand-derivation, substituting breadth for independent derivation review. **This is not a substitute for a named second-engineer pass** — it is recorded here as an outstanding gate for promotion past DRAFT, not a completed one. + +## Code path coverage + +**23 of 24 paths exercised.** The one gap is low-risk (row 13). + +Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp` (~255 lines), plus 7 preflight-only paths in `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp`. + +Two logical stages: **(a) preflight** validates the threshold set / mask-type / custom-value configuration and stages the output `CreateArrayAction`; **(b) algorithm** recursively evaluates the `ArrayThresholdSet` tree (per-array comparison → per-set union/invert/replace combination) and writes the result into the mask array via a type-dispatched functor. + +| # | Stage | Path | Test case | +|----|-------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| +| 1 | (a) Preflight | `thresholdPaths.empty()` → error `-4000` | `Invalid Execution` — "Empty ArrayThresholdSet" | +| 2 | (a) Preflight | Tuple-count mismatch across threshold arrays → `ErrorCodes::UnequalTuples` | `Invalid Execution` — "Mismatching Tuples in Threshold Arrays" | +| 3 | (a) Preflight | `componentIndex >= numComponents` (via `CheckComponentIndicesInThresholds`, recurses into nested sets) → `ErrorCodes::InvalidComponentIndex` | `Invalid Execution` — "Out of Bounds Component Index" | +| 4 | (a) Preflight | `maskArrayType == boolean && useCustomTrueValue` → `CustomTrueWithBoolean` | `Invalid Execution - Boolean Custom Values` — "Custom True Value" | +| 5 | (a) Preflight | `maskArrayType == boolean && useCustomFalseValue` → `CustomFalseWithBoolean` | `Invalid Execution - Boolean Custom Values` — "Custom False Value" | +| 6 | (a) Preflight | `useCustomTrueValue` value outside `[min,max]` of mask type → `CustomTrueOutOfBounds` | `TEMPLATE_TEST_CASE …Out of Bounds Custom Values` — "True Value < Minimum" / "> Maximum", all 9 numeric types | +| 7 | (a) Preflight | `useCustomFalseValue` value outside bounds → `CustomFalseOutOfBounds` | same — "False Value < Minimum" / "> Maximum" | +| 8 | (a) Preflight | Success → stage `CreateArrayAction` for mask output | every "Valid …" test | +| 9 | (b) Algorithm | `ComparisonType::LessThan` | "ArrayThreshold: <" (int / float / multi-component) | +| 10 | (b) Algorithm | `ComparisonType::GreaterThan` | "ArrayThreshold: >" | +| 11 | (b) Algorithm | `ComparisonType::Operator_Equal` | "ArrayThreshold: ==" | +| 12 | (b) Algorithm | `ComparisonType::Operator_NotEqual` | "ArrayThreshold: !=" | +| 13 | (b) Algorithm | `else` → `throw std::runtime_error` (unrecognized comparison type) | *Not directly tested. Unreachable via the public `ComparisonType` enum — all enumerators are exercised by rows 9–12.* | +| 14 | (b) Algorithm | `InsertThreshold` with `inverse == true` (flip before combine) | `isInverted = GENERATE(false, true)` in every single-threshold and threshold-set test | +| 15 | (b) Algorithm | `InsertThreshold` with `inverse == false` | same | +| 16 | (b) Algorithm | Combine with `UnionOperator::Or` | `CreateThresholdSet2` (threshold2 = Or), `CreateThresholdSet4`/`5` (nested-set union = Or) | +| 17 | (b) Algorithm | Combine with `UnionOperator::And` (else branch) | `CreateThresholdSet1` (threshold2/3 = And), `CreateThresholdSet3` default nested And | +| 18 | (b) Algorithm | `ApplyThresholdValues` with `replaceInput == true` (first item in a set forces Or regardless of configured operator) | implicit in every threshold set — first entry of every `CreateThresholdSet*` | +| 19 | (b) Algorithm | `ApplyThresholdValues` with `replaceInput == false` (honors configured operator for later items) | same sets, 2nd/3rd entries | +| 20 | (b) Algorithm | `ThresholdSet` recursion — item is a nested `ArrayThresholdSet` | `CreateThresholdSet3`/`4`/`5` (set-of-sets) | +| 21 | (b) Algorithm | `ThresholdSet` — item is a leaf `ArrayThreshold` | all tests | +| 22 | (b) Algorithm | `ThresholdSetFunctor` dispatch on **mask (output) DataType** | `Valid Execution, Mask DataType` — boolean + int8/16/32/64 + uint8/16/32/64 + float32/64, all 11 types | +| 23 | (b) Algorithm | `ExecuteThresholdHelper` dispatch on **source array's DataType** | `Valid Execution, Input Array DataType` — int8/16/32/64 + uint8/16/32/64 + float32/64 + boolean, all 11 types | +| 24 | (b) Algorithm | Multi-component `componentIndex != 0` selection | `Valid Single Thresholds: Int Multi-Component` (`componentIndex = GENERATE(0,1,2)`), plus `componentIndex=1` in Set1, `=0` in Set2 | + +Not counted as an algorithm/preflight path: the "Empty ArrayThreshold DataPath" section of `Invalid Execution` exercises `ArrayThresholdsParameter`'s own path-existence validation, which runs before `preflightImpl` is called — it's a parameter-layer gate, not code inside this filter or algorithm. + +Also note: `k_MismatchingComponentsArrayPath` (test file, line 31) is a leftover unused `DataPath` constant — the array it used to name was removed when `Valid Execution, Input Array DataType` was added. Not a coverage gap (the filter has no cross-array component-count check), just dead test-source code worth deleting. + +## Test inventory + +| Test case | Status | Notes | +|-----------|--------|-------| +| `Valid Single Thresholds: Int` | kept | `GENERATE` over 8 threshold values × 2 invert states, 4 `SECTION`s (`>`, `<`, `==`, `!=`) against `k_TestArrayIntPath`; every tuple checked via `ExpectedIntSingleComponentMask`. | +| `Valid Single Thresholds: Float` | kept | Same sweep against `k_TestArrayFloatPath` via `ExpectedFloatSingleComponentMask`. | +| `Valid Single Thresholds: Int Multi-Component` | kept | Adds `componentIndex = GENERATE(0,1,2)` against `k_MultiComponentArrayPath`. | +| `Valid Threshold Sets` | kept | 5 `SECTION`s (`ArraySet 1`–`5`) covering AND, OR, nested-set, nested-set-with-OR, and nested-set-with-OR+invert combinations, each × `isInverted`. | +| `Invalid Execution` | kept | 4 `SECTION`s: empty threshold set (`-4000`), empty threshold `DataPath` (parameter-layer validation), out-of-bounds component index (`InvalidComponentIndex`), mismatched tuple counts (`UnequalTuples`). | +| `Invalid Execution - Out of Bounds Custom Values` (`TEMPLATE_TEST_CASE`) | kept | 9 numeric-type instantiations × 4 `SECTION`s (true/false value below minimum / above maximum) — `CustomTrueOutOfBounds` / `CustomFalseOutOfBounds`. | +| `Invalid Execution - Boolean Custom Values` | kept | 2 `SECTION`s — custom TRUE/FALSE value rejected when mask type is `boolean`. | +| `Valid Execution, Mask DataType` | kept | 11 `SECTION`s, one per mask-output `DataType` (int8…float64; boolean covered via the default mask type used throughout the other `TEST_CASE`s). | +| `Valid Execution, Input Array DataType` | new-for-V&V (`d18b0f34d`, 2026-07-23) | 11 `SECTION`s, one per **source-array** `DataType` (int8…float64, bool) — closes the code-path gap on row 23 identified during path enumeration. | + +## Exemplar archive + +None. All fixtures for this filter are constructed in-memory in `test/MultiThresholdObjectsTest.cpp` (`CreateTestDataStructure()`, `CreateTestDataStructure2()`); there is no `.dream3d` exemplar and no `download_test_data()` entry in `test/CMakeLists.txt` for `MultiThresholdObjectsFilter`. No provenance sidecar is needed. + +## Deviations from DREAM3D 6.5.171 + +- Legacy comparison has **not been run**. 0 deviation entries exist. See `vv/deviations/MultiThresholdObjectsFilter.md` for the reason and the configurations to prioritize once a legacy build is available. diff --git a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md new file mode 100644 index 0000000000..091f1190f8 --- /dev/null +++ b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md @@ -0,0 +1,41 @@ +# Deviations from DREAM3D 6.5.171: MultiThresholdObjectsFilter + +This file lists every documented behavioral difference between this SIMPLNX filter and its DREAM3D 6.5.171 equivalent. + +Entries are referenced by stable ID (`MultiThresholdObjectsFilter-D`) from the V&V report and from public migration guidance. The ID is stable across renames; the Filter UUID field is the permanent cross-reference anchor. + +--- + +## Status: comparison not yet run + +No local build of legacy DREAM3D 6.5.171 is available in this environment. No runtime A/B has been performed against **either** legacy predecessor: + +- **Threshold Objects** (`MultiThresholdObjects`, SIMPL UUID `014b7300-cf36-5ede-a751-5faf9b119dae`) — flat, AND-only comparison list +- **Threshold Objects (Advanced)** (`MultiThresholdObjects2`, SIMPL UUID `686d5393-2b02-5c86-b887-dd81a8ae80f2`) — nested AND/OR comparison sets + +**0 deviation entries exist as of this DRAFT.** + +Per `vv_policy.md`'s one ordering rule ("pick the oracle before running any DREAM3D 6.5.171 comparison"), the V&V report's Class 1 (Analytical) oracle already establishes SIMPLNX correctness independently of legacy — see `vv/MultiThresholdObjectsFilter.md`. The legacy A/B remains outstanding and is required before this filter's status can move past DRAFT; it is not a precondition for the oracle work already done. + +**Because the Algorithm Relationship is classified as Rewrite** (this filter consolidates two independently-shipped legacy filters into one — see the V&V report), the burden here is higher than a straight port: the eventual comparison must independently confirm output equivalence against **both** legacy filters, run separately — + +1. Configurations expressible in the legacy flat (AND-only) model, compared against **Threshold Objects**. +2. Configurations using nested AND/OR sets or per-set invert, compared against **Threshold Objects (Advanced)**. + +A clean result on only one of the two legacy filters is not sufficient to close this Rewrite's defense — both source filters must be reconciled before the merged UUID's functional-equivalence claim can be considered verified. + +## Filter UUID + +`4246245e-1011-4add-8436-0af6bed19228` + +## Other configurations to prioritize once a legacy build is available + +Beyond running the two legacy comparisons described above, these individual parameter additions are the other likely sources of drift — not observed deviations, just a prioritized test plan for the eventual A/B: + +1. **Multi-component index selection** (`#1184` addition) — not present in legacy `Threshold Objects`; unconfirmed whether `Threshold Objects (Advanced)` had an equivalent. Comparison is only meaningful against whichever legacy filter (if either) has this feature. +2. **Custom TRUE/FALSE mask output values** (`#669` addition) — compare with it left at legacy defaults first, then with custom values set. +3. **Default mask output `DataType`** — SIMPLNX defaults to `uint8` (`#1502`); confirm what each legacy filter's default was and whether any migration guidance is needed for pipelines that relied on the default rather than explicitly setting it. + +## Entries + +No entries yet. When a comparison surfaces an actual behavioral difference, add it here following the stable-ID convention (`MultiThresholdObjectsFilter-D1`, `-D2`, …) with fields: Deviation ID, Filter UUID, Status, Symptom, Root cause (`bug` | `precision` | `order of operations` | `library` | `algorithmic choice`), Affected users, Recommendation. From b3d2844bc196385382f8567835deffdd7b084fad Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Fri, 7 Aug 2026 09:09:30 -0400 Subject: [PATCH 15/28] V&V Fixes * Optimized Mask calculations by only applying / checking trueValue and falseValue once the entire mask value has been calculated. All internal checks use an AbstractDataStore instead of AbstractDataStore. * Re-added TEMPLATE_TEST_CASE Valid Execution - Custom Values and SIMPL Backwards Compatibility unit tests * Reduced memory usage for thresholdValue --- .../Algorithms/MultiThresholdObjects.cpp | 145 +++++++++++------- .../Filters/MultiThresholdObjectsFilter.hpp | 1 - .../test/MultiThresholdObjectsTest.cpp | 102 +++++++++++- 3 files changed, 189 insertions(+), 59 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index c606f58230..283cfb87d3 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -13,15 +13,14 @@ using namespace nx::core; namespace { /** - * @brief InsertThreshold - * @param numItems - * @param currentArrayPtr + * @brief InsertThreshold is used by ThresholdSets to apply their values to the parent collection using the appropriate union operator and + * inversion of true/false values. + * @param currentVector * @param unionOperator - * @param newArrayPtr + * @param newVector * @param inverse */ -template -void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, AbstractDataStore& newVector, bool inverse, T trueValue, T falseValue) +void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, AbstractDataStore& newVector, bool inverse) { usize numItems = currentVector.getNumberOfTuples(); @@ -30,16 +29,16 @@ void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThresh // invert the current comparison if necessary if(inverse) { - newVector[i] = (newVector[i] == trueValue) ? falseValue : trueValue; + newVector[i] = !newVector[i]; } if(nx::core::IArrayThreshold::UnionOperator::Or == unionOperator) { - currentVector[i] = (currentVector[i] == trueValue || newVector[i] == trueValue) ? trueValue : falseValue; + currentVector[i] = (currentVector[i] || newVector[i]); } - else if(currentVector[i] == falseValue || newVector[i] == falseValue) + else if(!currentVector[i] || !newVector[i]) { - currentVector[i] = falseValue; + currentVector[i] = false; } } } @@ -49,12 +48,9 @@ void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThresh * @param arrayThreshold Current threshold to pull settings from. * @param outputResultStore Output DataStore for the current ThresholdSet. * @param inputThresholdStore Resulting output for the target array threshold. - * @param replaceInput The first threshould in every set has its output applied to the output regardless of union operator. - * @param trueValue Output mask value when the threshold is satisfied. - * @param falseValue Output mask value when the threshold is not satisfied. + * @param replaceInput The first threshold in every set has its output applied to the output regardless of union operator. */ -template -void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, AbstractDataStore& outputResultStore, AbstractDataStore& inputThresholdStore, bool replaceInput, T trueValue, T falseValue) +void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, AbstractDataStore& outputResultStore, AbstractDataStore& inputThresholdStore, bool replaceInput) { auto unionOperator = arrayThreshold.getUnionOperator(); bool inverse = arrayThreshold.isInverted(); @@ -65,57 +61,75 @@ void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, AbstractDataSto } // insert into current threshold - InsertThreshold(outputResultStore, unionOperator, inputThresholdStore, inverse, trueValue, falseValue); + InsertThreshold(outputResultStore, unionOperator, inputThresholdStore, inverse); } -template class ThresholdFilterHelper { public: - ThresholdFilterHelper(ArrayThreshold::ComparisonType compType, ArrayThreshold::ComparisonValue compValue, usize componentIndex, AbstractDataStore& output) + ThresholdFilterHelper(ArrayThreshold::ComparisonType compType, ArrayThreshold::ComparisonValue compValue, usize componentIndex, IArrayThreshold::UnionOperator unionType, + AbstractDataStore& output, bool invert) : m_ComparisonOperator(compType) , m_ComparisonValue(compValue) , m_ComponentIndex(componentIndex) + , m_UnionType(unionType) , m_Output(output) + , m_Invert(invert) { } template - void filterDataWithComparision(const AbstractDataStore& m_Input, T trueValue, T falseValue) + void filterDataWithComparision(const AbstractDataStore& inputStore) { - size_t numTuples = m_Input.getNumberOfTuples(); + size_t numTuples = inputStore.getNumberOfTuples(); T value = static_cast(m_ComparisonValue); for(size_t tupleIndex = 0; tupleIndex < numTuples; ++tupleIndex) { - T inputValue = m_Input.getComponentValue(tupleIndex, m_ComponentIndex); + T inputValue = inputStore.getComponentValue(tupleIndex, m_ComponentIndex); + bool currentOutputValue = m_Output.getValue(tupleIndex); // This should only be a single component bool comparison = CompT{}(inputValue, value); - T outputValue = comparison ? trueValue : falseValue; - m_Output[tupleIndex] = outputValue; + if(m_Invert) + { + comparison = !comparison; + } + + switch(m_UnionType) + { + case IArrayThreshold::UnionOperator::And: + m_Output.setValue(tupleIndex, currentOutputValue && comparison); + break; + case IArrayThreshold::UnionOperator::Or: + m_Output.setValue(tupleIndex, currentOutputValue || comparison); + break; + default: + throw std::runtime_error(fmt::format("Invalid threshold union operator: {}", static_cast(m_UnionType))); + break; + } } } template - void filterData(const AbstractDataStore& input, T trueValue, T falseValue) + void filterData(const AbstractDataStore& input) { if(m_ComparisonOperator == ArrayThreshold::ComparisonType::LessThan) { - filterDataWithComparision, T>(input, trueValue, falseValue); + filterDataWithComparision, T>(input); } else if(m_ComparisonOperator == ArrayThreshold::ComparisonType::GreaterThan) { - filterDataWithComparision, T>(input, trueValue, falseValue); + filterDataWithComparision, T>(input); } else if(m_ComparisonOperator == ArrayThreshold::ComparisonType::Operator_Equal) { - filterDataWithComparision, T>(input, trueValue, falseValue); + filterDataWithComparision, T>(input); } else if(m_ComparisonOperator == ArrayThreshold::ComparisonType::Operator_NotEqual) { - filterDataWithComparision, T>(input, trueValue, falseValue); + filterDataWithComparision, T>(input); } else { - std::string errorMessage = fmt::format("MultiThresholdObjects Comparison Operator not understood: '{}'", static_cast(m_ComparisonOperator)); + std::string errorMessage = fmt::format("MultiThresholdObjects Comparison Operator not understood: '{}'", static_cast(m_ComparisonOperator)); throw std::runtime_error(errorMessage); } } @@ -124,93 +138,106 @@ class ThresholdFilterHelper ArrayThreshold::ComparisonType m_ComparisonOperator; ArrayThreshold::ComparisonValue m_ComparisonValue; usize m_ComponentIndex = 0; - AbstractDataStore& m_Output; + IArrayThreshold::UnionOperator m_UnionType; + AbstractDataStore& m_Output; + bool m_Invert; }; struct ExecuteThresholdHelper { - template - void operator()(ThresholdFilterHelper& helper, const IDataArray& iDataArray, Type trueValue, Type falseValue) + template + void operator()(ThresholdFilterHelper& helper, const IDataArray& iDataArray) { const auto& dataStore = iDataArray.template getIDataStoreRefAs>(); - helper.template filterData(dataStore, trueValue, falseValue); + helper.template filterData(dataStore); } }; -template -void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, int32_t& err, bool replaceInput, T trueValue, T falseValue) +void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, int32_t& err, bool replaceInput) { // Get the total number of tuples, create and initialize an array with FALSE to use for these results size_t totalTuples = outputResultVector.getNumberOfTuples(); - auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); - AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); - std::fill(tempResultStore.begin(), tempResultStore.end(), falseValue); nx::core::ArrayThreshold::ComparisonType compOperator = comparisonValue.getComparisonType(); nx::core::ArrayThreshold::ComparisonValue compValue = comparisonValue.getComparisonValue(); nx::core::IArrayThreshold::UnionOperator unionOperator = comparisonValue.getUnionOperator(); + // Use the Or union operator for the first ThresholdValue in a set. + if(replaceInput) + { + unionOperator = IArrayThreshold::UnionOperator::Or; + } + DataPath inputDataArrayPath = comparisonValue.getArrayPath(); usize componentIndex = comparisonValue.getComponentIndex(); - ThresholdFilterHelper helper(compOperator, compValue, componentIndex, tempResultStore); + ThresholdFilterHelper helper(compOperator, compValue, componentIndex, unionOperator, outputResultVector, comparisonValue.isInverted()); const auto& iDataArray = dataStructure.getDataRefAs(inputDataArrayPath); - ExecuteDataFunction(ExecuteThresholdHelper{}, iDataArray.getDataType(), helper, iDataArray, trueValue, falseValue); - - ApplyThresholdValues(comparisonValue, outputResultVector, tempResultStore, replaceInput, trueValue, falseValue); + ExecuteDataFunction(ExecuteThresholdHelper{}, iDataArray.getDataType(), helper, iDataArray); } template -void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, int32_t& err, bool replaceInput, T trueValue, T falseValue) +void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, int32_t& err, bool replaceInput, + const std::atomic_bool& shouldCancel) { // Get the total number of tuples, create and initialize an array with FALSE to use for these results size_t totalTuples = outputResultVector.getNumberOfTuples(); - auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); - AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); - std::fill(tempResultStore.begin(), tempResultStore.end(), falseValue); + auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); + AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); + tempResultStore.fill(false); bool firstValueFound = false; ArrayThresholdSet::CollectionType thresholds = inputComparisonSet.getArrayThresholds(); for(const std::shared_ptr& threshold : thresholds) { + if(shouldCancel) + { + return; + } + const IArrayThreshold* thresholdPtr = threshold.get(); if(const auto* comparisonSet = dynamic_cast(thresholdPtr); comparisonSet != nullptr) { - ThresholdSet(*comparisonSet, dataStructure, tempResultStore, err, !firstValueFound, trueValue, falseValue); + ThresholdSet(*comparisonSet, dataStructure, tempResultStore, err, !firstValueFound, shouldCancel); firstValueFound = true; } else if(const auto* comparisonValue = dynamic_cast(thresholdPtr); comparisonValue != nullptr) { - ThresholdValue(*comparisonValue, dataStructure, tempResultStore, err, !firstValueFound, trueValue, falseValue); + ThresholdValue(*comparisonValue, dataStructure, tempResultStore, err, !firstValueFound); firstValueFound = true; } } // Apply resulting values to output - ApplyThresholdValues(inputComparisonSet, outputResultVector, tempResultStore, replaceInput, trueValue, falseValue); + ApplyThresholdValues(inputComparisonSet, outputResultVector, tempResultStore, replaceInput); } struct ThresholdSetFunctor { template - void operator()(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, IDataArray& outputResultArray, int32_t& err, bool replaceInput, T trueValue, T falseValue) + void operator()(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, IDataArray& outputResultArray, int32_t& err, bool replaceInput, T trueValue, T falseValue, + const std::atomic_bool& shouldCancel) { + if(shouldCancel) + { + return; + } + // Traditionally we would do a check to ensure we get a valid pointer, I'm forgoing that check because it // was essentially done in the preflight part. auto& outputDataStore = outputResultArray.template getIDataStoreRefAs>(); usize totalTuples = outputDataStore.getNumberOfTuples(); - auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); - AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); - std::fill(tempResultStore.begin(), tempResultStore.end(), falseValue); - ThresholdSet(inputComparisonSet, dataStructure, tempResultStore, err, replaceInput, trueValue, falseValue); + auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); + AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); + ThresholdSet(inputComparisonSet, dataStructure, tempResultStore, err, replaceInput, shouldCancel); for(size_t i = 0; i < totalTuples; i++) { - outputDataStore[i] = tempResultStore[i]; + outputDataStore[i] = tempResultStore[i] ? trueValue : falseValue; } } }; @@ -248,7 +275,13 @@ Result<> MultiThresholdObjects::operator()() int32_t err = 0; ArrayThresholdSet::CollectionType thresholdSet = thresholdsObject.getArrayThresholds(); - ExecuteDataFunction(ThresholdSetFunctor{}, maskArrayType, thresholdsObject, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), err, !firstValueFound, trueValue, falseValue); + if(m_ShouldCancel) + { + return {}; + } + + ExecuteDataFunction(ThresholdSetFunctor{}, maskArrayType, thresholdsObject, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), err, !firstValueFound, trueValue, falseValue, + m_ShouldCancel); return {}; } diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.hpp index ed99745f3d..9ae1d6b50a 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.hpp @@ -37,7 +37,6 @@ class SIMPLNXCORE_EXPORT MultiThresholdObjectsFilter : public IFilter enum ErrorCodes : int64 { - UnequalComponents = -4001, UnequalTuples = -4002, CustomTrueWithBoolean = -4003, CustomFalseWithBoolean = -4004, diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index bc54c0481f..981f8aded3 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -387,6 +387,8 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int", "[ CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, !isInverted); CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); } TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Float", "[SimplnxCore][MultiThresholdObjectsFilter]") @@ -422,6 +424,8 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Float", CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, !isInverted); CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); } TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int Multi-Component", "[SimplnxCore][MultiThresholdObjectsFilter]") @@ -458,6 +462,8 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int Mult CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, !isInverted, componentIndex); CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted, componentIndex); } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); } /** @@ -604,8 +610,6 @@ void CheckThresholdSet2(DataStructure& dataStructure, bool inverted) for(usize i = 0; i < k_TupleCount; i++) { - bool value = thresholdStore[i]; - bool expected = ExpectedThresholdSet2Mask(i, inverted); REQUIRE(thresholdStore[i] == ExpectedThresholdSet2Mask(i, inverted)); } } @@ -722,6 +726,8 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Threshold Sets", "[SimplnxC RunThresholdSetTest(dataStructure, thresholdSet); CheckThresholdSet5(dataStructure, isInverted); } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); } // Invalid executions @@ -1131,6 +1137,98 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution, Input Array Data SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) auto* maskArray = dataStructure.getDataAs(matrixPath.createChildPath(k_ThresholdArrayName)); + REQUIRE(maskArray != nullptr); auto& maskStore = maskArray->getDataStoreRef(); TestMaskOutputForInputType(maskStore, comparisonValue); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::MultiThresholdObjectsFilter: SIMPL Backwards Compatibility", "[SimplnxCore][MultiThresholdObjectsFilter][BackwardsCompatibility]") +{ + auto app = Application::GetOrCreateInstance(); + UnitTest::LoadPlugins(); + auto filterList = app->getFilterList(); + + const fs::path conversionDir = fs::path(nx::core::unit_test::k_SourceDir.view()) / "test" / "simpl_conversion"; + + const std::vector> fixtures = { + {"SIMPL 6.5 (UUID)", conversionDir / "6_5" / "MultiThresholdObjectsFilter.json"}, + {"SIMPL 6.4 (Filter_Name)", conversionDir / "6_4" / "MultiThresholdObjectsFilter.json"}, + }; + + for(const auto& [label, fixturePath] : fixtures) + { + DYNAMIC_SECTION(label) + { + auto pipelineResult = Pipeline::FromSIMPLFile(fixturePath, filterList); + REQUIRE(pipelineResult.valid()); + + auto& pipeline = pipelineResult.value(); + REQUIRE(pipeline.size() == 1); + + auto* pipelineFilter = dynamic_cast(pipeline.at(0)); + REQUIRE(pipelineFilter != nullptr); + + const IFilter* filter = pipelineFilter->getFilter(); + REQUIRE(filter != nullptr); + REQUIRE(filter->uuid() == FilterTraits::uuid); + + const Arguments args = pipelineFilter->getArguments(); + CHECK(args.value(MultiThresholdObjectsFilter::k_CreatedDataName_Key) == "TestName"); + } + } +} + +TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution - Custom Values", "[SimplnxCore][MultiThresholdObjectsFilter]", int8, uint8, int16, uint16, int32, uint32, int64, uint64, + float32, float64) +{ + UnitTest::LoadPlugins(); + + MultiThresholdObjectsFilter filter; + DataStructure dataStructure = CreateTestDataStructure(); + Arguments args; + + float64 trueValue = 25; + float64 falseValue = 10; + + ArrayThresholdSet thresholdSet; + auto threshold = std::make_shared(); + threshold->setArrayPath(k_TestArrayIntPath); + threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); + threshold->setComparisonValue(3); + thresholdSet.setArrayThresholds({threshold}); + + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); + args.insertOrAssign(MultiThresholdObjectsFilter::k_UseCustomTrueValue, std::make_any(true)); + args.insertOrAssign(MultiThresholdObjectsFilter::k_CustomTrueValue, std::make_any(trueValue)); + args.insertOrAssign(MultiThresholdObjectsFilter::k_UseCustomFalseValue, std::make_any(true)); + args.insertOrAssign(MultiThresholdObjectsFilter::k_CustomFalseValue, std::make_any(falseValue)); + args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedMaskType_Key, std::make_any(GetDataType())); + + // Preflight the filter and check result + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + // Execute the filter and check the result + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + auto* thresholdArray = dataStructure.getDataAs>(k_ThresholdArrayPath); + REQUIRE(thresholdArray != nullptr); + auto& thresholdStore = thresholdArray->getDataStoreRef(); + + // Use tuple count constant in case the underlying data size changes. + for(usize i = 0; i < k_TupleCount; i++) + { + if(i <= 3) + { + REQUIRE(thresholdStore[i] == falseValue); + } + else + { + REQUIRE(thresholdStore[i] == trueValue); + } + } } From fb60f2488ef03b049e7e2c1ac2f530d310193a08 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Fri, 7 Aug 2026 09:39:50 -0400 Subject: [PATCH 16/28] Update V&V docs --- .../vv/MultiThresholdObjectsFilter.md | 41 ++++--- .../deviations/MultiThresholdObjectsFilter.md | 107 ++++++++++++++---- 2 files changed, 115 insertions(+), 33 deletions(-) diff --git a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md index 0b9577ecd8..e323d6f7f0 100644 --- a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md @@ -6,7 +6,7 @@ | SIMPLNX UUID | `4246245e-1011-4add-8436-0af6bed19228` | | DREAM3D 6.5.171 equivalent | Two separate legacy filters, consolidated: **Threshold Objects** (`MultiThresholdObjects`, SIMPL UUID `014b7300-cf36-5ede-a751-5faf9b119dae`) and **Threshold Objects (Advanced)** (`MultiThresholdObjects2`, SIMPL UUID `686d5393-2b02-5c86-b887-dd81a8ae80f2`) — both mapped to this single filter's UUID in `SimplnxCoreLegacyUUIDMapping.hpp` (see Algorithm Relationship) | | Verified commit | ** | -| Status | READY FOR REVIEW | +| Status | PENDING - DRAFT | | Sign-off | *pending — DRAFT, not yet reviewed* | ## At a glance @@ -15,18 +15,18 @@ |------------------------|------------------------------------------------------------------------------------------------------------------------------| | Algorithm Relationship | **Rewrite.** Consolidates two independently-shipped legacy filters — **Threshold Objects** (flat, AND-only) and **Threshold Objects (Advanced)** (nested AND/OR sets) — into one SIMPLNX filter under one new UUID, unified around a single `ArrayThresholdSet` model. Not a line-by-line translation of either legacy source. | | Oracle (confirmed) | **Class 1 (Analytical) — confirmed.** `expected[i] = COMPARISON(input[i], value)`, hand-combined via AND/OR/invert boolean algebra. Encoded as 9 `TEST_CASE` groups (17 ctest entries) in `MultiThresholdObjectsTest.cpp`, all pass. | -| Code paths enumerated | **23 of 24 exercised.** Only the unreachable comparison-operator `else`-throw is untested (all four `ComparisonType` enumerators are covered elsewhere). | +| Code paths enumerated | **23 of 25 exercised.** Row 13 (unreachable comparison-operator `else`-throw) is a permanent, acceptable gap. Row 25 (a set mixing a leaf threshold with a nested set — the `MultiThresholdObjectsFilter-D1` trigger shape) has no in-repo regression test yet. | | Tests today | **9 `TEST_CASE` groups / 17 ctest entries.** Exhaustive sweeps over comparison operator × invert × union operator × set nesting × mask `DataType` (11 types) × source-array `DataType` (11 types), plus 4 negative/error-path groups. All fixtures built in-memory. | | Exemplar archive | **None.** All fixtures are constructed in-memory by `CreateTestDataStructure()` / `CreateTestDataStructure2()`; no `.dream3d` exemplar or `download_test_data()` entry exists for this filter. | -| Legacy comparison | **Not run** — no local build of legacy DREAM3D 6.5.171 (or its Advanced comparison-set variant) is available in this environment. Deferred; see `vv/deviations/MultiThresholdObjectsFilter.md`. | -| Bug flags | None identified. Provisional — no legacy comparison has been run yet. | -| V&V phase | Oracle chosen and applied (Class 1), code paths enumerated (23/24), test inventory documented. **Outstanding:** legacy 6.5.171 runtime A/B comparison, second-engineer oracle review, status promotion DRAFT → READY FOR REVIEW. | +| Legacy comparison | **Run.** Independent three-way A/B (DREAM3D 6.5.171 `PipelineRunner` vs. this branch's `nxrunner` vs. an independent numpy oracle) on a shared 100-tuple fixture, covering flat/basic (`MultiThresholdObjects`), nested, and inverted-nested (`MultiThresholdObjects2`) configurations, plus a 50M-tuple scale re-run of all three. Post-fix: all three MATCH across all cases at both scales. Pre-fix (`develop`): 2 of 3 configs diverged (38/100 and 51/100 tuples wrong) — see `MultiThresholdObjectsFilter-D1`/`-D2`. | +| Bug flags | **Two, both fixed, both now quantified against real legacy output.** `MultiThresholdObjectsFilter-D1` — a set combining a leaf threshold with a sibling nested set produced an all-false mask (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`). `MultiThresholdObjectsFilter-D2` — an inverted nested set used `std::reverse` to flip tuple *order* instead of each tuple's value (51/100 tuples wrong vs. the same legacy filter). Both fixed by commit `25f1986f1` ("Fixed MultiThresholdObjects ThresholdSets algorithm", 2026-04-23), predating this V&V pass. See `vv/deviations/MultiThresholdObjectsFilter.md`. | +| V&V phase | Oracle chosen and applied (Class 1, corroborated by an independent numpy oracle in the legacy A/B), code paths enumerated (23/25 — row 25 exposes the D1 trigger shape), legacy A/B run and MATCH at both 100-tuple and 50M-tuple scale, 3 deviations documented (`D1`/`D2` fixed bugs, `D3` confirmed non-bug capability difference). **Outstanding:** a regression test for the D1 trigger shape (no existing fixture uses it — see Code path coverage row 25), second-engineer oracle review, custom TRUE/FALSE-value and default-mask-type comparison against legacy (not covered by AB1–AB3). | For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrientationCheckFilter.md` and `src/Plugins/OrientationAnalysis/vv/ComputeAvgCAxesFilter.md` (on `topic/vv/compute_avg_caxis`). ## Summary -`MultiThresholdObjectsFilter` builds a typed mask array by elementwise-comparing one or more input arrays against user-supplied thresholds, combined through an arbitrarily-nested tree of AND/OR/invert `ArrayThresholdSet`s. Verification uses a **Class 1 (Analytical) oracle**: every comparison operator, invert flag, union operator, nesting depth, and both the mask-output and source-input `DataType` are exhaustively hand-derived and asserted in `MultiThresholdObjectsTest.cpp` (9 `TEST_CASE` groups, all passing). 23 of 24 algorithm/preflight code paths are exercised; the filter has not yet been diffed against legacy DREAM3D 6.5.171. +`MultiThresholdObjectsFilter` builds a typed mask array by elementwise-comparing one or more input arrays against user-supplied thresholds, combined through an arbitrarily-nested tree of AND/OR/invert `ArrayThresholdSet`s. Verification uses a **Class 1 (Analytical) oracle**: every comparison operator, invert flag, union operator, nesting depth, and both the mask-output and source-input `DataType` are exhaustively hand-derived and asserted in `MultiThresholdObjectsTest.cpp` (9 `TEST_CASE` groups, all passing). 23 of 25 algorithm/preflight code paths are exercised. An independent three-way runtime A/B (legacy DREAM3D 6.5.171, this branch, and a numpy oracle) against both legacy predecessors — at 100 tuples and again at 50M tuples — confirms the current implementation matches legacy exactly, and quantifies two real bugs that were present on `develop` and are already fixed by commit `25f1986f1`: `MultiThresholdObjectsFilter-D1` (all-false mask when a set mixes a leaf threshold with a nested set, 38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (`std::reverse`-based tuple-order corruption in an inverted nested set instead of per-value inversion, 51/100 tuples wrong). A third, non-bug deviation (`MultiThresholdObjectsFilter-D3`) documents that multi-component index selection is SIMPLNX-only — legacy `Threshold Objects (Advanced)` rejects non-scalar arrays outright. Neither D1 nor D2 has a regression test in the repo yet. ## Algorithm Relationship @@ -39,17 +39,21 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie 686d5393-2b02-5c86-b887-dd81a8ae80f2 → MultiThresholdObjectsFilter // MultiThresholdObjects2 ("Threshold Objects (Advanced)") ``` -`FromSIMPLJson()` correspondingly branches on which legacy UUID (or, for 6.4 pipelines lacking a UUID, which legacy class name) produced the incoming JSON: the basic `MultiThresholdObjects` source is read through `ComparisonSelectionFilterParameterConverter` (flat, AND-only comparison list) and the advanced `MultiThresholdObjects2` source is read through `ComparisonSelectionAdvancedFilterParameterConverter` (nested AND/OR comparison sets). Both are converted into the same `ArrayThresholdSet` argument. This is **not** a line-by-line port of a single legacy algorithm — it's a consolidation of two independently-shipped legacy filters into one, which is why the classification is **Rewrite** rather than Port, per `vv_policy.md`: *"keeping [a UUID relationship] is a claim of functional equivalence... The Deviations file must defend the claim."* Here the claim is stronger than usual — that the merged filter reproduces each of the two legacy filters' behavior when configured equivalently to it. SIMPL 6.4/6.5 conversion fixtures exist at `test/simpl_conversion/6_4/MultiThresholdObjectsFilter.json` and `test/simpl_conversion/6_5/MultiThresholdObjectsFilter.json`, but these only assert the *argument conversion* round-trips correctly, not that execution output matches either legacy filter — that's the outstanding legacy A/B (see Deviations file). +`FromSIMPLJson()` correspondingly branches on which legacy UUID (or, for 6.4 pipelines lacking a UUID, which legacy class name) produced the incoming JSON: the basic `MultiThresholdObjects` source is read through `ComparisonSelectionFilterParameterConverter` (flat, AND-only comparison list) and the advanced `MultiThresholdObjects2` source is read through `ComparisonSelectionAdvancedFilterParameterConverter` (nested AND/OR comparison sets). Both are converted into the same `ArrayThresholdSet` argument. This is **not** a line-by-line port of a single legacy algorithm — it's a consolidation of two independently-shipped legacy filters into one, which is why the classification is **Rewrite** rather than Port, per `vv_policy.md`: *"keeping [a UUID relationship] is a claim of functional equivalence... The Deviations file must defend the claim."* Here the claim is stronger than usual — that the merged filter reproduces each of the two legacy filters' behavior when configured equivalently to it. SIMPL 6.4/6.5 conversion fixtures exist at `test/simpl_conversion/6_4/MultiThresholdObjectsFilter.json` and `test/simpl_conversion/6_5/MultiThresholdObjectsFilter.json`, asserting the *argument conversion* round-trips correctly. Execution-output equivalence against both legacy filters has now been runtime-A/B-verified on representative flat, nested, and inverted-nested configurations — see Deviations file for the comparison record. -*Structural differences from each legacy source (none independently confirmed by a legacy A/B yet):* +*Structural differences from each legacy source:* -1. **Consolidation itself** — one `ArrayThresholdSet` tree replaces two separate legacy parameter models (flat list vs. nested set); the flat legacy model is representable as a one-level `ArrayThresholdSet`, but this equivalence has not been verified end-to-end against either legacy filter's actual output. -2. Multi-component index selection added (`#1184`, `32837a30f`) — not present in the legacy `Threshold Objects` filter (scalar-only comparison); unclear whether `Threshold Objects (Advanced)` had an equivalent. +1. **Consolidation itself** — one `ArrayThresholdSet` tree replaces two separate legacy parameter models (flat list vs. nested set); the flat legacy model is representable as a one-level `ArrayThresholdSet`. Spot-verified equivalent via runtime A/B (`AB1`, flat config vs. legacy `Threshold Objects`) — see Deviations file. +2. Multi-component index selection added (`#1184`, `32837a30f`) — confirmed **NX-only**: legacy `Threshold Objects (Advanced)`'s `dataCheck()` rejects non-scalar arrays outright (error `-11003`); legacy `Threshold Objects` never had per-component comparison either. Documented as `MultiThresholdObjectsFilter-D3` (non-bug capability addition) in the Deviations file. 3. Custom TRUE/FALSE mask output values added (`#669`, `b65210cf3`) — additive parameter; unconfirmed whether either legacy filter had this option or if it's SIMPLNX-only. 4. Default mask output `DataType` changed to `uint8` (`#1502`, `49919b086`) — unconfirmed against either legacy filter's default. 5. `executeImpl()` body moved into `Algorithms/MultiThresholdObjects.{hpp,cpp}` (`#1544`, `8381d1dd5`) — structural only, no behavior change (internal to SIMPLNX, not a legacy-relationship concern). -*Material PRs since baseline (2025-10-01):* none identified beyond the deltas above and this branch's `vv/MultiThresholdObjects` restructuring + test work. +*Material PRs since baseline (2025-10-01):* + +- **#1582** — "ENH: Add missing cancel checks to lots of filters" (`1a42ec6fb`) — cross-cutting PR; added `m_ShouldCancel` checks to many filters including this one. No output-behavior change on a non-cancelled run. +- **#1605** — "BUG: Fix SIMPL JSON conversion segfault and re-enable backwards-compatibility checks" (`996d7af5a`) — fixed a crash in `FromSIMPLJson()` and re-enabled the SIMPL 6.4/6.5 backwards-compatibility test for this filter. Affects pipeline-conversion correctness, not execution output. +- Otherwise none identified beyond the deltas above and this branch's `vv/MultiThresholdObjects` restructuring + test work. ## Oracle @@ -67,11 +71,11 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie 9 `TEST_CASE` groups (17 ctest entries, counting the 9 `TEMPLATE_TEST_CASE` type instantiations separately), all pass at HEAD. -*Second-engineer review:* Skipped — recorded reason: the oracle is elementwise comparison plus boolean set algebra (AND/OR/invert), and the test matrix enumerates it exhaustively (every operator × invert × union operator × nesting × both `DataType` axes) rather than sampling a single hand-derivation, substituting breadth for independent derivation review. **This is not a substitute for a named second-engineer pass** — it is recorded here as an outstanding gate for promotion past DRAFT, not a completed one. +*Second-engineer review:* Skipped — recorded reason: the oracle is elementwise comparison plus boolean set algebra (AND/OR/invert), and the test matrix enumerates it exhaustively (every operator × invert × union operator × nesting × both `DataType` axes) rather than sampling a single hand-derivation, substituting breadth for independent derivation review. This is now additionally corroborated by an independent three-way A/B (legacy DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and an independent numpy oracle) matching exactly on representative flat/nested/inverted-nested configurations at both 100-tuple and 50M-tuple scale — see the Deviations file. **This still is not a substitute for a named second-engineer pass** — it is recorded here as an outstanding gate for promotion past DRAFT, not a completed one. ## Code path coverage -**23 of 24 paths exercised.** The one gap is low-risk (row 13). +**23 of 25 paths exercised.** Row 13 is a permanent, acceptable gap; row 25 is a real gap that let `MultiThresholdObjectsFilter-D1` ship — see `vv/deviations/MultiThresholdObjectsFilter.md`. Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp` (~255 lines), plus 7 preflight-only paths in `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp`. @@ -103,11 +107,14 @@ Two logical stages: **(a) preflight** validates the threshold set / mask-type / | 22 | (b) Algorithm | `ThresholdSetFunctor` dispatch on **mask (output) DataType** | `Valid Execution, Mask DataType` — boolean + int8/16/32/64 + uint8/16/32/64 + float32/64, all 11 types | | 23 | (b) Algorithm | `ExecuteThresholdHelper` dispatch on **source array's DataType** | `Valid Execution, Input Array DataType` — int8/16/32/64 + uint8/16/32/64 + float32/64 + boolean, all 11 types | | 24 | (b) Algorithm | Multi-component `componentIndex != 0` selection | `Valid Single Thresholds: Int Multi-Component` (`componentIndex = GENERATE(0,1,2)`), plus `componentIndex=1` in Set1, `=0` in Set2 | +| 25 | (b) Algorithm | An `ArrayThresholdSet` whose children mix at least one leaf `ArrayThreshold` with at least one nested `ArrayThresholdSet` (e.g. `{leaf, nestedSet}`, not `{leaf, leaf, leaf}` or `{set, set}`). Historically produced an all-false mask regardless of input (`MultiThresholdObjectsFilter-D1`, confirmed against legacy `Threshold Objects (Advanced)` — 38/100 tuples wrong pre-fix), fixed by commit `25f1986f1`. | *Not directly tested by the in-repo `TEST_CASE` suite. No existing fixture uses this exact shape — every `CreateThresholdSet*` helper passes either all leaves or all nested sets to `setArrayThresholds()`, never a mix. Confirmed by the external `MultiThresholdObjectsFilter-AB2` legacy A/B fixture (see Deviations file), which is not part of the ctest suite. This gap is what let D1 ship; a dedicated in-repo regression fixture is recommended before status promotion.* | Not counted as an algorithm/preflight path: the "Empty ArrayThreshold DataPath" section of `Invalid Execution` exercises `ArrayThresholdsParameter`'s own path-existence validation, which runs before `preflightImpl` is called — it's a parameter-layer gate, not code inside this filter or algorithm. Also note: `k_MismatchingComponentsArrayPath` (test file, line 31) is a leftover unused `DataPath` constant — the array it used to name was removed when `Valid Execution, Input Array DataType` was added. Not a coverage gap (the filter has no cross-array component-count check), just dead test-source code worth deleting. +`MultiThresholdObjectsFilter-D2` (the pre-fix `std::reverse` tuple-order bug) does not get its own row: the buggy code path no longer exists (removed by commit `25f1986f1`, which unified all combination logic through `ApplyThresholdValues`/`InsertThreshold`). The legacy A/B's `AB3` fixture (a leaf combined with an inverted nested set — see Deviations file) is the confirmed trigger; it overlaps with row 25's mixed-sibling shape rather than isolating D2 cleanly on its own. `Valid Threshold Sets`' `isInverted = GENERATE(false, true)` sweep exercises top-level-inverted sets today, but that test predates the fix and was never confirmed to have actually caught D2 at the time (no regression-test commit accompanies `25f1986f1`), and it doesn't cover AB3's specific mixed-sibling-plus-inverted-nested-child shape either. + ## Test inventory | Test case | Status | Notes | @@ -122,10 +129,16 @@ Also note: `k_MismatchingComponentsArrayPath` (test file, line 31) is a leftover | `Valid Execution, Mask DataType` | kept | 11 `SECTION`s, one per mask-output `DataType` (int8…float64; boolean covered via the default mask type used throughout the other `TEST_CASE`s). | | `Valid Execution, Input Array DataType` | new-for-V&V (`d18b0f34d`, 2026-07-23) | 11 `SECTION`s, one per **source-array** `DataType` (int8…float64, bool) — closes the code-path gap on row 23 identified during path enumeration. | +**Missing:** no test case exercises a "plain nested set" (a top-level `ArrayThresholdSet` whose only child is a single nested `ArrayThresholdSet`, no siblings) — the shape that triggered `MultiThresholdObjectsFilter-D1`. Recommended before status promotion: add a `SECTION` to `Valid Threshold Sets` (or a new `TEST_CASE`) covering this shape, so a regression can't reintroduce D1 silently. + ## Exemplar archive None. All fixtures for this filter are constructed in-memory in `test/MultiThresholdObjectsTest.cpp` (`CreateTestDataStructure()`, `CreateTestDataStructure2()`); there is no `.dream3d` exemplar and no `download_test_data()` entry in `test/CMakeLists.txt` for `MultiThresholdObjectsFilter`. No provenance sidecar is needed. ## Deviations from DREAM3D 6.5.171 -- Legacy comparison has **not been run**. 0 deviation entries exist. See `vv/deviations/MultiThresholdObjectsFilter.md` for the reason and the configurations to prioritize once a legacy build is available. +Legacy comparison **run**: independent three-way A/B (DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and a numpy oracle) on flat, nested, and inverted-nested configurations at 100 tuples and again at 50M tuples. Post-fix, all three sources MATCH in every case. Full record in `vv/deviations/MultiThresholdObjectsFilter.md`. + +- `MultiThresholdObjectsFilter-D1` — a set mixing a leaf threshold with a sibling nested set produced an all-false mask pre-fix (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`). **Fixed** (`25f1986f1`). +- `MultiThresholdObjectsFilter-D2` — a leaf combined with an inverted nested set used `std::reverse` to flip tuple order instead of flipping each tuple's value pre-fix (51/100 tuples wrong vs. the same legacy filter). **Fixed** (`25f1986f1`). +- `MultiThresholdObjectsFilter-D3` — multi-component index selection is SIMPLNX-only; legacy `Threshold Objects (Advanced)` rejects non-scalar arrays (`dataCheck()` error `-11003`). Not a bug — a deliberate SIMPLNX capability addition, documented for migration guidance. diff --git a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md index 091f1190f8..12f1e33100 100644 --- a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md @@ -4,38 +4,107 @@ This file lists every documented behavioral difference between this SIMPLNX filt Entries are referenced by stable ID (`MultiThresholdObjectsFilter-D`) from the V&V report and from public migration guidance. The ID is stable across renames; the Filter UUID field is the permanent cross-reference anchor. +## Filter UUID + +`4246245e-1011-4add-8436-0af6bed19228` + +## Headline + +**3 deviations documented: 2 bugs (both in SIMPLNX, both fixed pre-branch), 1 confirmed non-bug capability difference.** Legacy comparison has been **run**: an independent three-way A/B — DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and an independent numpy oracle — on a shared 100-tuple fixture, covering representative flat (`Threshold Objects`), nested, and inverted-nested (`Threshold Objects (Advanced)`) configurations, re-run again at 50M tuples. Post-fix, all three sources MATCH in every case at both scales. All 17 in-repo ctest entries also pass locally. + +The same three pipelines run against `develop` (pre-fix) reproduce two real bugs quantitatively: `MultiThresholdObjectsFilter-D1` (38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (51/100 tuples wrong). Both are fixed by commit `25f1986f1` ("Fixed MultiThresholdObjects ThresholdSets algorithm", 2026-04-23), which predates this V&V pass. Neither has a dedicated regression test in the in-repo `TEST_CASE` suite yet (see the V&V report's Code path coverage row 25 and Test inventory "Missing" note) — status should not promote past DRAFT until at least D1's trigger shape has one. + +`MultiThresholdObjectsFilter-D3` documents a confirmed, deliberate capability difference (not a bug): multi-component index selection only exists in SIMPLNX. + --- -## Status: comparison not yet run +## Comparison method -No local build of legacy DREAM3D 6.5.171 is available in this environment. No runtime A/B has been performed against **either** legacy predecessor: +| | | +|---|---| +| **Comparison type** | Runtime three-way A/B: legacy DREAM3D 6.5.171 (`PipelineRunner`) vs. this branch (`nxrunner`) vs. an independent numpy oracle | +| **Shared input** | Legacy-format fixture, 100 tuples: `Int32 = 0..99`, `Float32 = 0.01*(i+1)` | +| **Scale re-run** | Same three configurations (AB1–AB3) re-run at 50M random tuples — this branch matches the numpy oracle exactly at scale | +| **In-repo regression suite** | All 17 ctest entries in `test/MultiThresholdObjectsTest.cpp` pass locally at the verified commit | -- **Threshold Objects** (`MultiThresholdObjects`, SIMPL UUID `014b7300-cf36-5ede-a751-5faf9b119dae`) — flat, AND-only comparison list -- **Threshold Objects (Advanced)** (`MultiThresholdObjects2`, SIMPL UUID `686d5393-2b02-5c86-b887-dd81a8ae80f2`) — nested AND/OR comparison sets +### Per-configuration result (100-tuple fixture, this branch = post-fix) -**0 deviation entries exist as of this DRAFT.** +| Case | Config | Legacy filter | This branch vs. legacy vs. oracle | +|---|---|---|---| +| `AB1` | `Int32 > 42 AND Float32 < 0.70` (flat) | `MultiThresholdObjects` ("Threshold Objects") | **MATCH** (legacy = NX = oracle) | +| `AB2` | `Int32 > 20 AND (Float32 < 0.60 OR Int32 == 55)` (nested set) | `MultiThresholdObjects2` ("Threshold Objects (Advanced)") | **MATCH** | +| `AB3` | `Int32 < 80 OR NOT(Int32 > 30 AND Float32 < 0.95)` (inverted nested set) | `MultiThresholdObjects2` | **MATCH** | -Per `vv_policy.md`'s one ordering rule ("pick the oracle before running any DREAM3D 6.5.171 comparison"), the V&V report's Class 1 (Analytical) oracle already establishes SIMPLNX correctness independently of legacy — see `vv/MultiThresholdObjectsFilter.md`. The legacy A/B remains outstanding and is required before this filter's status can move past DRAFT; it is not a precondition for the oracle work already done. +### Pre-fix (`develop`) result, same three pipelines -**Because the Algorithm Relationship is classified as Rewrite** (this filter consolidates two independently-shipped legacy filters into one — see the V&V report), the burden here is higher than a straight port: the eventual comparison must independently confirm output equivalence against **both** legacy filters, run separately — +| Case | Result on `develop` (pre-`25f1986f1`) | Deviation | +|---|---|---| +| `AB1` | Matches legacy/oracle (flat configs were never affected — see D1/D2 root causes) | none | +| `AB2` | **All-false mask — 38/100 tuples wrong** | `MultiThresholdObjectsFilter-D1` | +| `AB3` | **Differs in 51/100 values** | `MultiThresholdObjectsFilter-D2` | -1. Configurations expressible in the legacy flat (AND-only) model, compared against **Threshold Objects**. -2. Configurations using nested AND/OR sets or per-set invert, compared against **Threshold Objects (Advanced)**. +Both bug-fix claims in the PR are real, and the fix restores legacy semantics: legacy's `invertThreshold()` flips values element-wise; the old SIMPLNX `std::reverse` was a misport of that, and the old per-item functor dispatch broke nested-set combination entirely. (Engineer's account, corroborated by the `25f1986f1` diff — see D1/D2 Root cause below.) -A clean result on only one of the two legacy filters is not sufficient to close this Rewrite's defense — both source filters must be reconciled before the merged UUID's functional-equivalence claim can be considered verified. +--- -## Filter UUID +## MultiThresholdObjectsFilter-D1 -`4246245e-1011-4add-8436-0af6bed19228` +| Field | Value | +|---|---| +| **Deviation ID** | `MultiThresholdObjectsFilter-D1` | +| **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | +| **Status** | retired 2026-04-23 — fixed by commit `25f1986f1`, prior to this V&V pass | + +**Symptom:** An `ArrayThresholdSet` whose children mix at least one leaf `ArrayThreshold` with at least one nested `ArrayThresholdSet` (e.g. `AB2`: `{leaf: Int32 > 20, nestedSet: (Float32 < 0.60 OR Int32 == 55)}`) produced an all-false mask, regardless of input data. Quantified on the `AB2` fixture: **38 of 100 tuples wrong** (all forced false) vs. legacy `Threshold Objects (Advanced)` and the numpy oracle. -## Other configurations to prioritize once a legacy build is available +**Root cause:** Bug (SIMPLNX-side, pre-fix). Per the reporting engineer: the old per-item functor dispatch broke nested-set combination entirely. Corroborating evidence from the `25f1986f1` diff: the pre-fix `MultiThresholdObjects.cpp` threaded a redundant `bool inverse` parameter down through recursive `ThresholdSet`/`ThresholdValue` calls (separate from each node's own `arrayThreshold.isInverted()`), and applied a dual apply strategy — the first item at any nesting level (`replaceInput == true`) took a direct-copy path while later items took an `InsertThreshold`-based AND/OR combine path against an accumulator pre-filled with `falseValue`. Commit `25f1986f1` ("Standardized apply threshold values between thresholds and sets. Removed unnecessary inversion parameter...") replaced both call sites with a single `ApplyThresholdValues` → `InsertThreshold` path. The exact instruction-level trace of why this specifically zeroed the mask for the mixed-leaf/nested-set shape was not independently re-derived line-by-line for this report; the symptom and fix are established by the `AB2` runtime comparison against real legacy output, which is stronger evidence than a re-derived trace would be. + +**Affected users:** Any pipeline (SIMPLNX-native, or converted from legacy `Threshold Objects (Advanced)`) using a threshold set that combines a plain leaf comparison with a sibling nested group — a common shape, not an exotic edge case. Silent: the filter reported success and wrote a fully-false mask with no warning. + +**Recommendation:** Trust SIMPLNX (current/post-fix — confirmed bit-for-bit against legacy on `AB2` at both 100 tuples and 50M tuples). The pre-fix output was unconditionally wrong; anyone on a pre-`25f1986f1` build should upgrade and re-verify any pipeline outputs generated before the fix. + +--- -Beyond running the two legacy comparisons described above, these individual parameter additions are the other likely sources of drift — not observed deviations, just a prioritized test plan for the eventual A/B: +## MultiThresholdObjectsFilter-D2 + +| Field | Value | +|---|---| +| **Deviation ID** | `MultiThresholdObjectsFilter-D2` | +| **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | +| **Status** | retired 2026-04-23 — fixed by commit `25f1986f1`, prior to this V&V pass | + +**Symptom:** A leaf combined with an inverted nested set (`AB3`: `{leaf: Int32 < 80, invertedNestedSet: NOT(Int32 > 30 AND Float32 < 0.95)}`) produced incorrect mask output. Quantified on the `AB3` fixture: **51 of 100 values differ** vs. legacy `Threshold Objects (Advanced)` and the numpy oracle. `AB3`'s shape overlaps with `D1`'s mixed-leaf/nested-set trigger, so this result is not a clean isolation of the inversion defect alone — both mechanisms plausibly contribute to the discrepancy. + +**Root cause:** Bug (SIMPLNX-side, pre-fix). Per the reporting engineer: legacy's `invertThreshold()` flips mask values element-wise; the pre-fix SIMPLNX code instead called `std::reverse(tempResultVector.begin(), tempResultVector.end())` on the intermediate result buffer under certain replace/invert conditions — reversing the *order* of elements rather than flipping each element's own TRUE/FALSE value. This is not a valid implementation of per-element boolean inversion. The correct operation (applied correctly elsewhere in the same file, e.g. inside `InsertThreshold`: `newVector[i] = (newVector[i] == trueValue) ? falseValue : trueValue`) flips each element's own value in place, changing nothing about element order. Fixed by the same `25f1986f1` commit that removed the redundant `inverse` parameter and both `std::reverse` call sites, consolidating all inversion through `ApplyThresholdValues` → `InsertThreshold`'s per-element flip. The precise nesting depth at which the pre-fix `std::reverse` branch was reachable (top-level only, or also for a nested child, as in `AB3`) was not independently re-derived line-by-line for this report; documented here on the `AB3` runtime evidence plus the `25f1986f1` diff. + +**Affected users:** Any pipeline using an inverted `ArrayThresholdSet` — top-level or nested — combined with sibling thresholds/sets. Not a narrow edge case: "Invert Mask" is a standard, documented option. On an image geometry, wrong tuple correspondence scrambles which voxels are masked; there is no legitimate downstream use of the pre-fix output. + +**Recommendation:** Trust SIMPLNX (current/post-fix — confirmed bit-for-bit against legacy on `AB3` at both 100 tuples and 50M tuples). Anyone on a pre-`25f1986f1` build using an inverted threshold set should upgrade and re-verify any pipeline outputs generated before the fix. + +--- + +## MultiThresholdObjectsFilter-D3 + +| Field | Value | +|---|---| +| **Deviation ID** | `MultiThresholdObjectsFilter-D3` | +| **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | +| **Status** | active | + +**Symptom:** SIMPLNX accepts a component index on a multi-component threshold array (`ArrayThreshold::setComponentIndex()`); neither legacy filter has an equivalent parameter. + +**Root cause:** Algorithmic choice (deliberate SIMPLNX capability addition, not a port artifact — appropriate under this filter's Rewrite classification). Confirmed directly from legacy source: `MultiThresholdObjects2::dataCheck()` rejects non-scalar (multi-component) arrays outright with error `-11003`. Legacy `Threshold Objects` (the non-Advanced filter) has no per-component comparison concept either. SIMPLNX's `#1184` (`32837a30f`) added multi-component index selection with no legacy equivalent in either predecessor. + +**Affected users:** Nobody migrating *from* legacy is affected (the capability didn't exist to lose). Anyone relying on this SIMPLNX-only feature should be aware there is no DREAM3D 6.5.171 equivalent pipeline to fall back to if downgrading. + +**Recommendation:** Trust SIMPLNX. This is an intentional superset capability, not a correctness issue. Worth a one-line callout in public migration guidance: legacy `Threshold Objects (Advanced)` never supported per-component thresholding on multi-component arrays; SIMPLNX does. + +--- -1. **Multi-component index selection** (`#1184` addition) — not present in legacy `Threshold Objects`; unconfirmed whether `Threshold Objects (Advanced)` had an equivalent. Comparison is only meaningful against whichever legacy filter (if either) has this feature. -2. **Custom TRUE/FALSE mask output values** (`#669` addition) — compare with it left at legacy defaults first, then with custom values set. -3. **Default mask output `DataType`** — SIMPLNX defaults to `uint8` (`#1502`); confirm what each legacy filter's default was and whether any migration guidance is needed for pipelines that relied on the default rather than explicitly setting it. +## Outstanding comparison work -## Entries +Both legacy filters have now been run separately on representative configurations (`AB1` vs. `Threshold Objects`; `AB2`/`AB3` vs. `Threshold Objects (Advanced)`), satisfying this filter's Rewrite-classification requirement that functional equivalence be independently confirmed against both predecessors, not just one. Remaining lower-priority gaps: -No entries yet. When a comparison surfaces an actual behavioral difference, add it here following the stable-ID convention (`MultiThresholdObjectsFilter-D1`, `-D2`, …) with fields: Deviation ID, Filter UUID, Status, Symptom, Root cause (`bug` | `precision` | `order of operations` | `library` | `algorithmic choice`), Affected users, Recommendation. +1. **Custom TRUE/FALSE mask output values** (`#669` addition) — not exercised by `AB1`–`AB3`. Compare with it left at legacy defaults first, then with custom values set. +2. **Default mask output `DataType`** — SIMPLNX defaults to `uint8` (`#1502`); confirm what each legacy filter's default was and whether migration guidance is needed for pipelines that relied on the default rather than explicitly setting it. +3. **A broader configuration sweep** beyond the three representative `AB1`–`AB3` shapes (e.g., deeper nesting, mixed AND/OR at multiple levels) is optional given the strong quantitative match already obtained at both 100-tuple and 50M-tuple scale, but would further reduce residual risk before COMPLETE status. From 1714d75cb511a0bac3d31ef3df8f312f44c538fe Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Thu, 13 Aug 2026 10:10:37 -0400 Subject: [PATCH 17/28] Remove unused err parameter --- .../Filters/Algorithms/MultiThresholdObjects.cpp | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index 283cfb87d3..14950cdcbe 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -153,11 +153,8 @@ struct ExecuteThresholdHelper } }; -void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, int32_t& err, bool replaceInput) +void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, bool replaceInput) { - // Get the total number of tuples, create and initialize an array with FALSE to use for these results - size_t totalTuples = outputResultVector.getNumberOfTuples(); - nx::core::ArrayThreshold::ComparisonType compOperator = comparisonValue.getComparisonType(); nx::core::ArrayThreshold::ComparisonValue compValue = comparisonValue.getComparisonValue(); nx::core::IArrayThreshold::UnionOperator unionOperator = comparisonValue.getUnionOperator(); @@ -179,8 +176,7 @@ void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& ExecuteDataFunction(ExecuteThresholdHelper{}, iDataArray.getDataType(), helper, iDataArray); } -template -void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, int32_t& err, bool replaceInput, +void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, bool replaceInput, const std::atomic_bool& shouldCancel) { // Get the total number of tuples, create and initialize an array with FALSE to use for these results @@ -202,12 +198,12 @@ void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructu const IArrayThreshold* thresholdPtr = threshold.get(); if(const auto* comparisonSet = dynamic_cast(thresholdPtr); comparisonSet != nullptr) { - ThresholdSet(*comparisonSet, dataStructure, tempResultStore, err, !firstValueFound, shouldCancel); + ThresholdSet(*comparisonSet, dataStructure, tempResultStore, !firstValueFound, shouldCancel); firstValueFound = true; } else if(const auto* comparisonValue = dynamic_cast(thresholdPtr); comparisonValue != nullptr) { - ThresholdValue(*comparisonValue, dataStructure, tempResultStore, err, !firstValueFound); + ThresholdValue(*comparisonValue, dataStructure, tempResultStore, !firstValueFound); firstValueFound = true; } } @@ -219,7 +215,7 @@ void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructu struct ThresholdSetFunctor { template - void operator()(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, IDataArray& outputResultArray, int32_t& err, bool replaceInput, T trueValue, T falseValue, + void operator()(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, IDataArray& outputResultArray, bool replaceInput, T trueValue, T falseValue, const std::atomic_bool& shouldCancel) { if(shouldCancel) @@ -280,7 +276,7 @@ Result<> MultiThresholdObjects::operator()() return {}; } - ExecuteDataFunction(ThresholdSetFunctor{}, maskArrayType, thresholdsObject, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), err, !firstValueFound, trueValue, falseValue, + ExecuteDataFunction(ThresholdSetFunctor{}, maskArrayType, thresholdsObject, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), !firstValueFound, trueValue, falseValue, m_ShouldCancel); return {}; From 47ad972d5229a1df40101be52223f855fc0b4e07 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Thu, 13 Aug 2026 10:12:39 -0400 Subject: [PATCH 18/28] Update InsertThreshold to use getValue / setValue methods --- .../Filters/Algorithms/MultiThresholdObjects.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index 14950cdcbe..7c165235c1 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -29,16 +29,16 @@ void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThr // invert the current comparison if necessary if(inverse) { - newVector[i] = !newVector[i]; + newVector.setValue(i, !newVector.getValue(i)); } if(nx::core::IArrayThreshold::UnionOperator::Or == unionOperator) { - currentVector[i] = (currentVector[i] || newVector[i]); + currentVector.setValue(i, currentVector.getValue(i) || newVector.getValue(i)); } - else if(!currentVector[i] || !newVector[i]) + else if(!currentVector.getValue(i) || !newVector.getValue(i)) { - currentVector[i] = false; + currentVector.setValue(i, false); } } } From b24f7f5588a0f7eee1928d441f3082a229b0993e Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Fri, 14 Aug 2026 12:44:59 -0400 Subject: [PATCH 19/28] Fix algorithm errors due to casting comparison value * Added operators avoid floating-point inaccuracies. * Improved InsertThreshold readability. * Updated filterDataWithComparison to avoid changing the comparison value based by casting it to type T. --- .../Algorithms/MultiThresholdObjects.cpp | 87 ++++++++++++++++--- 1 file changed, 73 insertions(+), 14 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index 7c165235c1..c2c6057129 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -12,6 +12,58 @@ using namespace nx::core; namespace { +bool CheckEquality(float64 a, float64 b) +{ + // Allow tolerance for casting values to floating point precision. + return std::fabs(a - b) < std::numeric_limits::epsilon(); +} + +/** + * @brief The OperatorLess struct replaces std::less for accuracy between floating-point numbers. + * Approximately equal values are not determined less than or greater than the other. + */ +struct OperatorGreater +{ + bool operator()(float64 value1, float64 value2) + { + return (value1 > value2) && !CheckEquality(value1, value2); + } +}; + +/** + * @brief The OperatorLess struct replaces std::less for accuracy between floating-point numbers. + * Approximately equal values are not determined less than or greater than the other. + */ +struct OperatorLess +{ + bool operator()(float64 value1, float64 value2) + { + return (value1 < value2) && !CheckEquality(value1, value2); + } +}; + +/** + * @brief The OperatorEqual struct replaces std::equal_to for accuracy between floating-point numbers. + */ +struct OperatorEqual +{ + bool operator()(float64 value1, float64 value2) + { + return CheckEquality(value1, value2); + } +}; + +/** + * @brief The Operator_Equality struct replaces std::not_equal_to for accuracy between floating-point numbers. + */ +struct OperatorNotEqual +{ + bool operator()(float64 value1, float64 value2) + { + return !CheckEquality(value1, value2); + } +}; + /** * @brief InsertThreshold is used by ThresholdSets to apply their values to the parent collection using the appropriate union operator and * inversion of true/false values. @@ -26,20 +78,28 @@ void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThr for(usize i = 0; i < numItems; i++) { - // invert the current comparison if necessary + // Store values to avoid repeated access calls + bool currentValue = currentVector.getValue(i); + bool newValue = newVector.getValue(i); + + // Invert the new value if necessary before applying to the parent values. if(inverse) { - newVector.setValue(i, !newVector.getValue(i)); + newValue = !newValue; } + // Determine output value if(nx::core::IArrayThreshold::UnionOperator::Or == unionOperator) { - currentVector.setValue(i, currentVector.getValue(i) || newVector.getValue(i)); + currentValue = currentValue || newValue; } - else if(!currentVector.getValue(i) || !newVector.getValue(i)) + else { - currentVector.setValue(i, false); + currentValue = currentValue && newValue; } + + // Apply updated value + currentVector.setValue(i, currentValue); } } @@ -81,13 +141,12 @@ class ThresholdFilterHelper template void filterDataWithComparision(const AbstractDataStore& inputStore) { - size_t numTuples = inputStore.getNumberOfTuples(); - T value = static_cast(m_ComparisonValue); - for(size_t tupleIndex = 0; tupleIndex < numTuples; ++tupleIndex) + usize numTuples = inputStore.getNumberOfTuples(); + for(usize tupleIndex = 0; tupleIndex < numTuples; ++tupleIndex) { - T inputValue = inputStore.getComponentValue(tupleIndex, m_ComponentIndex); + auto inputValue = static_cast(inputStore.getComponentValue(tupleIndex, m_ComponentIndex)); bool currentOutputValue = m_Output.getValue(tupleIndex); // This should only be a single component - bool comparison = CompT{}(inputValue, value); + bool comparison = CompT{}(inputValue, m_ComparisonValue); if(m_Invert) { comparison = !comparison; @@ -113,19 +172,19 @@ class ThresholdFilterHelper { if(m_ComparisonOperator == ArrayThreshold::ComparisonType::LessThan) { - filterDataWithComparision, T>(input); + filterDataWithComparision(input); } else if(m_ComparisonOperator == ArrayThreshold::ComparisonType::GreaterThan) { - filterDataWithComparision, T>(input); + filterDataWithComparision(input); } else if(m_ComparisonOperator == ArrayThreshold::ComparisonType::Operator_Equal) { - filterDataWithComparision, T>(input); + filterDataWithComparision(input); } else if(m_ComparisonOperator == ArrayThreshold::ComparisonType::Operator_NotEqual) { - filterDataWithComparision, T>(input); + filterDataWithComparision(input); } else { From b9e340877e7bf8bd47f76a3d24b5662d16b16cc4 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Fri, 14 Aug 2026 12:49:56 -0400 Subject: [PATCH 20/28] Update unit tests * Add two exemplar outputs from legacy MultiThresholdObjects2 for an integer and floating point input arrays. * Fix boolean input test. --- .../test/MultiThresholdObjectsTest.cpp | 78 +++++++++++++++++-- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index 981f8aded3..7d03ca291f 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -30,11 +30,14 @@ const DataPath k_ThresholdArrayPath = k_ImageCellDataName.createChildPath(k_Thre const DataPath k_MismatchingTuplesArrayPath({"MismatchingTuplesArray"}); -constexpr int8 k_TupleCount = 5; +constexpr int8 k_TupleCount = 8; constexpr int8 k_MultiComponentCount = 3; constexpr float64 k_FloatValueIncrement = 0.01; +constexpr std::array k_ExemplarInt4{0, 0, 0, 0, 0, 1, 1, 1}; +constexpr std::array k_ExemplarFloat02{0, 1, 0, 0, 0, 0, 0, 0}; + constexpr int32 InputIntValue(int32 index) { return index; @@ -354,6 +357,46 @@ float64 GetOutOfBoundsMaximumValue() } } // namespace +void CheckExemplar(const DataStructure& dataStructure, const std::array& exemplarMask) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + REQUIRE(thresholdStore[i] == exemplarMask[i]); + } +} + +TEST_CASE("SimplnxCore::MultiThresholdObjects: Exemplar Single Thresholds: Int", "[SimplnxCore][MultiThresholdObjectsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = CreateTestDataStructure(); + const DataPath targetArray = k_TestArrayIntPath; + double thresholdValue = 4.0; + bool isInverted = false; + + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::GreaterThan, thresholdValue, isInverted); + CheckExemplar(dataStructure, k_ExemplarInt4); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::MultiThresholdObjects: Exemplar Single Thresholds: Float", "[SimplnxCore][MultiThresholdObjectsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = CreateTestDataStructure(); + const DataPath targetArray = k_TestArrayFloatPath; + double thresholdValue = 0.02; + bool isInverted = false; + + RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); + CheckExemplar(dataStructure, k_ExemplarFloat02); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int", "[SimplnxCore][MultiThresholdObjectsFilter]") { UnitTest::LoadPlugins(); @@ -1041,7 +1084,16 @@ void TestMaskOutputForInputType(Int8AbstractDataStore& mask, float64 comparisonV for(usize i = 0; i < count; i++) { int8 targetValue = (i < comparisonValue) ? 1 : 0; - REQUIRE(mask[i] == targetValue); + REQUIRE(static_cast(mask[i]) == targetValue); + } +} +void TestMaskOutputForBoolInputType(Int8AbstractDataStore& mask, float64 comparisonValue) +{ + usize count = mask.size(); + for(usize i = 0; i < count; i++) + { + int8 targetValue = (static_cast(i) < comparisonValue) ? 1 : 0; + REQUIRE(static_cast(mask[i]) == targetValue); } } @@ -1053,6 +1105,7 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution, Input Array Data float64 comparisonValue = 3.0; DataPath matrixPath({k_ImageGeometry, k_CellData}); + bool isBoolInput = false; // Shared filter setup MultiThresholdObjectsFilter filter; @@ -1121,8 +1174,10 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution, Input Array Data SECTION("Boolean") { threshold->setArrayPath(matrixPath.createChildPath("bool")); - args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); comparisonValue = 0.9; + threshold->setComparisonValue(comparisonValue); + args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); + isBoolInput = true; } args.insertOrAssign(MultiThresholdObjectsFilter::k_CreatedDataName_Key, std::make_any(k_ThresholdArrayName)); @@ -1139,7 +1194,15 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution, Input Array Data auto* maskArray = dataStructure.getDataAs(matrixPath.createChildPath(k_ThresholdArrayName)); REQUIRE(maskArray != nullptr); auto& maskStore = maskArray->getDataStoreRef(); - TestMaskOutputForInputType(maskStore, comparisonValue); + // Bool input + if(isBoolInput) + { + TestMaskOutputForBoolInputType(maskStore, comparisonValue); + } + else + { + TestMaskOutputForInputType(maskStore, comparisonValue); + } UnitTest::CheckArraysInheritTupleDims(dataStructure); } @@ -1191,12 +1254,13 @@ TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution - Custom float64 trueValue = 25; float64 falseValue = 10; + const int32 comparisonValue = 3; ArrayThresholdSet thresholdSet; auto threshold = std::make_shared(); threshold->setArrayPath(k_TestArrayIntPath); threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(3); + threshold->setComparisonValue(comparisonValue); thresholdSet.setArrayThresholds({threshold}); args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); @@ -1215,6 +1279,8 @@ TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution - Custom auto executeResult = filter.execute(dataStructure, args); SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + UnitTest::CheckArraysInheritTupleDims(dataStructure); + auto* thresholdArray = dataStructure.getDataAs>(k_ThresholdArrayPath); REQUIRE(thresholdArray != nullptr); auto& thresholdStore = thresholdArray->getDataStoreRef(); @@ -1222,7 +1288,7 @@ TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution - Custom // Use tuple count constant in case the underlying data size changes. for(usize i = 0; i < k_TupleCount; i++) { - if(i <= 3) + if(i <= comparisonValue) { REQUIRE(thresholdStore[i] == falseValue); } From 01f7f7a5c065c3a35de7d125112a261770b461ce Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Fri, 14 Aug 2026 12:55:11 -0400 Subject: [PATCH 21/28] Misc. cleanup * Remove unused replaceInput argument from ThresholdSetFunctor. * Removed unnecessary AbstractDataStore::fill(false); commands. * Use get/set functions instead of array index for data stores. --- .../Filters/Algorithms/MultiThresholdObjects.cpp | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index c2c6057129..44b23f32a0 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -235,14 +235,12 @@ void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& ExecuteDataFunction(ExecuteThresholdHelper{}, iDataArray.getDataType(), helper, iDataArray); } -void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, bool replaceInput, - const std::atomic_bool& shouldCancel) +void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, bool replaceInput, const std::atomic_bool& shouldCancel) { // Get the total number of tuples, create and initialize an array with FALSE to use for these results size_t totalTuples = outputResultVector.getNumberOfTuples(); auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); - tempResultStore.fill(false); bool firstValueFound = false; @@ -274,8 +272,7 @@ void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructu struct ThresholdSetFunctor { template - void operator()(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, IDataArray& outputResultArray, bool replaceInput, T trueValue, T falseValue, - const std::atomic_bool& shouldCancel) + void operator()(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, IDataArray& outputResultArray, T trueValue, T falseValue, const std::atomic_bool& shouldCancel) { if(shouldCancel) { @@ -288,11 +285,12 @@ struct ThresholdSetFunctor usize totalTuples = outputDataStore.getNumberOfTuples(); auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); - ThresholdSet(inputComparisonSet, dataStructure, tempResultStore, err, replaceInput, shouldCancel); + bool replaceInput = true; + ThresholdSet(inputComparisonSet, dataStructure, tempResultStore, replaceInput, shouldCancel); for(size_t i = 0; i < totalTuples; i++) { - outputDataStore[i] = tempResultStore[i] ? trueValue : falseValue; + outputDataStore.setValue(i, tempResultStore.getValue(i) ? trueValue : falseValue); } } }; @@ -325,7 +323,6 @@ Result<> MultiThresholdObjects::operator()() float64 trueValue = useCustomTrueValue ? customTrueValue : 1.0; float64 falseValue = useCustomFalseValue ? customFalseValue : 0.0; - bool firstValueFound = false; DataPath maskArrayPath = (*thresholdsObject.getRequiredPaths().begin()).replaceName(maskArrayName); int32_t err = 0; ArrayThresholdSet::CollectionType thresholdSet = thresholdsObject.getArrayThresholds(); @@ -335,8 +332,7 @@ Result<> MultiThresholdObjects::operator()() return {}; } - ExecuteDataFunction(ThresholdSetFunctor{}, maskArrayType, thresholdsObject, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), !firstValueFound, trueValue, falseValue, - m_ShouldCancel); + ExecuteDataFunction(ThresholdSetFunctor{}, maskArrayType, thresholdsObject, m_DataStructure, m_DataStructure.getDataRefAs(maskArrayPath), trueValue, falseValue, m_ShouldCancel); return {}; } From 9bc7d1a8f1c1fcb6c348cf3045d8d1d24d11fb21 Mon Sep 17 00:00:00 2001 From: Matthew Marine Date: Tue, 18 Aug 2026 13:17:16 -0400 Subject: [PATCH 22/28] Update V&V docs --- .../vv/MultiThresholdObjectsFilter.md | 67 +++++++++++-------- .../deviations/MultiThresholdObjectsFilter.md | 55 ++++++++++----- 2 files changed, 76 insertions(+), 46 deletions(-) diff --git a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md index e323d6f7f0..27bf6a15ac 100644 --- a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md @@ -14,19 +14,19 @@ | Aspect | Current state | |------------------------|------------------------------------------------------------------------------------------------------------------------------| | Algorithm Relationship | **Rewrite.** Consolidates two independently-shipped legacy filters — **Threshold Objects** (flat, AND-only) and **Threshold Objects (Advanced)** (nested AND/OR sets) — into one SIMPLNX filter under one new UUID, unified around a single `ArrayThresholdSet` model. Not a line-by-line translation of either legacy source. | -| Oracle (confirmed) | **Class 1 (Analytical) — confirmed.** `expected[i] = COMPARISON(input[i], value)`, hand-combined via AND/OR/invert boolean algebra. Encoded as 9 `TEST_CASE` groups (17 ctest entries) in `MultiThresholdObjectsTest.cpp`, all pass. | -| Code paths enumerated | **23 of 25 exercised.** Row 13 (unreachable comparison-operator `else`-throw) is a permanent, acceptable gap. Row 25 (a set mixing a leaf threshold with a nested set — the `MultiThresholdObjectsFilter-D1` trigger shape) has no in-repo regression test yet. | -| Tests today | **9 `TEST_CASE` groups / 17 ctest entries.** Exhaustive sweeps over comparison operator × invert × union operator × set nesting × mask `DataType` (11 types) × source-array `DataType` (11 types), plus 4 negative/error-path groups. All fixtures built in-memory. | +| Oracle (confirmed) | **Class 1 (Analytical) — confirmed.** `expected[i] = COMPARISON(input[i], value)`, hand-combined via AND/OR/invert boolean algebra. Encoded across 13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations in `MultiThresholdObjectsTest.cpp`, all pass. | +| Code paths enumerated | **24 of 26 exercised.** Row 13 (unreachable comparison-operator `else`-throw) is a permanent, acceptable gap. Row 25 (a set mixing a leaf threshold with a nested set — the `MultiThresholdObjectsFilter-D1` trigger shape) has no in-repo regression test yet. | +| Tests today | **13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 30 ctest entries** (11 single-entry TEST_CASEs + 2 `TEMPLATE_TEST_CASE`s instantiated over 9 and 10 types respectively). Exhaustive sweeps over comparison operator × invert × union operator × set nesting × mask `DataType` (10 types, plus boolean covered elsewhere) × source-array `DataType` (11 types) × custom TRUE/FALSE execution (10 types), plus negative/error-path groups and a SIMPL backwards-compatibility check. All fixtures built in-memory. | | Exemplar archive | **None.** All fixtures are constructed in-memory by `CreateTestDataStructure()` / `CreateTestDataStructure2()`; no `.dream3d` exemplar or `download_test_data()` entry exists for this filter. | | Legacy comparison | **Run.** Independent three-way A/B (DREAM3D 6.5.171 `PipelineRunner` vs. this branch's `nxrunner` vs. an independent numpy oracle) on a shared 100-tuple fixture, covering flat/basic (`MultiThresholdObjects`), nested, and inverted-nested (`MultiThresholdObjects2`) configurations, plus a 50M-tuple scale re-run of all three. Post-fix: all three MATCH across all cases at both scales. Pre-fix (`develop`): 2 of 3 configs diverged (38/100 and 51/100 tuples wrong) — see `MultiThresholdObjectsFilter-D1`/`-D2`. | -| Bug flags | **Two, both fixed, both now quantified against real legacy output.** `MultiThresholdObjectsFilter-D1` — a set combining a leaf threshold with a sibling nested set produced an all-false mask (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`). `MultiThresholdObjectsFilter-D2` — an inverted nested set used `std::reverse` to flip tuple *order* instead of each tuple's value (51/100 tuples wrong vs. the same legacy filter). Both fixed by commit `25f1986f1` ("Fixed MultiThresholdObjects ThresholdSets algorithm", 2026-04-23), predating this V&V pass. See `vv/deviations/MultiThresholdObjectsFilter.md`. | -| V&V phase | Oracle chosen and applied (Class 1, corroborated by an independent numpy oracle in the legacy A/B), code paths enumerated (23/25 — row 25 exposes the D1 trigger shape), legacy A/B run and MATCH at both 100-tuple and 50M-tuple scale, 3 deviations documented (`D1`/`D2` fixed bugs, `D3` confirmed non-bug capability difference). **Outstanding:** a regression test for the D1 trigger shape (no existing fixture uses it — see Code path coverage row 25), second-engineer oracle review, custom TRUE/FALSE-value and default-mask-type comparison against legacy (not covered by AB1–AB3). | +| Bug flags | **Three, all fixed by this PR.** `MultiThresholdObjectsFilter-D1` — a set combining a leaf threshold with a sibling nested set produced an all-false mask (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`) on `develop`; quantified against real legacy output. `MultiThresholdObjectsFilter-D2` — an inverted nested set used `std::reverse` to flip tuple *order* instead of each tuple's value (51/100 tuples wrong vs. the same legacy filter) on `develop`; quantified against real legacy output. `MultiThresholdObjectsFilter-D4` — `develop` applied raw `std::less`/`std::greater`/`std::equal_to`/`std::not_equal_to` directly to floating-point operands, unsafe near/at threshold boundaries; not directly exercised by `AB1`–`AB3`, so not independently quantified against legacy. See `vv/deviations/MultiThresholdObjectsFilter.md`. | +| V&V phase | Oracle chosen and applied (Class 1, corroborated by an independent numpy oracle in the legacy A/B), code paths enumerated (24/26 — row 25 exposes the D1 trigger shape), legacy A/B run and MATCH at both 100-tuple and 50M-tuple scale, 4 deviations documented (`D1`/`D2`/`D4` bugs fixed by this PR, `D3` confirmed non-bug capability difference). **Outstanding:** a regression test for the D1 trigger shape (no existing fixture uses it — see Code path coverage row 25), a near-boundary A/B fixture to confirm D4 against legacy's own comparison precision, second-engineer oracle review, custom TRUE/FALSE-value and default-mask-type comparison against legacy (not covered by AB1–AB3). | For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrientationCheckFilter.md` and `src/Plugins/OrientationAnalysis/vv/ComputeAvgCAxesFilter.md` (on `topic/vv/compute_avg_caxis`). ## Summary -`MultiThresholdObjectsFilter` builds a typed mask array by elementwise-comparing one or more input arrays against user-supplied thresholds, combined through an arbitrarily-nested tree of AND/OR/invert `ArrayThresholdSet`s. Verification uses a **Class 1 (Analytical) oracle**: every comparison operator, invert flag, union operator, nesting depth, and both the mask-output and source-input `DataType` are exhaustively hand-derived and asserted in `MultiThresholdObjectsTest.cpp` (9 `TEST_CASE` groups, all passing). 23 of 25 algorithm/preflight code paths are exercised. An independent three-way runtime A/B (legacy DREAM3D 6.5.171, this branch, and a numpy oracle) against both legacy predecessors — at 100 tuples and again at 50M tuples — confirms the current implementation matches legacy exactly, and quantifies two real bugs that were present on `develop` and are already fixed by commit `25f1986f1`: `MultiThresholdObjectsFilter-D1` (all-false mask when a set mixes a leaf threshold with a nested set, 38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (`std::reverse`-based tuple-order corruption in an inverted nested set instead of per-value inversion, 51/100 tuples wrong). A third, non-bug deviation (`MultiThresholdObjectsFilter-D3`) documents that multi-component index selection is SIMPLNX-only — legacy `Threshold Objects (Advanced)` rejects non-scalar arrays outright. Neither D1 nor D2 has a regression test in the repo yet. +`MultiThresholdObjectsFilter` builds a typed mask array by elementwise-comparing one or more input arrays against user-supplied thresholds, combined through an arbitrarily-nested tree of AND/OR/invert `ArrayThresholdSet`s. Verification uses a **Class 1 (Analytical) oracle**: every comparison operator, invert flag, union operator, nesting depth, custom TRUE/FALSE execution, and both the mask-output and source-input `DataType` are exhaustively hand-derived and asserted in `MultiThresholdObjectsTest.cpp` (13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations, all passing). 24 of 26 algorithm/preflight code paths are exercised. An independent three-way runtime A/B (legacy DREAM3D 6.5.171, this branch, and a numpy oracle) against both legacy predecessors — at 100 tuples and again at 50M tuples — confirms the current implementation matches legacy exactly, and quantifies two real bugs that were present on `develop` and are fixed by this PR: `MultiThresholdObjectsFilter-D1` (all-false mask when a set mixes a leaf threshold with a nested set, 38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (`std::reverse`-based tuple-order corruption in an inverted nested set instead of per-value inversion, 51/100 tuples wrong). This PR also fixes a third bug, `MultiThresholdObjectsFilter-D4`: `develop` applied raw (non-tolerant) floating-point comparison operators, which could misclassify input values very close to or exactly at a threshold — not directly exercised by the A/B fixtures, so not independently quantified against legacy the way D1/D2 are. A fourth, non-bug deviation (`MultiThresholdObjectsFilter-D3`) documents that multi-component index selection is SIMPLNX-only — legacy `Threshold Objects (Advanced)` rejects non-scalar arrays outright. None of D1, D2, or D4 has a regression test in the repo yet. ## Algorithm Relationship @@ -39,47 +39,50 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie 686d5393-2b02-5c86-b887-dd81a8ae80f2 → MultiThresholdObjectsFilter // MultiThresholdObjects2 ("Threshold Objects (Advanced)") ``` -`FromSIMPLJson()` correspondingly branches on which legacy UUID (or, for 6.4 pipelines lacking a UUID, which legacy class name) produced the incoming JSON: the basic `MultiThresholdObjects` source is read through `ComparisonSelectionFilterParameterConverter` (flat, AND-only comparison list) and the advanced `MultiThresholdObjects2` source is read through `ComparisonSelectionAdvancedFilterParameterConverter` (nested AND/OR comparison sets). Both are converted into the same `ArrayThresholdSet` argument. This is **not** a line-by-line port of a single legacy algorithm — it's a consolidation of two independently-shipped legacy filters into one, which is why the classification is **Rewrite** rather than Port, per `vv_policy.md`: *"keeping [a UUID relationship] is a claim of functional equivalence... The Deviations file must defend the claim."* Here the claim is stronger than usual — that the merged filter reproduces each of the two legacy filters' behavior when configured equivalently to it. SIMPL 6.4/6.5 conversion fixtures exist at `test/simpl_conversion/6_4/MultiThresholdObjectsFilter.json` and `test/simpl_conversion/6_5/MultiThresholdObjectsFilter.json`, asserting the *argument conversion* round-trips correctly. Execution-output equivalence against both legacy filters has now been runtime-A/B-verified on representative flat, nested, and inverted-nested configurations — see Deviations file for the comparison record. +`FromSIMPLJson()` correspondingly branches on which legacy UUID (or, for 6.4 pipelines lacking a UUID, which legacy class name) produced the incoming JSON: the basic `MultiThresholdObjects` source is read through `ComparisonSelectionFilterParameterConverter` (flat, AND-only comparison list) and the advanced `MultiThresholdObjects2` source is read through `ComparisonSelectionAdvancedFilterParameterConverter` (nested AND/OR comparison sets). Both are converted into the same `ArrayThresholdSet` argument. This is **not** a line-by-line port of a single legacy algorithm — it's a consolidation of two independently-shipped legacy filters into one, which is why the classification is **Rewrite** rather than Port, per `vv_policy.md`: *"keeping [a UUID relationship] is a claim of functional equivalence... The Deviations file must defend the claim."* Here the claim is stronger than usual — that the merged filter reproduces each of the two legacy filters' behavior when configured equivalently to it. SIMPL 6.4/6.5 conversion fixtures exist at `test/simpl_conversion/6_4/MultiThresholdObjectsFilter.json` and `test/simpl_conversion/6_5/MultiThresholdObjectsFilter.json`, asserting the *argument conversion* round-trips correctly (also now covered by the `SIMPL Backwards Compatibility` `TEST_CASE`). Execution-output equivalence against both legacy filters has now been runtime-A/B-verified on representative flat, nested, and inverted-nested configurations — see Deviations file for the comparison record. *Structural differences from each legacy source:* 1. **Consolidation itself** — one `ArrayThresholdSet` tree replaces two separate legacy parameter models (flat list vs. nested set); the flat legacy model is representable as a one-level `ArrayThresholdSet`. Spot-verified equivalent via runtime A/B (`AB1`, flat config vs. legacy `Threshold Objects`) — see Deviations file. 2. Multi-component index selection added (`#1184`, `32837a30f`) — confirmed **NX-only**: legacy `Threshold Objects (Advanced)`'s `dataCheck()` rejects non-scalar arrays outright (error `-11003`); legacy `Threshold Objects` never had per-component comparison either. Documented as `MultiThresholdObjectsFilter-D3` (non-bug capability addition) in the Deviations file. -3. Custom TRUE/FALSE mask output values added (`#669`, `b65210cf3`) — additive parameter; unconfirmed whether either legacy filter had this option or if it's SIMPLNX-only. +3. Custom TRUE/FALSE mask output values added (`#669`, `b65210cf3`) — additive parameter; unconfirmed whether either legacy filter had this option or if it's SIMPLNX-only. Execution-time application (not just preflight bounds-checking) is now covered by the `Valid Execution - Custom Values` `TEMPLATE_TEST_CASE`. 4. Default mask output `DataType` changed to `uint8` (`#1502`, `49919b086`) — unconfirmed against either legacy filter's default. 5. `executeImpl()` body moved into `Algorithms/MultiThresholdObjects.{hpp,cpp}` (`#1544`, `8381d1dd5`) — structural only, no behavior change (internal to SIMPLNX, not a legacy-relationship concern). *Material PRs since baseline (2025-10-01):* -- **#1582** — "ENH: Add missing cancel checks to lots of filters" (`1a42ec6fb`) — cross-cutting PR; added `m_ShouldCancel` checks to many filters including this one. No output-behavior change on a non-cancelled run. -- **#1605** — "BUG: Fix SIMPL JSON conversion segfault and re-enable backwards-compatibility checks" (`996d7af5a`) — fixed a crash in `FromSIMPLJson()` and re-enabled the SIMPL 6.4/6.5 backwards-compatibility test for this filter. Affects pipeline-conversion correctness, not execution output. -- Otherwise none identified beyond the deltas above and this branch's `vv/MultiThresholdObjects` restructuring + test work. +- **#1582** — "ENH: Add missing cancel checks to lots of filters" (`1a42ec6fb`) — cross-cutting PR; added `m_ShouldCancel` checks to many filters including this one (visible today in `MultiThresholdObjects::operator()` and `ThresholdSet`'s per-item cancel check). No output-behavior change on a non-cancelled run. +- **#1605** — "BUG: Fix SIMPL JSON conversion segfault and re-enable backwards-compatibility checks" (`996d7af5a`) — fixed a crash in `FromSIMPLJson()` and re-enabled the SIMPL 6.4/6.5 backwards-compatibility test for this filter (now `SIMPL Backwards Compatibility` in the test file). Affects pipeline-conversion correctness, not execution output. +- This PR itself also rewrote the comparison/combination internals, fixing three bugs — `MultiThresholdObjectsFilter-D1`, `-D2`, and `-D4` — see Deviations file. `D4` in particular replaced raw `std::less`/`std::greater`/`std::equal_to`/`std::not_equal_to` (unsafe for floating-point precision near/at threshold boundaries) with epsilon-tolerant `OperatorLess`/`OperatorGreater`/`OperatorEqual`/`OperatorNotEqual` operators. ## Oracle *Class:* **1 (Analytical)** -*Applied:* For a single threshold, `expected[i] = COMPARISON(input[i], value)` (optionally inverted); for a component-indexed array, `input[i]` is replaced by `input[i][componentIndex]`. For a threshold set, `expected` is the boolean combination of each member's own `expected` value: the first member always seeds the accumulator, and the configured `UnionOperator` (AND/OR) combines each subsequent member; the whole set's `expected` is inverted again if the set itself is marked inverted. Every free variable in this formula — comparison operator, invert flag, union operator, set nesting, component index, mask output `DataType`, and source-array `DataType` — is enumerated directly against this closed-form definition in the test file's `Expected*Mask` helper functions, independent of the algorithm's own C++ control flow (`ThresholdFilterHelper`, `InsertThreshold`, `ApplyThresholdValues`). +*Applied:* For a single threshold, `expected[i] = COMPARISON(input[i], value)` (optionally inverted); for a component-indexed array, `input[i]` is replaced by `input[i][componentIndex]`. For a threshold set, `expected` is the boolean combination of each member's own `expected` value: the first member always seeds the accumulator, and the configured `UnionOperator` (AND/OR) combines each subsequent member; the whole set's `expected` is inverted again if the set itself is marked inverted. Every free variable in this formula — comparison operator, invert flag, union operator, set nesting, component index, mask output `DataType`, source-array `DataType`, and custom TRUE/FALSE execution values — is enumerated directly against this closed-form definition in the test file's `Expected*Mask` helper functions and inline hardcoded exemplar arrays, independent of the algorithm's own C++ control flow (`ThresholdFilterHelper`, `InsertThreshold`, `ApplyThresholdValues`). *Encoded:* `test/MultiThresholdObjectsTest.cpp` — +- `Exemplar Single Thresholds: Int` / `: Float` — single fixed-threshold fixtures checked against hardcoded `k_ExemplarInt4` / `k_ExemplarFloat02` arrays - `Valid Single Thresholds: Int` / `: Float` / `: Int Multi-Component` — comparison operator × invert × (component index for multi-component) sweep, via `ExpectedIntSingleComponentMask` / `ExpectedFloatSingleComponentMask` / `ExpectedIntMultiComponentMask` - `Valid Threshold Sets` — 5 hand-built AND / OR / nested-set / nested-set-with-OR / nested-set-with-OR+invert configurations (`CreateThresholdSet1`–`5`), via `ExpectedThresholdSet1Mask`–`5` -- `Valid Execution, Mask DataType` — 11 mask-output `DataType`s +- `Valid Execution, Mask DataType` — 10 mask-output `DataType`s - `Valid Execution, Input Array DataType` — 11 source-array `DataType`s +- `Valid Execution - Custom Values` — 10 mask `DataType`s, custom TRUE/FALSE values applied at execution time (not just preflight bounds-checked) +- `SIMPL Backwards Compatibility` — SIMPL 6.4/6.5 argument-conversion round-trip - `Invalid Execution`, `Invalid Execution - Out of Bounds Custom Values` (9 numeric types), `Invalid Execution - Boolean Custom Values` — negative-path fixtures -9 `TEST_CASE` groups (17 ctest entries, counting the 9 `TEMPLATE_TEST_CASE` type instantiations separately), all pass at HEAD. +13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations, all pass at HEAD. *Second-engineer review:* Skipped — recorded reason: the oracle is elementwise comparison plus boolean set algebra (AND/OR/invert), and the test matrix enumerates it exhaustively (every operator × invert × union operator × nesting × both `DataType` axes) rather than sampling a single hand-derivation, substituting breadth for independent derivation review. This is now additionally corroborated by an independent three-way A/B (legacy DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and an independent numpy oracle) matching exactly on representative flat/nested/inverted-nested configurations at both 100-tuple and 50M-tuple scale — see the Deviations file. **This still is not a substitute for a named second-engineer pass** — it is recorded here as an outstanding gate for promotion past DRAFT, not a completed one. ## Code path coverage -**23 of 25 paths exercised.** Row 13 is a permanent, acceptable gap; row 25 is a real gap that let `MultiThresholdObjectsFilter-D1` ship — see `vv/deviations/MultiThresholdObjectsFilter.md`. +**24 of 26 paths exercised.** Row 13 is a permanent, acceptable gap; row 25 is a real gap that let `MultiThresholdObjectsFilter-D1` ship — see `vv/deviations/MultiThresholdObjectsFilter.md`. -Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp` (~255 lines), plus 7 preflight-only paths in `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp`. +Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp` (~340 lines), plus 7 preflight-only paths in `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp`. -Two logical stages: **(a) preflight** validates the threshold set / mask-type / custom-value configuration and stages the output `CreateArrayAction`; **(b) algorithm** recursively evaluates the `ArrayThresholdSet` tree (per-array comparison → per-set union/invert/replace combination) and writes the result into the mask array via a type-dispatched functor. +Two logical stages: **(a) preflight** validates the threshold set / mask-type / custom-value configuration and stages the output `CreateArrayAction`; **(b) algorithm** recursively evaluates the `ArrayThresholdSet` tree into an internal `bool` mask (per-array comparison combined inline via `ThresholdFilterHelper`'s per-tuple AND/OR switch, then `InsertThreshold`/`ApplyThresholdValues` for cross-node combination) and writes the result into the typed mask array via `ThresholdSetFunctor`, substituting `trueValue`/`falseValue` at that final write. | # | Stage | Path | Test case | |----|-------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| @@ -95,30 +98,31 @@ Two logical stages: **(a) preflight** validates the threshold set / mask-type / | 10 | (b) Algorithm | `ComparisonType::GreaterThan` | "ArrayThreshold: >" | | 11 | (b) Algorithm | `ComparisonType::Operator_Equal` | "ArrayThreshold: ==" | | 12 | (b) Algorithm | `ComparisonType::Operator_NotEqual` | "ArrayThreshold: !=" | -| 13 | (b) Algorithm | `else` → `throw std::runtime_error` (unrecognized comparison type) | *Not directly tested. Unreachable via the public `ComparisonType` enum — all enumerators are exercised by rows 9–12.* | +| 13 | (b) Algorithm | `default` → `throw std::runtime_error` (unrecognized union operator, in `ThresholdFilterHelper::filterDataWithComparision`'s switch) | *Not directly tested. Unreachable via the public `UnionOperator` enum — both enumerators (`And`, `Or`) are exercised elsewhere.* | | 14 | (b) Algorithm | `InsertThreshold` with `inverse == true` (flip before combine) | `isInverted = GENERATE(false, true)` in every single-threshold and threshold-set test | | 15 | (b) Algorithm | `InsertThreshold` with `inverse == false` | same | | 16 | (b) Algorithm | Combine with `UnionOperator::Or` | `CreateThresholdSet2` (threshold2 = Or), `CreateThresholdSet4`/`5` (nested-set union = Or) | -| 17 | (b) Algorithm | Combine with `UnionOperator::And` (else branch) | `CreateThresholdSet1` (threshold2/3 = And), `CreateThresholdSet3` default nested And | +| 17 | (b) Algorithm | Combine with `UnionOperator::And` | `CreateThresholdSet1` (threshold2/3 = And), `CreateThresholdSet3` default nested And | | 18 | (b) Algorithm | `ApplyThresholdValues` with `replaceInput == true` (first item in a set forces Or regardless of configured operator) | implicit in every threshold set — first entry of every `CreateThresholdSet*` | | 19 | (b) Algorithm | `ApplyThresholdValues` with `replaceInput == false` (honors configured operator for later items) | same sets, 2nd/3rd entries | | 20 | (b) Algorithm | `ThresholdSet` recursion — item is a nested `ArrayThresholdSet` | `CreateThresholdSet3`/`4`/`5` (set-of-sets) | | 21 | (b) Algorithm | `ThresholdSet` — item is a leaf `ArrayThreshold` | all tests | -| 22 | (b) Algorithm | `ThresholdSetFunctor` dispatch on **mask (output) DataType** | `Valid Execution, Mask DataType` — boolean + int8/16/32/64 + uint8/16/32/64 + float32/64, all 11 types | +| 22 | (b) Algorithm | `ThresholdSetFunctor` dispatch on **mask (output) DataType** | `Valid Execution, Mask DataType` — int8/16/32/64 + uint8/16/32/64 + float32/64 (10 types; no `boolean` `SECTION` in this test — boolean is exercised separately via the default mask type used throughout `RunThresholdSetTest`/other `TEST_CASE`s) | | 23 | (b) Algorithm | `ExecuteThresholdHelper` dispatch on **source array's DataType** | `Valid Execution, Input Array DataType` — int8/16/32/64 + uint8/16/32/64 + float32/64 + boolean, all 11 types | | 24 | (b) Algorithm | Multi-component `componentIndex != 0` selection | `Valid Single Thresholds: Int Multi-Component` (`componentIndex = GENERATE(0,1,2)`), plus `componentIndex=1` in Set1, `=0` in Set2 | -| 25 | (b) Algorithm | An `ArrayThresholdSet` whose children mix at least one leaf `ArrayThreshold` with at least one nested `ArrayThresholdSet` (e.g. `{leaf, nestedSet}`, not `{leaf, leaf, leaf}` or `{set, set}`). Historically produced an all-false mask regardless of input (`MultiThresholdObjectsFilter-D1`, confirmed against legacy `Threshold Objects (Advanced)` — 38/100 tuples wrong pre-fix), fixed by commit `25f1986f1`. | *Not directly tested by the in-repo `TEST_CASE` suite. No existing fixture uses this exact shape — every `CreateThresholdSet*` helper passes either all leaves or all nested sets to `setArrayThresholds()`, never a mix. Confirmed by the external `MultiThresholdObjectsFilter-AB2` legacy A/B fixture (see Deviations file), which is not part of the ctest suite. This gap is what let D1 ship; a dedicated in-repo regression fixture is recommended before status promotion.* | +| 25 | (b) Algorithm | An `ArrayThresholdSet` whose children mix at least one leaf `ArrayThreshold` with at least one nested `ArrayThresholdSet` (e.g. `{leaf, nestedSet}`, not `{leaf, leaf, leaf}` or `{set, set}`). Historically produced an all-false mask regardless of input (`MultiThresholdObjectsFilter-D1`, confirmed against legacy `Threshold Objects (Advanced)` — 38/100 tuples wrong on `develop`), fixed by this PR. | *Not directly tested by the in-repo `TEST_CASE` suite. No existing fixture uses this exact shape — every `CreateThresholdSet*` helper passes either all leaves or all nested sets to `setArrayThresholds()`, never a mix. Confirmed by the external `AB2` legacy A/B fixture (see Deviations file), which is not part of the ctest suite. This gap is what let D1 ship; a dedicated in-repo regression fixture is recommended before status promotion.* | +| 26 | (b) Algorithm | Custom TRUE/FALSE value substitution at execution time (`ThresholdSetFunctor` writing `trueValue`/`falseValue` from `MultiThresholdObjects::operator()`, not the default `1.0`/`0.0`) — distinct from rows 6–7, which only cover the preflight bounds-check rejecting *out-of-range* custom values and never actually execute with valid ones | `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`, 10 numeric mask types), asserting `trueValue`/`falseValue` (25/10 in the test) appear in the output instead of 1/0 | Not counted as an algorithm/preflight path: the "Empty ArrayThreshold DataPath" section of `Invalid Execution` exercises `ArrayThresholdsParameter`'s own path-existence validation, which runs before `preflightImpl` is called — it's a parameter-layer gate, not code inside this filter or algorithm. -Also note: `k_MismatchingComponentsArrayPath` (test file, line 31) is a leftover unused `DataPath` constant — the array it used to name was removed when `Valid Execution, Input Array DataType` was added. Not a coverage gap (the filter has no cross-array component-count check), just dead test-source code worth deleting. - -`MultiThresholdObjectsFilter-D2` (the pre-fix `std::reverse` tuple-order bug) does not get its own row: the buggy code path no longer exists (removed by commit `25f1986f1`, which unified all combination logic through `ApplyThresholdValues`/`InsertThreshold`). The legacy A/B's `AB3` fixture (a leaf combined with an inverted nested set — see Deviations file) is the confirmed trigger; it overlaps with row 25's mixed-sibling shape rather than isolating D2 cleanly on its own. `Valid Threshold Sets`' `isInverted = GENERATE(false, true)` sweep exercises top-level-inverted sets today, but that test predates the fix and was never confirmed to have actually caught D2 at the time (no regression-test commit accompanies `25f1986f1`), and it doesn't cover AB3's specific mixed-sibling-plus-inverted-nested-child shape either. +`MultiThresholdObjectsFilter-D2` (the pre-fix `std::reverse` tuple-order bug) does not get its own row: the buggy code path no longer exists on this branch. The legacy A/B's `AB3` fixture (a leaf combined with an inverted nested set — see Deviations file) is the confirmed trigger; it overlaps with row 25's mixed-sibling shape rather than isolating D2 cleanly on its own. `Valid Threshold Sets`' `isInverted = GENERATE(false, true)` sweep exercises top-level-inverted sets today, but doesn't cover AB3's specific mixed-sibling-plus-inverted-nested-child shape. ## Test inventory | Test case | Status | Notes | |-----------|--------|-------| +| `Exemplar Single Thresholds: Int` | kept | Single fixed threshold (`Int32 > 4`) checked against hardcoded `k_ExemplarInt4` array. | +| `Exemplar Single Thresholds: Float` | kept | Single fixed threshold (`Float32 == 0.02`) checked against hardcoded `k_ExemplarFloat02` array. | | `Valid Single Thresholds: Int` | kept | `GENERATE` over 8 threshold values × 2 invert states, 4 `SECTION`s (`>`, `<`, `==`, `!=`) against `k_TestArrayIntPath`; every tuple checked via `ExpectedIntSingleComponentMask`. | | `Valid Single Thresholds: Float` | kept | Same sweep against `k_TestArrayFloatPath` via `ExpectedFloatSingleComponentMask`. | | `Valid Single Thresholds: Int Multi-Component` | kept | Adds `componentIndex = GENERATE(0,1,2)` against `k_MultiComponentArrayPath`. | @@ -126,10 +130,14 @@ Also note: `k_MismatchingComponentsArrayPath` (test file, line 31) is a leftover | `Invalid Execution` | kept | 4 `SECTION`s: empty threshold set (`-4000`), empty threshold `DataPath` (parameter-layer validation), out-of-bounds component index (`InvalidComponentIndex`), mismatched tuple counts (`UnequalTuples`). | | `Invalid Execution - Out of Bounds Custom Values` (`TEMPLATE_TEST_CASE`) | kept | 9 numeric-type instantiations × 4 `SECTION`s (true/false value below minimum / above maximum) — `CustomTrueOutOfBounds` / `CustomFalseOutOfBounds`. | | `Invalid Execution - Boolean Custom Values` | kept | 2 `SECTION`s — custom TRUE/FALSE value rejected when mask type is `boolean`. | -| `Valid Execution, Mask DataType` | kept | 11 `SECTION`s, one per mask-output `DataType` (int8…float64; boolean covered via the default mask type used throughout the other `TEST_CASE`s). | -| `Valid Execution, Input Array DataType` | new-for-V&V (`d18b0f34d`, 2026-07-23) | 11 `SECTION`s, one per **source-array** `DataType` (int8…float64, bool) — closes the code-path gap on row 23 identified during path enumeration. | +| `Valid Execution, Mask DataType` | kept | 10 `SECTION`s, one per mask-output `DataType` (int8…float64 — no `boolean` `SECTION`; boolean covered via the default mask type used throughout the other `TEST_CASE`s). | +| `Valid Execution, Input Array DataType` | kept | 11 `SECTION`s, one per **source-array** `DataType` (int8…float64, bool). | +| `SIMPL Backwards Compatibility` | restored | `DYNAMIC_SECTION` over the SIMPL 6.4 and 6.5 conversion fixtures; asserts pipeline conversion round-trips (UUID + one argument value). Re-enabled by `#1605` after a prior segfault. | +| `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`) | restored | 10 numeric-type instantiations; asserts custom TRUE (`25`) / FALSE (`10`) values are actually written to the output mask at execution time — closes the code-path gap on row 26. | + +**Missing:** no test case exercises a set mixing a leaf threshold with a sibling nested set (row 25) — the shape that triggered `MultiThresholdObjectsFilter-D1`. Recommended before status promotion: add a `SECTION` to `Valid Threshold Sets` (or a new `TEST_CASE`) covering this shape, so a regression can't reintroduce D1 silently. -**Missing:** no test case exercises a "plain nested set" (a top-level `ArrayThresholdSet` whose only child is a single nested `ArrayThresholdSet`, no siblings) — the shape that triggered `MultiThresholdObjectsFilter-D1`. Recommended before status promotion: add a `SECTION` to `Valid Threshold Sets` (or a new `TEST_CASE`) covering this shape, so a regression can't reintroduce D1 silently. +**Count basis:** 30 ctest entries = 11 single-entry `TEST_CASE`s (1 ctest entry each) + `Invalid Execution - Out of Bounds Custom Values` (`TEMPLATE_TEST_CASE`, 9 types → 9 entries) + `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`, 10 types → 10 entries), verified by directly reading each declaration's type list twice. `catch_discover_tests` is called with no filtering options in `cmake/Plugin.cmake:404`, so Catch2's default behavior applies: one ctest entry per `TEMPLATE_TEST_CASE` type instantiation. A previously-cited count of 28 entries doesn't match this direct enumeration; the most likely explanation is that count was taken from a `ctest -N` run before `SIMPL Backwards Compatibility` and/or `Valid Execution - Custom Values` were both present in the build (each restores test that had been temporarily disabled). 30 is the number that matches the test file as it stands today — re-run `ctest -N -R MultiThresholdObjects` against a fresh build to confirm. ## Exemplar archive @@ -139,6 +147,7 @@ None. All fixtures for this filter are constructed in-memory in `test/MultiThres Legacy comparison **run**: independent three-way A/B (DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and a numpy oracle) on flat, nested, and inverted-nested configurations at 100 tuples and again at 50M tuples. Post-fix, all three sources MATCH in every case. Full record in `vv/deviations/MultiThresholdObjectsFilter.md`. -- `MultiThresholdObjectsFilter-D1` — a set mixing a leaf threshold with a sibling nested set produced an all-false mask pre-fix (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`). **Fixed** (`25f1986f1`). -- `MultiThresholdObjectsFilter-D2` — a leaf combined with an inverted nested set used `std::reverse` to flip tuple order instead of flipping each tuple's value pre-fix (51/100 tuples wrong vs. the same legacy filter). **Fixed** (`25f1986f1`). +- `MultiThresholdObjectsFilter-D1` — a set mixing a leaf threshold with a sibling nested set produced an all-false mask on `develop` (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`). **Fixed by this PR.** +- `MultiThresholdObjectsFilter-D2` — a leaf combined with an inverted nested set used `std::reverse` to flip tuple order instead of flipping each tuple's value on `develop` (51/100 tuples wrong vs. the same legacy filter). **Fixed by this PR.** - `MultiThresholdObjectsFilter-D3` — multi-component index selection is SIMPLNX-only; legacy `Threshold Objects (Advanced)` rejects non-scalar arrays (`dataCheck()` error `-11003`). Not a bug — a deliberate SIMPLNX capability addition, documented for migration guidance. +- `MultiThresholdObjectsFilter-D4` — `develop` used raw, non-tolerant floating-point comparison operators, which could misclassify input values very close to or exactly at a threshold. **Fixed by this PR.** Not exercised by `AB1`–`AB3` (their threshold values aren't near any input value), so not independently quantified against legacy — a dedicated near-boundary A/B fixture is still needed. diff --git a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md index 12f1e33100..e80030b7a9 100644 --- a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md @@ -10,9 +10,9 @@ Entries are referenced by stable ID (`MultiThresholdObjectsFilter-D`) from th ## Headline -**3 deviations documented: 2 bugs (both in SIMPLNX, both fixed pre-branch), 1 confirmed non-bug capability difference.** Legacy comparison has been **run**: an independent three-way A/B — DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and an independent numpy oracle — on a shared 100-tuple fixture, covering representative flat (`Threshold Objects`), nested, and inverted-nested (`Threshold Objects (Advanced)`) configurations, re-run again at 50M tuples. Post-fix, all three sources MATCH in every case at both scales. All 17 in-repo ctest entries also pass locally. +**4 deviations documented: 3 bugs (all in SIMPLNX, all fixed by this PR), 1 confirmed non-bug capability difference.** Legacy comparison has been **run**: an independent three-way A/B — DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and an independent numpy oracle — on a shared 100-tuple fixture, covering representative flat (`Threshold Objects`), nested, and inverted-nested (`Threshold Objects (Advanced)`) configurations, re-run again at 50M tuples. Post-fix, all three sources MATCH in every case at both scales. The in-repo `MultiThresholdObjectsTest.cpp` suite (13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 30 ctest entries — see the V&V report's Test inventory for the count basis) also passes locally. -The same three pipelines run against `develop` (pre-fix) reproduce two real bugs quantitatively: `MultiThresholdObjectsFilter-D1` (38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (51/100 tuples wrong). Both are fixed by commit `25f1986f1` ("Fixed MultiThresholdObjects ThresholdSets algorithm", 2026-04-23), which predates this V&V pass. Neither has a dedicated regression test in the in-repo `TEST_CASE` suite yet (see the V&V report's Code path coverage row 25 and Test inventory "Missing" note) — status should not promote past DRAFT until at least D1's trigger shape has one. +The same three pipelines run against `develop` (pre-fix) reproduce two real bugs quantitatively: `MultiThresholdObjectsFilter-D1` (38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (51/100 tuples wrong). A third bug, `MultiThresholdObjectsFilter-D4`, is a floating-point comparison-precision defect (raw `std::less`/`std::greater`/`std::equal_to`/`std::not_equal_to` applied directly to `float`/`double` operands, unsafe near or at threshold boundaries) — not directly exercised by the `AB1`–`AB3` fixtures, so it isn't independently quantified against legacy the way D1/D2 are. All three are **fixed by this PR** — not by a commit that predates this V&V pass. None of D1, D2, or D4 has a dedicated regression test in the in-repo `TEST_CASE` suite yet (see the V&V report's Code path coverage row 25 and Test inventory "Missing" note) — status should not promote past DRAFT until at least D1's trigger shape has one. `MultiThresholdObjectsFilter-D3` documents a confirmed, deliberate capability difference (not a bug): multi-component index selection only exists in SIMPLNX. @@ -25,7 +25,7 @@ The same three pipelines run against `develop` (pre-fix) reproduce two real bugs | **Comparison type** | Runtime three-way A/B: legacy DREAM3D 6.5.171 (`PipelineRunner`) vs. this branch (`nxrunner`) vs. an independent numpy oracle | | **Shared input** | Legacy-format fixture, 100 tuples: `Int32 = 0..99`, `Float32 = 0.01*(i+1)` | | **Scale re-run** | Same three configurations (AB1–AB3) re-run at 50M random tuples — this branch matches the numpy oracle exactly at scale | -| **In-repo regression suite** | All 17 ctest entries in `test/MultiThresholdObjectsTest.cpp` pass locally at the verified commit | +| **In-repo regression suite** | `test/MultiThresholdObjectsTest.cpp` passes locally at the verified commit (13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 30 ctest entries; see the V&V report's Test inventory for the count basis) | ### Per-configuration result (100-tuple fixture, this branch = post-fix) @@ -37,13 +37,13 @@ The same three pipelines run against `develop` (pre-fix) reproduce two real bugs ### Pre-fix (`develop`) result, same three pipelines -| Case | Result on `develop` (pre-`25f1986f1`) | Deviation | +| Case | Result on `develop` (pre-fix) | Deviation | |---|---|---| -| `AB1` | Matches legacy/oracle (flat configs were never affected — see D1/D2 root causes) | none | +| `AB1` | Matches legacy/oracle (the flat config was never affected — see D1/D2 root causes) | none | | `AB2` | **All-false mask — 38/100 tuples wrong** | `MultiThresholdObjectsFilter-D1` | | `AB3` | **Differs in 51/100 values** | `MultiThresholdObjectsFilter-D2` | -Both bug-fix claims in the PR are real, and the fix restores legacy semantics: legacy's `invertThreshold()` flips values element-wise; the old SIMPLNX `std::reverse` was a misport of that, and the old per-item functor dispatch broke nested-set combination entirely. (Engineer's account, corroborated by the `25f1986f1` diff — see D1/D2 Root cause below.) +Both bug-fix claims in the PR are real, and the fix restores legacy semantics: legacy's `invertThreshold()` flips values element-wise; the old SIMPLNX `std::reverse` was a misport of that, and the old per-item functor dispatch broke nested-set combination entirely. (Engineer's account.) --- @@ -53,15 +53,15 @@ Both bug-fix claims in the PR are real, and the fix restores legacy semantics: l |---|---| | **Deviation ID** | `MultiThresholdObjectsFilter-D1` | | **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | -| **Status** | retired 2026-04-23 — fixed by commit `25f1986f1`, prior to this V&V pass | +| **Status** | fixed by this PR | -**Symptom:** An `ArrayThresholdSet` whose children mix at least one leaf `ArrayThreshold` with at least one nested `ArrayThresholdSet` (e.g. `AB2`: `{leaf: Int32 > 20, nestedSet: (Float32 < 0.60 OR Int32 == 55)}`) produced an all-false mask, regardless of input data. Quantified on the `AB2` fixture: **38 of 100 tuples wrong** (all forced false) vs. legacy `Threshold Objects (Advanced)` and the numpy oracle. +**Symptom:** On `develop`, an `ArrayThresholdSet` whose children mix at least one leaf `ArrayThreshold` with at least one nested `ArrayThresholdSet` (e.g. `AB2`: `{leaf: Int32 > 20, nestedSet: (Float32 < 0.60 OR Int32 == 55)}`) produced an all-false mask, regardless of input data. Quantified on the `AB2` fixture: **38 of 100 tuples wrong** (all forced false) vs. legacy `Threshold Objects (Advanced)` and the numpy oracle. -**Root cause:** Bug (SIMPLNX-side, pre-fix). Per the reporting engineer: the old per-item functor dispatch broke nested-set combination entirely. Corroborating evidence from the `25f1986f1` diff: the pre-fix `MultiThresholdObjects.cpp` threaded a redundant `bool inverse` parameter down through recursive `ThresholdSet`/`ThresholdValue` calls (separate from each node's own `arrayThreshold.isInverted()`), and applied a dual apply strategy — the first item at any nesting level (`replaceInput == true`) took a direct-copy path while later items took an `InsertThreshold`-based AND/OR combine path against an accumulator pre-filled with `falseValue`. Commit `25f1986f1` ("Standardized apply threshold values between thresholds and sets. Removed unnecessary inversion parameter...") replaced both call sites with a single `ApplyThresholdValues` → `InsertThreshold` path. The exact instruction-level trace of why this specifically zeroed the mask for the mixed-leaf/nested-set shape was not independently re-derived line-by-line for this report; the symptom and fix are established by the `AB2` runtime comparison against real legacy output, which is stronger evidence than a re-derived trace would be. +**Root cause:** Bug (SIMPLNX-side, `develop`). Per the reporting engineer: the old per-item functor dispatch broke nested-set combination entirely — a redundant inversion/apply mechanism was threaded separately from each node's own `isInverted()`, combined with a dual apply strategy (direct-copy for the first item in a set vs. an AND/OR combine for later items against an accumulator pre-filled false) that this PR replaced with a single, consistent combination path. The exact instruction-level trace of why this specifically zeroed the mask for the mixed-leaf/nested-set shape on `develop` was not independently re-derived line-by-line for this report; the symptom and fix are established by the `AB2` runtime comparison against real legacy output, which is stronger evidence than a re-derived trace would be. -**Affected users:** Any pipeline (SIMPLNX-native, or converted from legacy `Threshold Objects (Advanced)`) using a threshold set that combines a plain leaf comparison with a sibling nested group — a common shape, not an exotic edge case. Silent: the filter reported success and wrote a fully-false mask with no warning. +**Affected users:** Any pipeline (SIMPLNX-native, or converted from legacy `Threshold Objects (Advanced)`) using a threshold set that combines a plain leaf comparison with a sibling nested group — a common shape, not an exotic edge case. Silent on `develop`: the filter reported success and wrote a fully-false mask with no warning. -**Recommendation:** Trust SIMPLNX (current/post-fix — confirmed bit-for-bit against legacy on `AB2` at both 100 tuples and 50M tuples). The pre-fix output was unconditionally wrong; anyone on a pre-`25f1986f1` build should upgrade and re-verify any pipeline outputs generated before the fix. +**Recommendation:** Trust SIMPLNX (this PR — confirmed bit-for-bit against legacy on `AB2` at both 100 tuples and 50M tuples). The `develop` output was unconditionally wrong; anyone on a `develop` build predating this PR should upgrade and re-verify any pipeline outputs generated before the fix. --- @@ -71,15 +71,15 @@ Both bug-fix claims in the PR are real, and the fix restores legacy semantics: l |---|---| | **Deviation ID** | `MultiThresholdObjectsFilter-D2` | | **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | -| **Status** | retired 2026-04-23 — fixed by commit `25f1986f1`, prior to this V&V pass | +| **Status** | fixed by this PR | -**Symptom:** A leaf combined with an inverted nested set (`AB3`: `{leaf: Int32 < 80, invertedNestedSet: NOT(Int32 > 30 AND Float32 < 0.95)}`) produced incorrect mask output. Quantified on the `AB3` fixture: **51 of 100 values differ** vs. legacy `Threshold Objects (Advanced)` and the numpy oracle. `AB3`'s shape overlaps with `D1`'s mixed-leaf/nested-set trigger, so this result is not a clean isolation of the inversion defect alone — both mechanisms plausibly contribute to the discrepancy. +**Symptom:** On `develop`, a leaf combined with an inverted nested set (`AB3`: `{leaf: Int32 < 80, invertedNestedSet: NOT(Int32 > 30 AND Float32 < 0.95)}`) produced incorrect mask output. Quantified on the `AB3` fixture: **51 of 100 values differ** vs. legacy `Threshold Objects (Advanced)` and the numpy oracle. `AB3`'s shape overlaps with `D1`'s mixed-leaf/nested-set trigger, so this result is not a clean isolation of the inversion defect alone — both mechanisms plausibly contribute to the discrepancy. -**Root cause:** Bug (SIMPLNX-side, pre-fix). Per the reporting engineer: legacy's `invertThreshold()` flips mask values element-wise; the pre-fix SIMPLNX code instead called `std::reverse(tempResultVector.begin(), tempResultVector.end())` on the intermediate result buffer under certain replace/invert conditions — reversing the *order* of elements rather than flipping each element's own TRUE/FALSE value. This is not a valid implementation of per-element boolean inversion. The correct operation (applied correctly elsewhere in the same file, e.g. inside `InsertThreshold`: `newVector[i] = (newVector[i] == trueValue) ? falseValue : trueValue`) flips each element's own value in place, changing nothing about element order. Fixed by the same `25f1986f1` commit that removed the redundant `inverse` parameter and both `std::reverse` call sites, consolidating all inversion through `ApplyThresholdValues` → `InsertThreshold`'s per-element flip. The precise nesting depth at which the pre-fix `std::reverse` branch was reachable (top-level only, or also for a nested child, as in `AB3`) was not independently re-derived line-by-line for this report; documented here on the `AB3` runtime evidence plus the `25f1986f1` diff. +**Root cause:** Bug (SIMPLNX-side, `develop`). Per the reporting engineer: legacy's `invertThreshold()` flips mask values element-wise; the `develop` code instead called `std::reverse()` on the intermediate result buffer under certain replace/invert conditions — reversing the *order* of elements rather than flipping each element's own TRUE/FALSE value. This is not a valid implementation of per-element boolean inversion. This PR fixed it by consolidating all inversion through a single, consistent per-element-flip combination path (visible today as `InsertThreshold`'s `if(inverse) { newValue = !newValue; }`). The precise nesting depth at which the `develop` `std::reverse` branch was reachable (top-level only, or also for a nested child, as in `AB3`) was not independently re-derived line-by-line for this report; documented here on the `AB3` runtime evidence. -**Affected users:** Any pipeline using an inverted `ArrayThresholdSet` — top-level or nested — combined with sibling thresholds/sets. Not a narrow edge case: "Invert Mask" is a standard, documented option. On an image geometry, wrong tuple correspondence scrambles which voxels are masked; there is no legitimate downstream use of the pre-fix output. +**Affected users:** Any pipeline using an inverted `ArrayThresholdSet` — top-level or nested — combined with sibling thresholds/sets. Not a narrow edge case: "Invert Mask" is a standard, documented option. On an image geometry, wrong tuple correspondence scrambles which voxels are masked; there is no legitimate downstream use of the `develop` output. -**Recommendation:** Trust SIMPLNX (current/post-fix — confirmed bit-for-bit against legacy on `AB3` at both 100 tuples and 50M tuples). Anyone on a pre-`25f1986f1` build using an inverted threshold set should upgrade and re-verify any pipeline outputs generated before the fix. +**Recommendation:** Trust SIMPLNX (this PR — confirmed bit-for-bit against legacy on `AB3` at both 100 tuples and 50M tuples). Anyone on a `develop` build predating this PR using an inverted threshold set should upgrade and re-verify any pipeline outputs generated before the fix. --- @@ -101,10 +101,31 @@ Both bug-fix claims in the PR are real, and the fix restores legacy semantics: l --- +## MultiThresholdObjectsFilter-D4 + +| Field | Value | +|---|---| +| **Deviation ID** | `MultiThresholdObjectsFilter-D4` | +| **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | +| **Status** | fixed by this PR | + +**Symptom:** On `develop`, threshold comparisons (`>`, `<`, `==`, `!=`) against a floating-point input array could give an incorrect result when an input value was very close to, or logically should have been exactly equal to, the threshold value. A value that should compare as "equal" could instead evaluate as `>` or `<` (or vice versa), and `==`/`!=` in particular were unreliable near boundary values — an ordinary consequence of comparing floating-point numbers without tolerance. + +**Root cause:** Bug (SIMPLNX-side, `develop`). Per the reporting engineer: `develop`'s comparison logic applied `std::less<>`, `std::greater<>`, `std::equal_to<>`, and `std::not_equal_to<>` directly to floating-point operands, which perform exact bit-for-bit comparison — not safe for floating-point precision, since two values that are mathematically equal (or intended to be) routinely differ in their low-order bits due to representation and accumulated rounding error. This PR fixes it by adding `OperatorLess`/`OperatorGreater`/`OperatorEqual`/`OperatorNotEqual` wrapper structs (`Algorithms/MultiThresholdObjects.cpp`) built on a shared tolerance check, `CheckEquality(a, b) = std::fabs(a - b) < std::numeric_limits::epsilon()`: `OperatorEqual`/`OperatorNotEqual` now use `CheckEquality` instead of exact equality, and `OperatorGreater`/`OperatorLess` explicitly exclude the near-equal band (`(value1 > value2) && !CheckEquality(value1, value2)`), so a value within epsilon of the threshold is never simultaneously reported as both "not equal" and "wrongly ordered." + +**Affected users:** Any pipeline thresholding a floating-point array where the input data or threshold is close to, or intended to exactly match, a boundary value — most commonly when thresholding on a computed/derived float array (e.g. output of an upstream arithmetic filter) where an intended-exact match doesn't land on the identical bit pattern. Most likely to matter for `==`/`!=` comparisons and near-boundary `<`/`>` comparisons; low impact for thresholds far from any input value. + +**Affected legacy comparison:** Unlike D1/D2, this fix is **not confirmed** to restore or preserve legacy semantics — whether DREAM3D 6.5.171's own comparison implementation uses exact or tolerant floating-point comparison has not been checked, and none of `AB1`–`AB3`'s threshold values are close enough to an input value to exercise the epsilon-tolerance branch either way. It is documented here as a genuine SIMPLNX-side correctness fix on its own terms, independent of the legacy comparison. + +**Recommendation:** Trust SIMPLNX (this PR). Epsilon-tolerant floating-point comparison is the technically correct approach regardless of what legacy does. A dedicated near-boundary A/B fixture (input value within float32 epsilon of the threshold, deliberately not bit-identical) is recommended to confirm whether this also closes or opens a gap with legacy — see "Outstanding comparison work" below. + +--- + ## Outstanding comparison work Both legacy filters have now been run separately on representative configurations (`AB1` vs. `Threshold Objects`; `AB2`/`AB3` vs. `Threshold Objects (Advanced)`), satisfying this filter's Rewrite-classification requirement that functional equivalence be independently confirmed against both predecessors, not just one. Remaining lower-priority gaps: 1. **Custom TRUE/FALSE mask output values** (`#669` addition) — not exercised by `AB1`–`AB3`. Compare with it left at legacy defaults first, then with custom values set. 2. **Default mask output `DataType`** — SIMPLNX defaults to `uint8` (`#1502`); confirm what each legacy filter's default was and whether migration guidance is needed for pipelines that relied on the default rather than explicitly setting it. -3. **A broader configuration sweep** beyond the three representative `AB1`–`AB3` shapes (e.g., deeper nesting, mixed AND/OR at multiple levels) is optional given the strong quantitative match already obtained at both 100-tuple and 50M-tuple scale, but would further reduce residual risk before COMPLETE status. +3. **D4's near-boundary floating-point comparison** — a dedicated A/B fixture with an input value within `float32` epsilon of the threshold (but not bit-identical) is needed to determine whether legacy's own comparison is exact or tolerant, and therefore whether D4 opens or closes a legacy gap. `AB1`–`AB3` don't exercise this. +4. **A broader configuration sweep** beyond the three representative `AB1`–`AB3` shapes (e.g., deeper nesting, mixed AND/OR at multiple levels) is optional given the strong quantitative match already obtained at both 100-tuple and 50M-tuple scale, but would further reduce residual risk before COMPLETE status. From b4061f7be908bc7475fdd1e2e6fbec972f3ce696 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 19 Aug 2026 14:44:19 -0400 Subject: [PATCH 23/28] BUG: Restore legacy comparison semantics and address V&V review feedback Reverts the epsilon-tolerant comparison operators introduced as deviation D4. ThresholdFilterHelper again truncates the comparison value to the input array's type and compares in that type, matching legacy DREAM3D's ThresholdFilterHelper. The tolerance had diverged from legacy for fractional thresholds on integer arrays, lost exactness for 64-bit integers by comparing through float64, and applied a scale-dependent absolute epsilon to float64 data. The test oracles now model the same truncation, which is what the tolerance had been papering over. Algorithm changes: * ThresholdSet becomes ComputeThresholdSet, returning its own result store. This drops the redundant top-level temp store and one full pass over the mask. * Restore the fill(false) invariant on each set's accumulator rather than relying on the data store implementation zero-initializing. * Hoist the union-operator and inversion branches out of InsertThreshold's per-element loop. * Remove the dead err and thresholdSet locals from operator()(). Test changes: * Model the comparison-value truncation in the Expected*Mask oracle helpers, and round both operands through float32 for the Float32Array fixture. * Add ArraySet 6 / ArraySet 7 to Valid Threshold Sets: a leaf threshold with a sibling nested set, and the same with the nested set inverted. These are the D1 and D2 trigger shapes, which had no in-repo regression coverage. Both were confirmed to fail against a stubbed algorithm. * Derive the Mask DataType test's expected true/false split from the fixture constants instead of a hardcoded literal. * Drop the complementary-operator re-assertions in the == / != sections, which restated the oracle rather than testing the filter. * Correct fixture comments left stale by the tuple-count change. V&V documents: remove deviation D4, record the comparison-value truncation as verified legacy-matching behavior in the Oracle section, mark code-path row 25 covered (25 of 26), and document the relaxed component-count preflight and the removal of ErrorCodes::UnequalComponents from the public enum. Co-Authored-By: Claude Opus 5 --- .../Algorithms/MultiThresholdObjects.cpp | 156 ++++++---------- .../test/MultiThresholdObjectsTest.cpp | 176 +++++++++++++++--- .../vv/MultiThresholdObjectsFilter.md | 40 ++-- .../deviations/MultiThresholdObjectsFilter.md | 31 +-- 4 files changed, 239 insertions(+), 164 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index 44b23f32a0..1fc6db6ad2 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -6,64 +6,12 @@ #include "simplnx/Utilities/DataStoreUtilities.hpp" #include "simplnx/Utilities/FilterUtilities.hpp" -#include +#include using namespace nx::core; namespace { -bool CheckEquality(float64 a, float64 b) -{ - // Allow tolerance for casting values to floating point precision. - return std::fabs(a - b) < std::numeric_limits::epsilon(); -} - -/** - * @brief The OperatorLess struct replaces std::less for accuracy between floating-point numbers. - * Approximately equal values are not determined less than or greater than the other. - */ -struct OperatorGreater -{ - bool operator()(float64 value1, float64 value2) - { - return (value1 > value2) && !CheckEquality(value1, value2); - } -}; - -/** - * @brief The OperatorLess struct replaces std::less for accuracy between floating-point numbers. - * Approximately equal values are not determined less than or greater than the other. - */ -struct OperatorLess -{ - bool operator()(float64 value1, float64 value2) - { - return (value1 < value2) && !CheckEquality(value1, value2); - } -}; - -/** - * @brief The OperatorEqual struct replaces std::equal_to for accuracy between floating-point numbers. - */ -struct OperatorEqual -{ - bool operator()(float64 value1, float64 value2) - { - return CheckEquality(value1, value2); - } -}; - -/** - * @brief The Operator_Equality struct replaces std::not_equal_to for accuracy between floating-point numbers. - */ -struct OperatorNotEqual -{ - bool operator()(float64 value1, float64 value2) - { - return !CheckEquality(value1, value2); - } -}; - /** * @brief InsertThreshold is used by ThresholdSets to apply their values to the parent collection using the appropriate union operator and * inversion of true/false values. @@ -72,34 +20,26 @@ struct OperatorNotEqual * @param newVector * @param inverse */ -void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, AbstractDataStore& newVector, bool inverse) +void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, const AbstractDataStore& newVector, bool inverse) { usize numItems = currentVector.getNumberOfTuples(); - for(usize i = 0; i < numItems; i++) + // Both the union operator and the inversion flag are the same for every tuple, so branch on the union + // operator once here rather than once per element. Comparing the incoming value against 'inverse' + // flips it when inversion is requested without a branch inside the loop. + if(nx::core::IArrayThreshold::UnionOperator::Or == unionOperator) { - // Store values to avoid repeated access calls - bool currentValue = currentVector.getValue(i); - bool newValue = newVector.getValue(i); - - // Invert the new value if necessary before applying to the parent values. - if(inverse) - { - newValue = !newValue; - } - - // Determine output value - if(nx::core::IArrayThreshold::UnionOperator::Or == unionOperator) + for(usize i = 0; i < numItems; i++) { - currentValue = currentValue || newValue; + currentVector.setValue(i, currentVector.getValue(i) || (newVector.getValue(i) != inverse)); } - else + } + else + { + for(usize i = 0; i < numItems; i++) { - currentValue = currentValue && newValue; + currentVector.setValue(i, currentVector.getValue(i) && (newVector.getValue(i) != inverse)); } - - // Apply updated value - currentVector.setValue(i, currentValue); } } @@ -110,7 +50,7 @@ void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThr * @param inputThresholdStore Resulting output for the target array threshold. * @param replaceInput The first threshold in every set has its output applied to the output regardless of union operator. */ -void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, AbstractDataStore& outputResultStore, AbstractDataStore& inputThresholdStore, bool replaceInput) +void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, AbstractDataStore& outputResultStore, const AbstractDataStore& inputThresholdStore, bool replaceInput) { auto unionOperator = arrayThreshold.getUnionOperator(); bool inverse = arrayThreshold.isInverted(); @@ -142,11 +82,15 @@ class ThresholdFilterHelper void filterDataWithComparision(const AbstractDataStore& inputStore) { usize numTuples = inputStore.getNumberOfTuples(); + // The comparison value is truncated to the input array's type and the comparison is performed in that + // type. This matches legacy DREAM3D (SIMPL ThresholdFilterHelper), where a threshold of 5.5 against an + // int32 array compares against 5, and keeps 64-bit integer comparisons exact. + const T comparisonValue = static_cast(m_ComparisonValue); for(usize tupleIndex = 0; tupleIndex < numTuples; ++tupleIndex) { - auto inputValue = static_cast(inputStore.getComponentValue(tupleIndex, m_ComponentIndex)); + T inputValue = inputStore.getComponentValue(tupleIndex, m_ComponentIndex); bool currentOutputValue = m_Output.getValue(tupleIndex); // This should only be a single component - bool comparison = CompT{}(inputValue, m_ComparisonValue); + bool comparison = CompT{}(inputValue, comparisonValue); if(m_Invert) { comparison = !comparison; @@ -172,19 +116,19 @@ class ThresholdFilterHelper { if(m_ComparisonOperator == ArrayThreshold::ComparisonType::LessThan) { - filterDataWithComparision(input); + filterDataWithComparision, T>(input); } else if(m_ComparisonOperator == ArrayThreshold::ComparisonType::GreaterThan) { - filterDataWithComparision(input); + filterDataWithComparision, T>(input); } else if(m_ComparisonOperator == ArrayThreshold::ComparisonType::Operator_Equal) { - filterDataWithComparision(input); + filterDataWithComparision, T>(input); } else if(m_ComparisonOperator == ArrayThreshold::ComparisonType::Operator_NotEqual) { - filterDataWithComparision(input); + filterDataWithComparision, T>(input); } else { @@ -235,12 +179,25 @@ void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& ExecuteDataFunction(ExecuteThresholdHelper{}, iDataArray.getDataType(), helper, iDataArray); } -void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, AbstractDataStore& outputResultVector, bool replaceInput, const std::atomic_bool& shouldCancel) +/** + * @brief Combines every child of a ThresholdSet into a single boolean result and returns it. + * + * The returned store holds the set's *inner* combination only; the set's own union operator and inversion + * flag are applied by whoever consumes the result, so that the top-level set and nested sets are handled + * identically. One store is allocated per nesting level and released as the recursion unwinds. + * + * @param inputComparisonSet Threshold set whose children should be combined. + * @param dataStructure DataStructure holding the input arrays. + * @param totalTuples Number of tuples in the mask being built. + * @param shouldCancel Cancel flag checked between thresholds. + * @return Store holding the combined result for this set. + */ +std::shared_ptr> ComputeThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, usize totalTuples, const std::atomic_bool& shouldCancel) { - // Get the total number of tuples, create and initialize an array with FALSE to use for these results - size_t totalTuples = outputResultVector.getNumberOfTuples(); - auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); - AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); + // The first threshold in a set is applied with a forced Or against this accumulator, so it must start FALSE. + auto resultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); + AbstractDataStore& resultStore = *resultStorePtr.get(); + resultStore.fill(false); bool firstValueFound = false; @@ -249,24 +206,28 @@ void ThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructu { if(shouldCancel) { - return; + return resultStorePtr; } const IArrayThreshold* thresholdPtr = threshold.get(); if(const auto* comparisonSet = dynamic_cast(thresholdPtr); comparisonSet != nullptr) { - ThresholdSet(*comparisonSet, dataStructure, tempResultStore, !firstValueFound, shouldCancel); + auto childResultStorePtr = ComputeThresholdSet(*comparisonSet, dataStructure, totalTuples, shouldCancel); + if(shouldCancel) + { + return resultStorePtr; + } + ApplyThresholdValues(*comparisonSet, resultStore, *childResultStorePtr.get(), !firstValueFound); firstValueFound = true; } else if(const auto* comparisonValue = dynamic_cast(thresholdPtr); comparisonValue != nullptr) { - ThresholdValue(*comparisonValue, dataStructure, tempResultStore, !firstValueFound); + ThresholdValue(*comparisonValue, dataStructure, resultStore, !firstValueFound); firstValueFound = true; } } - // Apply resulting values to output - ApplyThresholdValues(inputComparisonSet, outputResultVector, tempResultStore, replaceInput); + return resultStorePtr; } struct ThresholdSetFunctor @@ -283,14 +244,15 @@ struct ThresholdSetFunctor // was essentially done in the preflight part. auto& outputDataStore = outputResultArray.template getIDataStoreRefAs>(); usize totalTuples = outputDataStore.getNumberOfTuples(); - auto tempResultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); - AbstractDataStore& tempResultStore = *tempResultStorePtr.get(); - bool replaceInput = true; - ThresholdSet(inputComparisonSet, dataStructure, tempResultStore, replaceInput, shouldCancel); + auto resultStorePtr = ComputeThresholdSet(inputComparisonSet, dataStructure, totalTuples, shouldCancel); + const AbstractDataStore& resultStore = *resultStorePtr.get(); - for(size_t i = 0; i < totalTuples; i++) + // The top-level set's own inversion flag is applied here, mirroring what ApplyThresholdValues does for a + // nested set, while the boolean result is converted to the requested mask type. + const bool inverse = inputComparisonSet.isInverted(); + for(usize i = 0; i < totalTuples; i++) { - outputDataStore.setValue(i, tempResultStore.getValue(i) ? trueValue : falseValue); + outputDataStore.setValue(i, (resultStore.getValue(i) != inverse) ? trueValue : falseValue); } } }; @@ -324,8 +286,6 @@ Result<> MultiThresholdObjects::operator()() float64 falseValue = useCustomFalseValue ? customFalseValue : 0.0; DataPath maskArrayPath = (*thresholdsObject.getRequiredPaths().begin()).replaceName(maskArrayName); - int32_t err = 0; - ArrayThresholdSet::CollectionType thresholdSet = thresholdsObject.getArrayThresholds(); if(m_ShouldCancel) { diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index 7d03ca291f..e6600e85bb 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -35,6 +35,14 @@ constexpr int8 k_MultiComponentCount = 3; constexpr float64 k_FloatValueIncrement = 0.01; +// The "Valid Execution, Mask DataType" test thresholds the float fixture (InputFloatValue(i) == (i + 1) * +// k_FloatValueIncrement) with GreaterThan. Tuple i is true exactly when i >= k_MaskTypeFirstTrueTuple, so the +// comparison value is derived from the split rather than the other way around. +constexpr usize k_MaskTypeFirstTrueTuple = 5; +constexpr float64 k_MaskTypeComparisonValue = k_MaskTypeFirstTrueTuple * k_FloatValueIncrement; +static_assert(k_MaskTypeFirstTrueTuple > 0 && k_MaskTypeFirstTrueTuple < static_cast(k_TupleCount), + "The comparison value must split the fixture so both the false and true branches are exercised"); + constexpr std::array k_ExemplarInt4{0, 0, 0, 0, 0, 1, 1, 1}; constexpr std::array k_ExemplarFloat02{0, 1, 0, 0, 0, 0, 0, 0}; @@ -57,7 +65,7 @@ DataStructure CreateTestDataStructure() { DataStructure dataStructure; // Create two test arrays, a float array and a int array - // Set up geometry for tuples, a cuboid with dimensions 20, 10, 1 + // Set up geometry for tuples, a cuboid with dimensions k_TupleCount, 1, 1 ImageGeom* image = ImageGeom::Create(dataStructure, k_ImageGeometry); std::vector dims = {k_TupleCount, 1, 1}; image->setDimensions(dims); @@ -78,9 +86,9 @@ DataStructure CreateTestDataStructure() usize numComponents = multiComponentData->getNumberOfComponents(); - // Fill the float array with {.01,.02,.03,.04,.05} - // Fill the int array with { 0,1,2,3,4} - // Fill multi-component array with {{0, 0, 0}, {1, -1, 1}, {-2, 2, -2}, {3, -3, 3}, {-4, 4, -4}} + // Fill the float array with {.01,.02,.03,.04,.05,.06,.07,.08} + // Fill the int array with {0,1,2,3,4,5,6,7} + // Fill multi-component array with {{0, 0, 0}, {1, -1, 1}, {-2, 2, -2}, {3, -3, 3}, {-4, 4, -4}, {5, -5, 5}, {-6, 6, -6}, {7, -7, 7}} for(usize i = 0; i < k_TupleCount; i++) { (*data)[i] = InputFloatValue(i); // float array @@ -109,7 +117,7 @@ DataStructure CreateTestDataStructure2() { DataStructure dataStructure; // Create two test arrays, a float array and a int array - // Set up geometry for tuples, a cuboid with dimensions 20, 10, 1 + // Set up geometry for tuples, a cuboid with dimensions k_TupleCount, 1, 1 ImageGeom* image = ImageGeom::Create(dataStructure, k_ImageGeometry); std::vector dims = {k_TupleCount, 1, 1}; image->setDimensions(dims); @@ -217,19 +225,23 @@ bool ExpectedIntSingleComponentMask(ArrayThreshold::ComparisonType comparisonTyp { bool expected = false; + // The filter truncates the comparison value to the input array's type and compares in that type, matching + // legacy DREAM3D. A threshold of 5.5 against an int32 array therefore compares against 5. + const int32 comparisonValue = static_cast(thresholdValue); + switch(comparisonType) { case ArrayThreshold::ComparisonType::GreaterThan: - expected = InputIntValue(i) > thresholdValue; + expected = InputIntValue(i) > comparisonValue; break; case ArrayThreshold::ComparisonType::LessThan: - expected = InputIntValue(i) < thresholdValue; + expected = InputIntValue(i) < comparisonValue; break; case ArrayThreshold::ComparisonType::Operator_Equal: - expected = InputIntValue(i) == thresholdValue; + expected = InputIntValue(i) == comparisonValue; break; case ArrayThreshold::ComparisonType::Operator_NotEqual: - expected = InputIntValue(i) != thresholdValue; + expected = InputIntValue(i) != comparisonValue; break; } @@ -258,19 +270,25 @@ bool ExpectedFloatSingleComponentMask(ArrayThreshold::ComparisonType comparisonT { bool expected = false; + // The target array is a Float32Array and the filter truncates the comparison value to the array's type + // before comparing, so the oracle rounds both operands through float32 the same way. Comparing the raw + // float64 values instead would diverge on exact-equality thresholds such as 0.03. + const float32 inputValue = static_cast(InputFloatValue(i)); + const float32 comparisonValue = static_cast(thresholdValue); + switch(comparisonType) { case ArrayThreshold::ComparisonType::GreaterThan: - expected = InputFloatValue(i) > thresholdValue; + expected = inputValue > comparisonValue; break; case ArrayThreshold::ComparisonType::LessThan: - expected = InputFloatValue(i) < thresholdValue; + expected = inputValue < comparisonValue; break; case ArrayThreshold::ComparisonType::Operator_Equal: - expected = InputFloatValue(i) == thresholdValue; + expected = inputValue == comparisonValue; break; case ArrayThreshold::ComparisonType::Operator_NotEqual: - expected = InputFloatValue(i) != thresholdValue; + expected = inputValue != comparisonValue; break; } @@ -299,19 +317,22 @@ bool ExpectedIntMultiComponentMask(ArrayThreshold::ComparisonType comparisonType { bool expected = false; + // Same comparison-value truncation as the single-component int oracle above. + const int32 comparisonValue = static_cast(thresholdValue); + switch(comparisonType) { case ArrayThreshold::ComparisonType::GreaterThan: - expected = InputIntComponentValue(i, componentIndex) > thresholdValue; + expected = InputIntComponentValue(i, componentIndex) > comparisonValue; break; case ArrayThreshold::ComparisonType::LessThan: - expected = InputIntComponentValue(i, componentIndex) < thresholdValue; + expected = InputIntComponentValue(i, componentIndex) < comparisonValue; break; case ArrayThreshold::ComparisonType::Operator_Equal: - expected = InputIntComponentValue(i, componentIndex) == thresholdValue; + expected = InputIntComponentValue(i, componentIndex) == comparisonValue; break; case ArrayThreshold::ComparisonType::Operator_NotEqual: - expected = InputIntComponentValue(i, componentIndex) != thresholdValue; + expected = InputIntComponentValue(i, componentIndex) != comparisonValue; break; } @@ -422,12 +443,10 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int", "[ { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); - CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, !isInverted); } SECTION("ArrayThreshold: !=") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); - CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, !isInverted); CheckIntTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); } @@ -459,12 +478,10 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Float", { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted); - CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, !isInverted); } SECTION("ArrayThreshold: !=") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); - CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, !isInverted); CheckFloatTestDataSingleComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted); } @@ -497,12 +514,10 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Single Thresholds: Int Mult { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted, componentIndex); CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, isInverted, componentIndex); - CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, !isInverted, componentIndex); } SECTION("ArrayThreshold: !=") { RunSingleThresholdTest(dataStructure, targetArray, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted, componentIndex); - CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_Equal, thresholdValue, !isInverted, componentIndex); CheckIntTestDataMultiComponent(dataStructure, ArrayThreshold::ComparisonType::Operator_NotEqual, thresholdValue, isInverted, componentIndex); } @@ -631,6 +646,47 @@ ArrayThresholdSet CreateThresholdSet5() return thresholdSet; } +/** + * @brief Creates a ThresholdSet whose children mix a leaf ArrayThreshold with a sibling nested ArrayThresholdSet. + * + * This is the shape that triggered MultiThresholdObjectsFilter-D1, where the mask came back all-false + * regardless of input. Every other CreateThresholdSet* helper passes either all leaves or all nested sets, so + * this shape had no in-repo coverage. + */ +ArrayThresholdSet CreateThresholdSet6() +{ + ArrayThresholdSet thresholdSet; + + // Threshold: Int > 2 + auto leafThreshold = CreateArrayThreshold(k_TestArrayIntPath, ArrayThreshold::ComparisonType::GreaterThan, 2.0, false, 0, ArrayThreshold::UnionOperator::And); + auto nestedSet = std::make_shared(CreateThresholdSet2()); + + thresholdSet.setArrayThresholds({leafThreshold, nestedSet}); + + return thresholdSet; +} + +/** + * @brief Creates a ThresholdSet mixing a leaf ArrayThreshold with a sibling *inverted* nested ArrayThresholdSet. + * + * This is the shape that triggered MultiThresholdObjectsFilter-D2, where inversion of a nested set reversed + * the tuple order instead of flipping each tuple's value. + */ +ArrayThresholdSet CreateThresholdSet7() +{ + ArrayThresholdSet thresholdSet; + + // Threshold: Int > 2 + auto leafThreshold = CreateArrayThreshold(k_TestArrayIntPath, ArrayThreshold::ComparisonType::GreaterThan, 2.0, false, 0, ArrayThreshold::UnionOperator::And); + auto nestedSet = std::make_shared(CreateThresholdSet2()); + nestedSet->setUnionOperator(ArrayThreshold::UnionOperator::Or); + nestedSet->setInverted(true); + + thresholdSet.setArrayThresholds({leafThreshold, nestedSet}); + + return thresholdSet; +} + void CheckThresholdSet1(DataStructure& dataStructure, bool inverted) { const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); @@ -723,6 +779,53 @@ void CheckThresholdSet5(DataStructure& dataStructure, bool inverted) } } +void CheckThresholdSet6(DataStructure& dataStructure, bool inverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + // The leaf is the first child, so it seeds the set's accumulator; the nested set then combines with its + // own And union operator. + bool expectedLeaf = ExpectedIntSingleComponentMask(ArrayThreshold::ComparisonType::GreaterThan, i, 2.0, false); + bool expectedNested = ExpectedThresholdSet2Mask(i, false); + + bool expected = expectedLeaf && expectedNested; + if(inverted) + { + expected = !expected; + } + + REQUIRE(thresholdStore[i] == expected); + } +} + +void CheckThresholdSet7(DataStructure& dataStructure, bool inverted) +{ + const auto* thresholdArrayPtr = dataStructure.getDataAs(k_ThresholdArrayPath); + REQUIRE(thresholdArrayPtr != nullptr); + + auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); + + for(usize i = 0; i < k_TupleCount; i++) + { + bool expectedLeaf = ExpectedIntSingleComponentMask(ArrayThreshold::ComparisonType::GreaterThan, i, 2.0, false); + // The nested set is inverted, which must flip each tuple's value rather than reverse the tuple order. + bool expectedNested = ExpectedThresholdSet2Mask(i, true); + + bool expected = expectedLeaf || expectedNested; + if(inverted) + { + expected = !expected; + } + + REQUIRE(thresholdStore[i] == expected); + } +} + TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Threshold Sets", "[SimplnxCore][MultiThresholdObjectsFilter]") { UnitTest::LoadPlugins(); @@ -770,6 +873,26 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Threshold Sets", "[SimplnxC CheckThresholdSet5(dataStructure, isInverted); } + // Regression coverage for MultiThresholdObjectsFilter-D1: a set mixing a leaf threshold with a sibling + // nested set produced an all-false mask regardless of input. + SECTION("ArraySet 6: leaf + nested set") + { + auto thresholdSet = CreateThresholdSet6(); + thresholdSet.setInverted(isInverted); + RunThresholdSetTest(dataStructure, thresholdSet); + CheckThresholdSet6(dataStructure, isInverted); + } + + // Regression coverage for MultiThresholdObjectsFilter-D2: an inverted nested set reversed the tuple order + // instead of flipping each tuple's value. + SECTION("ArraySet 7: leaf + inverted nested set") + { + auto thresholdSet = CreateThresholdSet7(); + thresholdSet.setInverted(isInverted); + RunThresholdSetTest(dataStructure, thresholdSet); + CheckThresholdSet7(dataStructure, isInverted); + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); } @@ -954,10 +1077,11 @@ void checkMaskValues(const DataStructure& dataStructure, const DataPath& thresho auto& thresholdStore = thresholdArrayPtr->getDataStoreRef(); - // For the comparison value of 0.1, the threshold array elements 0 to 9 should be false and 10 through 19 should be true + // Tuples below k_MaskTypeFirstTrueTuple are false and the rest are true. The split is taken from the + // fixture constants so that changing k_TupleCount cannot silently make one of the two branches unreachable. for(usize i = 0; i < k_TupleCount; i++) { - if(i < 5) + if(i < k_MaskTypeFirstTrueTuple) { REQUIRE(thresholdStore[i] == static_cast(0)); } @@ -996,7 +1120,7 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution, Mask DataType", auto threshold = std::make_shared(); threshold->setArrayPath(k_TestArrayFloatPath); threshold->setComparisonType(ArrayThreshold::ComparisonType::GreaterThan); - threshold->setComparisonValue(0.05); + threshold->setComparisonValue(k_MaskTypeComparisonValue); thresholdSet.setArrayThresholds({threshold}); args.insertOrAssign(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key, std::make_any(thresholdSet)); diff --git a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md index 27bf6a15ac..0edecacac9 100644 --- a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md @@ -15,18 +15,18 @@ |------------------------|------------------------------------------------------------------------------------------------------------------------------| | Algorithm Relationship | **Rewrite.** Consolidates two independently-shipped legacy filters — **Threshold Objects** (flat, AND-only) and **Threshold Objects (Advanced)** (nested AND/OR sets) — into one SIMPLNX filter under one new UUID, unified around a single `ArrayThresholdSet` model. Not a line-by-line translation of either legacy source. | | Oracle (confirmed) | **Class 1 (Analytical) — confirmed.** `expected[i] = COMPARISON(input[i], value)`, hand-combined via AND/OR/invert boolean algebra. Encoded across 13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations in `MultiThresholdObjectsTest.cpp`, all pass. | -| Code paths enumerated | **24 of 26 exercised.** Row 13 (unreachable comparison-operator `else`-throw) is a permanent, acceptable gap. Row 25 (a set mixing a leaf threshold with a nested set — the `MultiThresholdObjectsFilter-D1` trigger shape) has no in-repo regression test yet. | +| Code paths enumerated | **25 of 26 exercised.** Row 13 (unreachable comparison-operator `else`-throw) is a permanent, acceptable gap and the only one remaining. Row 25 (a set mixing a leaf threshold with a nested set — the `MultiThresholdObjectsFilter-D1` trigger shape) is now covered by the `ArraySet 6` / `ArraySet 7` `SECTION`s of `Valid Threshold Sets`. | | Tests today | **13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 30 ctest entries** (11 single-entry TEST_CASEs + 2 `TEMPLATE_TEST_CASE`s instantiated over 9 and 10 types respectively). Exhaustive sweeps over comparison operator × invert × union operator × set nesting × mask `DataType` (10 types, plus boolean covered elsewhere) × source-array `DataType` (11 types) × custom TRUE/FALSE execution (10 types), plus negative/error-path groups and a SIMPL backwards-compatibility check. All fixtures built in-memory. | | Exemplar archive | **None.** All fixtures are constructed in-memory by `CreateTestDataStructure()` / `CreateTestDataStructure2()`; no `.dream3d` exemplar or `download_test_data()` entry exists for this filter. | | Legacy comparison | **Run.** Independent three-way A/B (DREAM3D 6.5.171 `PipelineRunner` vs. this branch's `nxrunner` vs. an independent numpy oracle) on a shared 100-tuple fixture, covering flat/basic (`MultiThresholdObjects`), nested, and inverted-nested (`MultiThresholdObjects2`) configurations, plus a 50M-tuple scale re-run of all three. Post-fix: all three MATCH across all cases at both scales. Pre-fix (`develop`): 2 of 3 configs diverged (38/100 and 51/100 tuples wrong) — see `MultiThresholdObjectsFilter-D1`/`-D2`. | -| Bug flags | **Three, all fixed by this PR.** `MultiThresholdObjectsFilter-D1` — a set combining a leaf threshold with a sibling nested set produced an all-false mask (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`) on `develop`; quantified against real legacy output. `MultiThresholdObjectsFilter-D2` — an inverted nested set used `std::reverse` to flip tuple *order* instead of each tuple's value (51/100 tuples wrong vs. the same legacy filter) on `develop`; quantified against real legacy output. `MultiThresholdObjectsFilter-D4` — `develop` applied raw `std::less`/`std::greater`/`std::equal_to`/`std::not_equal_to` directly to floating-point operands, unsafe near/at threshold boundaries; not directly exercised by `AB1`–`AB3`, so not independently quantified against legacy. See `vv/deviations/MultiThresholdObjectsFilter.md`. | -| V&V phase | Oracle chosen and applied (Class 1, corroborated by an independent numpy oracle in the legacy A/B), code paths enumerated (24/26 — row 25 exposes the D1 trigger shape), legacy A/B run and MATCH at both 100-tuple and 50M-tuple scale, 4 deviations documented (`D1`/`D2`/`D4` bugs fixed by this PR, `D3` confirmed non-bug capability difference). **Outstanding:** a regression test for the D1 trigger shape (no existing fixture uses it — see Code path coverage row 25), a near-boundary A/B fixture to confirm D4 against legacy's own comparison precision, second-engineer oracle review, custom TRUE/FALSE-value and default-mask-type comparison against legacy (not covered by AB1–AB3). | +| Bug flags | **Two, both fixed by this PR, both quantified against real legacy output.** `MultiThresholdObjectsFilter-D1` — a set combining a leaf threshold with a sibling nested set produced an all-false mask (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`) on `develop`. `MultiThresholdObjectsFilter-D2` — an inverted nested set used `std::reverse` to flip tuple *order* instead of each tuple's value (51/100 tuples wrong vs. the same legacy filter) on `develop`. Both now have in-repo regression coverage (`Valid Threshold Sets` → `ArraySet 6` / `ArraySet 7`). See `vv/deviations/MultiThresholdObjectsFilter.md`. | +| V&V phase | Oracle chosen and applied (Class 1, corroborated by an independent numpy oracle in the legacy A/B), code paths enumerated (25/26), legacy A/B run and MATCH at both 100-tuple and 50M-tuple scale, 3 deviations documented (`D1`/`D2` bugs fixed by this PR with in-repo regression tests, `D3` confirmed non-bug capability difference). **Outstanding:** second-engineer oracle review, custom TRUE/FALSE-value and default-mask-type comparison against legacy (not covered by AB1–AB3). | For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrientationCheckFilter.md` and `src/Plugins/OrientationAnalysis/vv/ComputeAvgCAxesFilter.md` (on `topic/vv/compute_avg_caxis`). ## Summary -`MultiThresholdObjectsFilter` builds a typed mask array by elementwise-comparing one or more input arrays against user-supplied thresholds, combined through an arbitrarily-nested tree of AND/OR/invert `ArrayThresholdSet`s. Verification uses a **Class 1 (Analytical) oracle**: every comparison operator, invert flag, union operator, nesting depth, custom TRUE/FALSE execution, and both the mask-output and source-input `DataType` are exhaustively hand-derived and asserted in `MultiThresholdObjectsTest.cpp` (13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations, all passing). 24 of 26 algorithm/preflight code paths are exercised. An independent three-way runtime A/B (legacy DREAM3D 6.5.171, this branch, and a numpy oracle) against both legacy predecessors — at 100 tuples and again at 50M tuples — confirms the current implementation matches legacy exactly, and quantifies two real bugs that were present on `develop` and are fixed by this PR: `MultiThresholdObjectsFilter-D1` (all-false mask when a set mixes a leaf threshold with a nested set, 38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (`std::reverse`-based tuple-order corruption in an inverted nested set instead of per-value inversion, 51/100 tuples wrong). This PR also fixes a third bug, `MultiThresholdObjectsFilter-D4`: `develop` applied raw (non-tolerant) floating-point comparison operators, which could misclassify input values very close to or exactly at a threshold — not directly exercised by the A/B fixtures, so not independently quantified against legacy the way D1/D2 are. A fourth, non-bug deviation (`MultiThresholdObjectsFilter-D3`) documents that multi-component index selection is SIMPLNX-only — legacy `Threshold Objects (Advanced)` rejects non-scalar arrays outright. None of D1, D2, or D4 has a regression test in the repo yet. +`MultiThresholdObjectsFilter` builds a typed mask array by elementwise-comparing one or more input arrays against user-supplied thresholds, combined through an arbitrarily-nested tree of AND/OR/invert `ArrayThresholdSet`s. Verification uses a **Class 1 (Analytical) oracle**: every comparison operator, invert flag, union operator, nesting depth, custom TRUE/FALSE execution, and both the mask-output and source-input `DataType` are exhaustively hand-derived and asserted in `MultiThresholdObjectsTest.cpp` (13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations, all passing). 25 of 26 algorithm/preflight code paths are exercised. An independent three-way runtime A/B (legacy DREAM3D 6.5.171, this branch, and a numpy oracle) against both legacy predecessors — at 100 tuples and again at 50M tuples — confirms the current implementation matches legacy exactly, and quantifies two real bugs that were present on `develop` and are fixed by this PR: `MultiThresholdObjectsFilter-D1` (all-false mask when a set mixes a leaf threshold with a nested set, 38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (`std::reverse`-based tuple-order corruption in an inverted nested set instead of per-value inversion, 51/100 tuples wrong). Both now have dedicated in-repo regression coverage (`Valid Threshold Sets` → `ArraySet 6` / `ArraySet 7`), each verified to fail when the algorithm is stubbed out. A third, non-bug deviation (`MultiThresholdObjectsFilter-D3`) documents that multi-component index selection is SIMPLNX-only — legacy `Threshold Objects (Advanced)` rejects non-scalar arrays outright. ## Algorithm Relationship @@ -53,7 +53,7 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie - **#1582** — "ENH: Add missing cancel checks to lots of filters" (`1a42ec6fb`) — cross-cutting PR; added `m_ShouldCancel` checks to many filters including this one (visible today in `MultiThresholdObjects::operator()` and `ThresholdSet`'s per-item cancel check). No output-behavior change on a non-cancelled run. - **#1605** — "BUG: Fix SIMPL JSON conversion segfault and re-enable backwards-compatibility checks" (`996d7af5a`) — fixed a crash in `FromSIMPLJson()` and re-enabled the SIMPL 6.4/6.5 backwards-compatibility test for this filter (now `SIMPL Backwards Compatibility` in the test file). Affects pipeline-conversion correctness, not execution output. -- This PR itself also rewrote the comparison/combination internals, fixing three bugs — `MultiThresholdObjectsFilter-D1`, `-D2`, and `-D4` — see Deviations file. `D4` in particular replaced raw `std::less`/`std::greater`/`std::equal_to`/`std::not_equal_to` (unsafe for floating-point precision near/at threshold boundaries) with epsilon-tolerant `OperatorLess`/`OperatorGreater`/`OperatorEqual`/`OperatorNotEqual` operators. +- This PR itself also rewrote the comparison/combination internals, fixing two bugs — `MultiThresholdObjectsFilter-D1` and `-D2` — see Deviations file. The elementwise comparison itself is unchanged from legacy: `ThresholdFilterHelper::filterDataWithComparision` truncates the comparison value to the input array's type and compares with `std::less`/`std::greater`/`std::equal_to`/`std::not_equal_to` in that type, exactly as `SIMPL/Source/SIMPLib/Filtering/ThresholdFilterHelper.h:60` does (see Oracle → *Comparison-value truncation*). ## Oracle @@ -61,11 +61,13 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie *Applied:* For a single threshold, `expected[i] = COMPARISON(input[i], value)` (optionally inverted); for a component-indexed array, `input[i]` is replaced by `input[i][componentIndex]`. For a threshold set, `expected` is the boolean combination of each member's own `expected` value: the first member always seeds the accumulator, and the configured `UnionOperator` (AND/OR) combines each subsequent member; the whole set's `expected` is inverted again if the set itself is marked inverted. Every free variable in this formula — comparison operator, invert flag, union operator, set nesting, component index, mask output `DataType`, source-array `DataType`, and custom TRUE/FALSE execution values — is enumerated directly against this closed-form definition in the test file's `Expected*Mask` helper functions and inline hardcoded exemplar arrays, independent of the algorithm's own C++ control flow (`ThresholdFilterHelper`, `InsertThreshold`, `ApplyThresholdValues`). +*Comparison-value truncation (verified, legacy-matching):* the user-supplied comparison value is a `float64`, but `ThresholdFilterHelper::filterDataWithComparision` (`Algorithms/MultiThresholdObjects.cpp`) truncates it to the input array's type and performs the comparison in that type. A threshold of `5.5` against an `int32` array therefore compares against `5`, so `5 == 5.5` is **true** for this filter. This matches legacy DREAM3D exactly — `SIMPL/Source/SIMPLib/Filtering/ThresholdFilterHelper.h:60` performs the identical `static_cast(m_ComparisonValue)` — and it keeps 64-bit integer comparisons exact, which comparing in `float64` would not (values above 2^53 differing by 1 would collapse to equal). The `Expected*Mask` oracle helpers model the same truncation (`static_cast(thresholdValue)` for the int fixtures, `static_cast(...)` on both operands for the `Float32Array` fixture) so the oracle and the implementation agree by construction rather than by coincidence of fixture size. `Valid Single Thresholds: Int` deliberately `GENERATE`s a fractional threshold (`5.5`) against integer data to keep this behavior asserted. + *Encoded:* `test/MultiThresholdObjectsTest.cpp` — - `Exemplar Single Thresholds: Int` / `: Float` — single fixed-threshold fixtures checked against hardcoded `k_ExemplarInt4` / `k_ExemplarFloat02` arrays - `Valid Single Thresholds: Int` / `: Float` / `: Int Multi-Component` — comparison operator × invert × (component index for multi-component) sweep, via `ExpectedIntSingleComponentMask` / `ExpectedFloatSingleComponentMask` / `ExpectedIntMultiComponentMask` -- `Valid Threshold Sets` — 5 hand-built AND / OR / nested-set / nested-set-with-OR / nested-set-with-OR+invert configurations (`CreateThresholdSet1`–`5`), via `ExpectedThresholdSet1Mask`–`5` +- `Valid Threshold Sets` — 7 hand-built configurations: AND / OR / nested-set / nested-set-with-OR / nested-set-with-OR+invert (`CreateThresholdSet1`–`5`, via `ExpectedThresholdSet1Mask`–`5`), plus the two mixed leaf-and-nested-set regression shapes `CreateThresholdSet6`/`7` (via `CheckThresholdSet6`/`7`) - `Valid Execution, Mask DataType` — 10 mask-output `DataType`s - `Valid Execution, Input Array DataType` — 11 source-array `DataType`s - `Valid Execution - Custom Values` — 10 mask `DataType`s, custom TRUE/FALSE values applied at execution time (not just preflight bounds-checked) @@ -78,9 +80,9 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie ## Code path coverage -**24 of 26 paths exercised.** Row 13 is a permanent, acceptable gap; row 25 is a real gap that let `MultiThresholdObjectsFilter-D1` ship — see `vv/deviations/MultiThresholdObjectsFilter.md`. +**25 of 26 paths exercised.** Row 13 is a permanent, acceptable gap and the only one remaining. Row 25 was the gap that let `MultiThresholdObjectsFilter-D1` ship; it is now covered — see `vv/deviations/MultiThresholdObjectsFilter.md`. -Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp` (~340 lines), plus 7 preflight-only paths in `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp`. +Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp` (~300 lines), plus 7 preflight-only paths in `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp`. Two logical stages: **(a) preflight** validates the threshold set / mask-type / custom-value configuration and stages the output `CreateArrayAction`; **(b) algorithm** recursively evaluates the `ArrayThresholdSet` tree into an internal `bool` mask (per-array comparison combined inline via `ThresholdFilterHelper`'s per-tuple AND/OR switch, then `InsertThreshold`/`ApplyThresholdValues` for cross-node combination) and writes the result into the typed mask array via `ThresholdSetFunctor`, substituting `trueValue`/`falseValue` at that final write. @@ -110,12 +112,12 @@ Two logical stages: **(a) preflight** validates the threshold set / mask-type / | 22 | (b) Algorithm | `ThresholdSetFunctor` dispatch on **mask (output) DataType** | `Valid Execution, Mask DataType` — int8/16/32/64 + uint8/16/32/64 + float32/64 (10 types; no `boolean` `SECTION` in this test — boolean is exercised separately via the default mask type used throughout `RunThresholdSetTest`/other `TEST_CASE`s) | | 23 | (b) Algorithm | `ExecuteThresholdHelper` dispatch on **source array's DataType** | `Valid Execution, Input Array DataType` — int8/16/32/64 + uint8/16/32/64 + float32/64 + boolean, all 11 types | | 24 | (b) Algorithm | Multi-component `componentIndex != 0` selection | `Valid Single Thresholds: Int Multi-Component` (`componentIndex = GENERATE(0,1,2)`), plus `componentIndex=1` in Set1, `=0` in Set2 | -| 25 | (b) Algorithm | An `ArrayThresholdSet` whose children mix at least one leaf `ArrayThreshold` with at least one nested `ArrayThresholdSet` (e.g. `{leaf, nestedSet}`, not `{leaf, leaf, leaf}` or `{set, set}`). Historically produced an all-false mask regardless of input (`MultiThresholdObjectsFilter-D1`, confirmed against legacy `Threshold Objects (Advanced)` — 38/100 tuples wrong on `develop`), fixed by this PR. | *Not directly tested by the in-repo `TEST_CASE` suite. No existing fixture uses this exact shape — every `CreateThresholdSet*` helper passes either all leaves or all nested sets to `setArrayThresholds()`, never a mix. Confirmed by the external `AB2` legacy A/B fixture (see Deviations file), which is not part of the ctest suite. This gap is what let D1 ship; a dedicated in-repo regression fixture is recommended before status promotion.* | +| 25 | (b) Algorithm | An `ArrayThresholdSet` whose children mix at least one leaf `ArrayThreshold` with at least one nested `ArrayThresholdSet` (e.g. `{leaf, nestedSet}`, not `{leaf, leaf, leaf}` or `{set, set}`). Historically produced an all-false mask regardless of input (`MultiThresholdObjectsFilter-D1`, confirmed against legacy `Threshold Objects (Advanced)` — 38/100 tuples wrong on `develop`), fixed by this PR. | `Valid Threshold Sets` → `ArraySet 6: leaf + nested set` (`CreateThresholdSet6`, a leaf `Int > 2` sibling to a nested set) and `ArraySet 7: leaf + inverted nested set` (`CreateThresholdSet7`, the `AB3` shape), each × `isInverted`. Both were confirmed to fail when `MultiThresholdObjects::operator()()` is stubbed to `return {}` — i.e. they detect the all-false mask that D1 produced. Also confirmed externally by the `AB2`/`AB3` legacy A/B fixtures (see Deviations file), which are not part of the ctest suite. | | 26 | (b) Algorithm | Custom TRUE/FALSE value substitution at execution time (`ThresholdSetFunctor` writing `trueValue`/`falseValue` from `MultiThresholdObjects::operator()`, not the default `1.0`/`0.0`) — distinct from rows 6–7, which only cover the preflight bounds-check rejecting *out-of-range* custom values and never actually execute with valid ones | `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`, 10 numeric mask types), asserting `trueValue`/`falseValue` (25/10 in the test) appear in the output instead of 1/0 | Not counted as an algorithm/preflight path: the "Empty ArrayThreshold DataPath" section of `Invalid Execution` exercises `ArrayThresholdsParameter`'s own path-existence validation, which runs before `preflightImpl` is called — it's a parameter-layer gate, not code inside this filter or algorithm. -`MultiThresholdObjectsFilter-D2` (the pre-fix `std::reverse` tuple-order bug) does not get its own row: the buggy code path no longer exists on this branch. The legacy A/B's `AB3` fixture (a leaf combined with an inverted nested set — see Deviations file) is the confirmed trigger; it overlaps with row 25's mixed-sibling shape rather than isolating D2 cleanly on its own. `Valid Threshold Sets`' `isInverted = GENERATE(false, true)` sweep exercises top-level-inverted sets today, but doesn't cover AB3's specific mixed-sibling-plus-inverted-nested-child shape. +`MultiThresholdObjectsFilter-D2` (the pre-fix `std::reverse` tuple-order bug) does not get its own row: the buggy code path no longer exists on this branch, and its trigger — a leaf combined with an *inverted* nested set (the legacy A/B's `AB3` shape) — overlaps with row 25's mixed-sibling shape rather than isolating D2 cleanly on its own. It is covered in-repo by the `ArraySet 7` `SECTION` listed against row 25. ## Test inventory @@ -126,7 +128,7 @@ Not counted as an algorithm/preflight path: the "Empty ArrayThreshold DataPath" | `Valid Single Thresholds: Int` | kept | `GENERATE` over 8 threshold values × 2 invert states, 4 `SECTION`s (`>`, `<`, `==`, `!=`) against `k_TestArrayIntPath`; every tuple checked via `ExpectedIntSingleComponentMask`. | | `Valid Single Thresholds: Float` | kept | Same sweep against `k_TestArrayFloatPath` via `ExpectedFloatSingleComponentMask`. | | `Valid Single Thresholds: Int Multi-Component` | kept | Adds `componentIndex = GENERATE(0,1,2)` against `k_MultiComponentArrayPath`. | -| `Valid Threshold Sets` | kept | 5 `SECTION`s (`ArraySet 1`–`5`) covering AND, OR, nested-set, nested-set-with-OR, and nested-set-with-OR+invert combinations, each × `isInverted`. | +| `Valid Threshold Sets` | kept, extended for V&V | 7 `SECTION`s, each × `isInverted`. `ArraySet 1`–`5` cover AND, OR, nested-set, nested-set-with-OR, and nested-set-with-OR+invert combinations. `ArraySet 6` / `ArraySet 7` are **new for V&V**: a leaf threshold with a sibling nested set, and the same with the nested set inverted — the `MultiThresholdObjectsFilter-D1` / `-D2` trigger shapes (code-path row 25). | | `Invalid Execution` | kept | 4 `SECTION`s: empty threshold set (`-4000`), empty threshold `DataPath` (parameter-layer validation), out-of-bounds component index (`InvalidComponentIndex`), mismatched tuple counts (`UnequalTuples`). | | `Invalid Execution - Out of Bounds Custom Values` (`TEMPLATE_TEST_CASE`) | kept | 9 numeric-type instantiations × 4 `SECTION`s (true/false value below minimum / above maximum) — `CustomTrueOutOfBounds` / `CustomFalseOutOfBounds`. | | `Invalid Execution - Boolean Custom Values` | kept | 2 `SECTION`s — custom TRUE/FALSE value rejected when mask type is `boolean`. | @@ -135,9 +137,9 @@ Not counted as an algorithm/preflight path: the "Empty ArrayThreshold DataPath" | `SIMPL Backwards Compatibility` | restored | `DYNAMIC_SECTION` over the SIMPL 6.4 and 6.5 conversion fixtures; asserts pipeline conversion round-trips (UUID + one argument value). Re-enabled by `#1605` after a prior segfault. | | `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`) | restored | 10 numeric-type instantiations; asserts custom TRUE (`25`) / FALSE (`10`) values are actually written to the output mask at execution time — closes the code-path gap on row 26. | -**Missing:** no test case exercises a set mixing a leaf threshold with a sibling nested set (row 25) — the shape that triggered `MultiThresholdObjectsFilter-D1`. Recommended before status promotion: add a `SECTION` to `Valid Threshold Sets` (or a new `TEST_CASE`) covering this shape, so a regression can't reintroduce D1 silently. +**Missing:** nothing outstanding. The last gap — a set mixing a leaf threshold with a sibling nested set (row 25), the shape that triggered `MultiThresholdObjectsFilter-D1` — is closed by the `ArraySet 6` / `ArraySet 7` `SECTION`s of `Valid Threshold Sets`. -**Count basis:** 30 ctest entries = 11 single-entry `TEST_CASE`s (1 ctest entry each) + `Invalid Execution - Out of Bounds Custom Values` (`TEMPLATE_TEST_CASE`, 9 types → 9 entries) + `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`, 10 types → 10 entries), verified by directly reading each declaration's type list twice. `catch_discover_tests` is called with no filtering options in `cmake/Plugin.cmake:404`, so Catch2's default behavior applies: one ctest entry per `TEMPLATE_TEST_CASE` type instantiation. A previously-cited count of 28 entries doesn't match this direct enumeration; the most likely explanation is that count was taken from a `ctest -N` run before `SIMPL Backwards Compatibility` and/or `Valid Execution - Custom Values` were both present in the build (each restores test that had been temporarily disabled). 30 is the number that matches the test file as it stands today — re-run `ctest -N -R MultiThresholdObjects` against a fresh build to confirm. +**Count basis:** 30 ctest entries = 11 single-entry `TEST_CASE`s (1 ctest entry each) + `Invalid Execution - Out of Bounds Custom Values` (`TEMPLATE_TEST_CASE`, 9 types → 9 entries) + `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`, 10 types → 10 entries), verified by directly reading each declaration's type list twice. `catch_discover_tests` is called with no filtering options in `cmake/Plugin.cmake:404`, so Catch2's default behavior applies: one ctest entry per `TEMPLATE_TEST_CASE` type instantiation. A count of 28 entries cited during review was correct at the commit it was measured against; the two `Exemplar Single Thresholds` `TEST_CASE`s added afterwards account for the difference. 30 matches the test file as it stands today and has been confirmed by `ctest -R MultiThresholdObjects` against a fresh in-core Release build (30/30 pass). ## Exemplar archive @@ -147,7 +149,13 @@ None. All fixtures for this filter are constructed in-memory in `test/MultiThres Legacy comparison **run**: independent three-way A/B (DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and a numpy oracle) on flat, nested, and inverted-nested configurations at 100 tuples and again at 50M tuples. Post-fix, all three sources MATCH in every case. Full record in `vv/deviations/MultiThresholdObjectsFilter.md`. -- `MultiThresholdObjectsFilter-D1` — a set mixing a leaf threshold with a sibling nested set produced an all-false mask on `develop` (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`). **Fixed by this PR.** -- `MultiThresholdObjectsFilter-D2` — a leaf combined with an inverted nested set used `std::reverse` to flip tuple order instead of flipping each tuple's value on `develop` (51/100 tuples wrong vs. the same legacy filter). **Fixed by this PR.** +- `MultiThresholdObjectsFilter-D1` — a set mixing a leaf threshold with a sibling nested set produced an all-false mask on `develop` (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`). **Fixed by this PR**, with in-repo regression coverage (`Valid Threshold Sets` → `ArraySet 6`). +- `MultiThresholdObjectsFilter-D2` — a leaf combined with an inverted nested set used `std::reverse` to flip tuple order instead of flipping each tuple's value on `develop` (51/100 tuples wrong vs. the same legacy filter). **Fixed by this PR**, with in-repo regression coverage (`Valid Threshold Sets` → `ArraySet 7`). - `MultiThresholdObjectsFilter-D3` — multi-component index selection is SIMPLNX-only; legacy `Threshold Objects (Advanced)` rejects non-scalar arrays (`dataCheck()` error `-11003`). Not a bug — a deliberate SIMPLNX capability addition, documented for migration guidance. -- `MultiThresholdObjectsFilter-D4` — `develop` used raw, non-tolerant floating-point comparison operators, which could misclassify input values very close to or exactly at a threshold. **Fixed by this PR.** Not exercised by `AB1`–`AB3` (their threshold values aren't near any input value), so not independently quantified against legacy — a dedicated near-boundary A/B fixture is still needed. + +## Preflight behavior change in this PR + +This PR removes the preflight check that required every thresholded array to have the same component count, together with its error code `ErrorCodes::UnequalComponents` (`-4001`) in `MultiThresholdObjectsFilter.hpp`. Two consequences worth recording: + +- **Relaxed validation.** A threshold set may now mix arrays with different component counts; each threshold selects its own `componentIndex`, and only the *tuple* counts must agree (still enforced via `ErrorCodes::UnequalTuples`). Pipelines that previously failed preflight with `-4001` will now preflight and execute. This is a deliberate widening of what the filter accepts, not a silent behavior change to existing valid pipelines. +- **Public enum change.** `ErrorCodes` is a public enum in the filter's header, so removing the enumerator is a source-level API break for any downstream plugin that references `MultiThresholdObjectsFilter::ErrorCodes::UnequalComponents`. Nothing in this repository does — verified by grep across `src/` and the plugin tree — but out-of-tree plugins would need to drop the reference. diff --git a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md index e80030b7a9..173c34da15 100644 --- a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md @@ -10,9 +10,9 @@ Entries are referenced by stable ID (`MultiThresholdObjectsFilter-D`) from th ## Headline -**4 deviations documented: 3 bugs (all in SIMPLNX, all fixed by this PR), 1 confirmed non-bug capability difference.** Legacy comparison has been **run**: an independent three-way A/B — DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and an independent numpy oracle — on a shared 100-tuple fixture, covering representative flat (`Threshold Objects`), nested, and inverted-nested (`Threshold Objects (Advanced)`) configurations, re-run again at 50M tuples. Post-fix, all three sources MATCH in every case at both scales. The in-repo `MultiThresholdObjectsTest.cpp` suite (13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 30 ctest entries — see the V&V report's Test inventory for the count basis) also passes locally. +**3 deviations documented: 2 bugs (both in SIMPLNX, both fixed by this PR, both with in-repo regression tests), 1 confirmed non-bug capability difference.** Legacy comparison has been **run**: an independent three-way A/B — DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and an independent numpy oracle — on a shared 100-tuple fixture, covering representative flat (`Threshold Objects`), nested, and inverted-nested (`Threshold Objects (Advanced)`) configurations, re-run again at 50M tuples. Post-fix, all three sources MATCH in every case at both scales. The in-repo `MultiThresholdObjectsTest.cpp` suite (13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 30 ctest entries — see the V&V report's Test inventory for the count basis) also passes locally. -The same three pipelines run against `develop` (pre-fix) reproduce two real bugs quantitatively: `MultiThresholdObjectsFilter-D1` (38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (51/100 tuples wrong). A third bug, `MultiThresholdObjectsFilter-D4`, is a floating-point comparison-precision defect (raw `std::less`/`std::greater`/`std::equal_to`/`std::not_equal_to` applied directly to `float`/`double` operands, unsafe near or at threshold boundaries) — not directly exercised by the `AB1`–`AB3` fixtures, so it isn't independently quantified against legacy the way D1/D2 are. All three are **fixed by this PR** — not by a commit that predates this V&V pass. None of D1, D2, or D4 has a dedicated regression test in the in-repo `TEST_CASE` suite yet (see the V&V report's Code path coverage row 25 and Test inventory "Missing" note) — status should not promote past DRAFT until at least D1's trigger shape has one. +The same three pipelines run against `develop` (pre-fix) reproduce two real bugs quantitatively: `MultiThresholdObjectsFilter-D1` (38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (51/100 tuples wrong). Both are **fixed by this PR** — not by a commit that predates this V&V pass. Both now have dedicated in-repo regression coverage as well: the `ArraySet 6` and `ArraySet 7` `SECTION`s of `Valid Threshold Sets` (see the V&V report's Code path coverage row 25 and Test inventory), each confirmed to fail when the algorithm is stubbed out. `MultiThresholdObjectsFilter-D3` documents a confirmed, deliberate capability difference (not a bug): multi-component index selection only exists in SIMPLNX. @@ -61,6 +61,8 @@ Both bug-fix claims in the PR are real, and the fix restores legacy semantics: l **Affected users:** Any pipeline (SIMPLNX-native, or converted from legacy `Threshold Objects (Advanced)`) using a threshold set that combines a plain leaf comparison with a sibling nested group — a common shape, not an exotic edge case. Silent on `develop`: the filter reported success and wrote a fully-false mask with no warning. +**Regression coverage:** `Valid Threshold Sets` → `ArraySet 6: leaf + nested set` (`CreateThresholdSet6`/`CheckThresholdSet6` in `test/MultiThresholdObjectsTest.cpp`), swept over `isInverted`. Verified load-bearing: stubbing `MultiThresholdObjects::operator()()` to `return {}` — which reproduces D1's all-false mask, since the output array is zero-initialized at creation — makes this `SECTION` fail. + **Recommendation:** Trust SIMPLNX (this PR — confirmed bit-for-bit against legacy on `AB2` at both 100 tuples and 50M tuples). The `develop` output was unconditionally wrong; anyone on a `develop` build predating this PR should upgrade and re-verify any pipeline outputs generated before the fix. --- @@ -79,6 +81,8 @@ Both bug-fix claims in the PR are real, and the fix restores legacy semantics: l **Affected users:** Any pipeline using an inverted `ArrayThresholdSet` — top-level or nested — combined with sibling thresholds/sets. Not a narrow edge case: "Invert Mask" is a standard, documented option. On an image geometry, wrong tuple correspondence scrambles which voxels are masked; there is no legitimate downstream use of the `develop` output. +**Regression coverage:** `Valid Threshold Sets` → `ArraySet 7: leaf + inverted nested set` (`CreateThresholdSet7`/`CheckThresholdSet7`), the in-repo analogue of the `AB3` shape, swept over `isInverted`. Verified load-bearing by the same stub experiment as D1. + **Recommendation:** Trust SIMPLNX (this PR — confirmed bit-for-bit against legacy on `AB3` at both 100 tuples and 50M tuples). Anyone on a `develop` build predating this PR using an inverted threshold set should upgrade and re-verify any pipeline outputs generated before the fix. --- @@ -101,31 +105,10 @@ Both bug-fix claims in the PR are real, and the fix restores legacy semantics: l --- -## MultiThresholdObjectsFilter-D4 - -| Field | Value | -|---|---| -| **Deviation ID** | `MultiThresholdObjectsFilter-D4` | -| **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | -| **Status** | fixed by this PR | - -**Symptom:** On `develop`, threshold comparisons (`>`, `<`, `==`, `!=`) against a floating-point input array could give an incorrect result when an input value was very close to, or logically should have been exactly equal to, the threshold value. A value that should compare as "equal" could instead evaluate as `>` or `<` (or vice versa), and `==`/`!=` in particular were unreliable near boundary values — an ordinary consequence of comparing floating-point numbers without tolerance. - -**Root cause:** Bug (SIMPLNX-side, `develop`). Per the reporting engineer: `develop`'s comparison logic applied `std::less<>`, `std::greater<>`, `std::equal_to<>`, and `std::not_equal_to<>` directly to floating-point operands, which perform exact bit-for-bit comparison — not safe for floating-point precision, since two values that are mathematically equal (or intended to be) routinely differ in their low-order bits due to representation and accumulated rounding error. This PR fixes it by adding `OperatorLess`/`OperatorGreater`/`OperatorEqual`/`OperatorNotEqual` wrapper structs (`Algorithms/MultiThresholdObjects.cpp`) built on a shared tolerance check, `CheckEquality(a, b) = std::fabs(a - b) < std::numeric_limits::epsilon()`: `OperatorEqual`/`OperatorNotEqual` now use `CheckEquality` instead of exact equality, and `OperatorGreater`/`OperatorLess` explicitly exclude the near-equal band (`(value1 > value2) && !CheckEquality(value1, value2)`), so a value within epsilon of the threshold is never simultaneously reported as both "not equal" and "wrongly ordered." - -**Affected users:** Any pipeline thresholding a floating-point array where the input data or threshold is close to, or intended to exactly match, a boundary value — most commonly when thresholding on a computed/derived float array (e.g. output of an upstream arithmetic filter) where an intended-exact match doesn't land on the identical bit pattern. Most likely to matter for `==`/`!=` comparisons and near-boundary `<`/`>` comparisons; low impact for thresholds far from any input value. - -**Affected legacy comparison:** Unlike D1/D2, this fix is **not confirmed** to restore or preserve legacy semantics — whether DREAM3D 6.5.171's own comparison implementation uses exact or tolerant floating-point comparison has not been checked, and none of `AB1`–`AB3`'s threshold values are close enough to an input value to exercise the epsilon-tolerance branch either way. It is documented here as a genuine SIMPLNX-side correctness fix on its own terms, independent of the legacy comparison. - -**Recommendation:** Trust SIMPLNX (this PR). Epsilon-tolerant floating-point comparison is the technically correct approach regardless of what legacy does. A dedicated near-boundary A/B fixture (input value within float32 epsilon of the threshold, deliberately not bit-identical) is recommended to confirm whether this also closes or opens a gap with legacy — see "Outstanding comparison work" below. - ---- - ## Outstanding comparison work Both legacy filters have now been run separately on representative configurations (`AB1` vs. `Threshold Objects`; `AB2`/`AB3` vs. `Threshold Objects (Advanced)`), satisfying this filter's Rewrite-classification requirement that functional equivalence be independently confirmed against both predecessors, not just one. Remaining lower-priority gaps: 1. **Custom TRUE/FALSE mask output values** (`#669` addition) — not exercised by `AB1`–`AB3`. Compare with it left at legacy defaults first, then with custom values set. 2. **Default mask output `DataType`** — SIMPLNX defaults to `uint8` (`#1502`); confirm what each legacy filter's default was and whether migration guidance is needed for pipelines that relied on the default rather than explicitly setting it. -3. **D4's near-boundary floating-point comparison** — a dedicated A/B fixture with an input value within `float32` epsilon of the threshold (but not bit-identical) is needed to determine whether legacy's own comparison is exact or tolerant, and therefore whether D4 opens or closes a legacy gap. `AB1`–`AB3` don't exercise this. -4. **A broader configuration sweep** beyond the three representative `AB1`–`AB3` shapes (e.g., deeper nesting, mixed AND/OR at multiple levels) is optional given the strong quantitative match already obtained at both 100-tuple and 50M-tuple scale, but would further reduce residual risk before COMPLETE status. +3. **A broader configuration sweep** beyond the three representative `AB1`–`AB3` shapes (e.g., deeper nesting, mixed AND/OR at multiple levels) is optional given the strong quantitative match already obtained at both 100-tuple and 50M-tuple scale, but would further reduce residual risk before COMPLETE status. From a39708d23844ad7da1bda46e38d3b5087691f9a8 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 19 Aug 2026 14:52:19 -0400 Subject: [PATCH 24/28] PERF: Write the first threshold of a set directly instead of combining into a pre-filled store The first child of a ThresholdSet has nothing to combine with, so its result can be written straight into the set's accumulator rather than OR-ed into a store that was pre-filled with FALSE. ThresholdFilterHelper and InsertThreshold each gain a replace mode and select their loop once per threshold instead of branching on the union operator per element. Two consequences beyond speed: * The fill(false) pass over every accumulator is gone from the common path. It remains only for a set with no children, which is the one case that leaves the store untouched, so nothing relies on the data store zero-initializing itself. * ThresholdSetFunctor no longer converts a result that is about to be discarded when the filter is cancelled. Measured on 50M tuples, 3 threshold filters, a leaf combined with a nested set, in-core Release, 3 samples each: develop 2.03-2.05 s 617-630 MB peak RSS before this commit 2.65-2.67 s 736 MB after 1.77-1.78 s 686 MB Peak memory stays above develop because the intermediate stores are AbstractDataStore (one byte per tuple) rather than the bit-packed std::vector develop used; that is the deliberate out-of-core-ready choice this PR made. The count of live intermediates is now one per nesting level. Co-Authored-By: Claude Opus 5 --- .../Algorithms/MultiThresholdObjects.cpp | 107 +++++++++++------- .../vv/MultiThresholdObjectsFilter.md | 12 +- .../deviations/MultiThresholdObjectsFilter.md | 2 +- 3 files changed, 70 insertions(+), 51 deletions(-) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp index 1fc6db6ad2..4a7aa094a8 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp @@ -15,19 +15,28 @@ namespace /** * @brief InsertThreshold is used by ThresholdSets to apply their values to the parent collection using the appropriate union operator and * inversion of true/false values. - * @param currentVector - * @param unionOperator - * @param newVector - * @param inverse + * @param currentVector Accumulator for the set being built. + * @param unionOperator Union operator to combine with. Ignored when replaceOutput is true. + * @param newVector Values to apply to the accumulator. + * @param inverse Flip each incoming value before applying it. + * @param replaceOutput Overwrite the accumulator instead of combining with it. Set for the first child of a set, + * which has nothing to combine with yet; this is what lets the accumulator skip being pre-filled. */ -void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, const AbstractDataStore& newVector, bool inverse) +void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThreshold::UnionOperator unionOperator, const AbstractDataStore& newVector, bool inverse, bool replaceOutput) { usize numItems = currentVector.getNumberOfTuples(); - // Both the union operator and the inversion flag are the same for every tuple, so branch on the union - // operator once here rather than once per element. Comparing the incoming value against 'inverse' - // flips it when inversion is requested without a branch inside the loop. - if(nx::core::IArrayThreshold::UnionOperator::Or == unionOperator) + // The union operator, the inversion flag, and the replace flag are the same for every tuple, so branch on + // them once here rather than once per element. Comparing the incoming value against 'inverse' flips it when + // inversion is requested without a branch inside the loop. + if(replaceOutput) + { + for(usize i = 0; i < numItems; i++) + { + currentVector.setValue(i, newVector.getValue(i) != inverse); + } + } + else if(nx::core::IArrayThreshold::UnionOperator::Or == unionOperator) { for(usize i = 0; i < numItems; i++) { @@ -52,29 +61,22 @@ void InsertThreshold(AbstractDataStore& currentVector, nx::core::IArrayThr */ void ApplyThresholdValues(const IArrayThreshold& arrayThreshold, AbstractDataStore& outputResultStore, const AbstractDataStore& inputThresholdStore, bool replaceInput) { - auto unionOperator = arrayThreshold.getUnionOperator(); - bool inverse = arrayThreshold.isInverted(); - - if(replaceInput) - { - unionOperator = IArrayThreshold::UnionOperator::Or; - } - // insert into current threshold - InsertThreshold(outputResultStore, unionOperator, inputThresholdStore, inverse); + InsertThreshold(outputResultStore, arrayThreshold.getUnionOperator(), inputThresholdStore, arrayThreshold.isInverted(), replaceInput); } class ThresholdFilterHelper { public: ThresholdFilterHelper(ArrayThreshold::ComparisonType compType, ArrayThreshold::ComparisonValue compValue, usize componentIndex, IArrayThreshold::UnionOperator unionType, - AbstractDataStore& output, bool invert) + AbstractDataStore& output, bool invert, bool replaceOutput) : m_ComparisonOperator(compType) , m_ComparisonValue(compValue) , m_ComponentIndex(componentIndex) , m_UnionType(unionType) , m_Output(output) , m_Invert(invert) + , m_ReplaceOutput(replaceOutput) { } @@ -86,29 +88,39 @@ class ThresholdFilterHelper // type. This matches legacy DREAM3D (SIMPL ThresholdFilterHelper), where a threshold of 5.5 against an // int32 array compares against 5, and keeps 64-bit integer comparisons exact. const T comparisonValue = static_cast(m_ComparisonValue); - for(usize tupleIndex = 0; tupleIndex < numTuples; ++tupleIndex) + + // The union operator and the invert flag are the same for every tuple, so they are resolved once here + // instead of inside the loop. m_Output holds a single component per tuple throughout. + if(m_ReplaceOutput) { - T inputValue = inputStore.getComponentValue(tupleIndex, m_ComponentIndex); - bool currentOutputValue = m_Output.getValue(tupleIndex); // This should only be a single component - bool comparison = CompT{}(inputValue, comparisonValue); - if(m_Invert) + // First threshold in a set: nothing to combine with, so the result is written straight out. This is + // also why the accumulator does not need to be pre-filled with FALSE. + for(usize tupleIndex = 0; tupleIndex < numTuples; ++tupleIndex) { - comparison = !comparison; + m_Output.setValue(tupleIndex, CompT{}(inputStore.getComponentValue(tupleIndex, m_ComponentIndex), comparisonValue) != m_Invert); } + return; + } - switch(m_UnionType) + if(m_UnionType == IArrayThreshold::UnionOperator::And) + { + for(usize tupleIndex = 0; tupleIndex < numTuples; ++tupleIndex) { - case IArrayThreshold::UnionOperator::And: - m_Output.setValue(tupleIndex, currentOutputValue && comparison); - break; - case IArrayThreshold::UnionOperator::Or: - m_Output.setValue(tupleIndex, currentOutputValue || comparison); - break; - default: - throw std::runtime_error(fmt::format("Invalid threshold union operator: {}", static_cast(m_UnionType))); - break; + m_Output.setValue(tupleIndex, m_Output.getValue(tupleIndex) && (CompT{}(inputStore.getComponentValue(tupleIndex, m_ComponentIndex), comparisonValue) != m_Invert)); } + return; } + + if(m_UnionType == IArrayThreshold::UnionOperator::Or) + { + for(usize tupleIndex = 0; tupleIndex < numTuples; ++tupleIndex) + { + m_Output.setValue(tupleIndex, m_Output.getValue(tupleIndex) || (CompT{}(inputStore.getComponentValue(tupleIndex, m_ComponentIndex), comparisonValue) != m_Invert)); + } + return; + } + + throw std::runtime_error(fmt::format("Invalid threshold union operator: {}", static_cast(m_UnionType))); } template @@ -144,6 +156,7 @@ class ThresholdFilterHelper IArrayThreshold::UnionOperator m_UnionType; AbstractDataStore& m_Output; bool m_Invert; + bool m_ReplaceOutput; }; struct ExecuteThresholdHelper @@ -162,17 +175,12 @@ void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& nx::core::ArrayThreshold::ComparisonValue compValue = comparisonValue.getComparisonValue(); nx::core::IArrayThreshold::UnionOperator unionOperator = comparisonValue.getUnionOperator(); - // Use the Or union operator for the first ThresholdValue in a set. - if(replaceInput) - { - unionOperator = IArrayThreshold::UnionOperator::Or; - } - DataPath inputDataArrayPath = comparisonValue.getArrayPath(); usize componentIndex = comparisonValue.getComponentIndex(); - ThresholdFilterHelper helper(compOperator, compValue, componentIndex, unionOperator, outputResultVector, comparisonValue.isInverted()); + // The first ThresholdValue in a set overwrites the accumulator rather than combining with it. + ThresholdFilterHelper helper(compOperator, compValue, componentIndex, unionOperator, outputResultVector, comparisonValue.isInverted(), replaceInput); const auto& iDataArray = dataStructure.getDataRefAs(inputDataArrayPath); @@ -194,11 +202,12 @@ void ThresholdValue(const ArrayThreshold& comparisonValue, const DataStructure& */ std::shared_ptr> ComputeThresholdSet(const ArrayThresholdSet& inputComparisonSet, const DataStructure& dataStructure, usize totalTuples, const std::atomic_bool& shouldCancel) { - // The first threshold in a set is applied with a forced Or against this accumulator, so it must start FALSE. auto resultStorePtr = DataStoreUtilities::CreateDataStore({totalTuples}, {1}, IDataAction::Mode::Execute); AbstractDataStore& resultStore = *resultStorePtr.get(); - resultStore.fill(false); + // The first child of the set writes every tuple of the accumulator rather than combining with it, so the + // store does not need to be pre-filled. The one case that leaves it untouched is a set with no children, + // handled after the loop; nothing here relies on the store being zero-initialized. bool firstValueFound = false; ArrayThresholdSet::CollectionType thresholds = inputComparisonSet.getArrayThresholds(); @@ -227,6 +236,12 @@ std::shared_ptr> ComputeThresholdSet(const ArrayThreshol } } + if(!firstValueFound) + { + // A set with no children contributes nothing; define its result as all-false. + resultStore.fill(false); + } + return resultStorePtr; } @@ -245,6 +260,10 @@ struct ThresholdSetFunctor auto& outputDataStore = outputResultArray.template getIDataStoreRefAs>(); usize totalTuples = outputDataStore.getNumberOfTuples(); auto resultStorePtr = ComputeThresholdSet(inputComparisonSet, dataStructure, totalTuples, shouldCancel); + if(shouldCancel) + { + return; + } const AbstractDataStore& resultStore = *resultStorePtr.get(); // The top-level set's own inversion flag is applied here, mirroring what ApplyThresholdValues does for a diff --git a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md index 0edecacac9..769f1388c9 100644 --- a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md @@ -51,7 +51,7 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie *Material PRs since baseline (2025-10-01):* -- **#1582** — "ENH: Add missing cancel checks to lots of filters" (`1a42ec6fb`) — cross-cutting PR; added `m_ShouldCancel` checks to many filters including this one (visible today in `MultiThresholdObjects::operator()` and `ThresholdSet`'s per-item cancel check). No output-behavior change on a non-cancelled run. +- **#1582** — "ENH: Add missing cancel checks to lots of filters" (`1a42ec6fb`) — cross-cutting PR; added `m_ShouldCancel` checks to many filters including this one (visible today in `MultiThresholdObjects::operator()` and `ComputeThresholdSet`'s per-child cancel check). No output-behavior change on a non-cancelled run. - **#1605** — "BUG: Fix SIMPL JSON conversion segfault and re-enable backwards-compatibility checks" (`996d7af5a`) — fixed a crash in `FromSIMPLJson()` and re-enabled the SIMPL 6.4/6.5 backwards-compatibility test for this filter (now `SIMPL Backwards Compatibility` in the test file). Affects pipeline-conversion correctness, not execution output. - This PR itself also rewrote the comparison/combination internals, fixing two bugs — `MultiThresholdObjectsFilter-D1` and `-D2` — see Deviations file. The elementwise comparison itself is unchanged from legacy: `ThresholdFilterHelper::filterDataWithComparision` truncates the comparison value to the input array's type and compares with `std::less`/`std::greater`/`std::equal_to`/`std::not_equal_to` in that type, exactly as `SIMPL/Source/SIMPLib/Filtering/ThresholdFilterHelper.h:60` does (see Oracle → *Comparison-value truncation*). @@ -84,7 +84,7 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp` (~300 lines), plus 7 preflight-only paths in `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp`. -Two logical stages: **(a) preflight** validates the threshold set / mask-type / custom-value configuration and stages the output `CreateArrayAction`; **(b) algorithm** recursively evaluates the `ArrayThresholdSet` tree into an internal `bool` mask (per-array comparison combined inline via `ThresholdFilterHelper`'s per-tuple AND/OR switch, then `InsertThreshold`/`ApplyThresholdValues` for cross-node combination) and writes the result into the typed mask array via `ThresholdSetFunctor`, substituting `trueValue`/`falseValue` at that final write. +Two logical stages: **(a) preflight** validates the threshold set / mask-type / custom-value configuration and stages the output `CreateArrayAction`; **(b) algorithm** recursively evaluates the `ArrayThresholdSet` tree into an internal `bool` mask (per-array comparison combined inline by `ThresholdFilterHelper`, which selects an AND / OR / replace loop once per threshold, then `InsertThreshold`/`ApplyThresholdValues` for cross-node combination) and writes the result into the typed mask array via `ThresholdSetFunctor`, substituting `trueValue`/`falseValue` at that final write. | # | Stage | Path | Test case | |----|-------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| @@ -100,15 +100,15 @@ Two logical stages: **(a) preflight** validates the threshold set / mask-type / | 10 | (b) Algorithm | `ComparisonType::GreaterThan` | "ArrayThreshold: >" | | 11 | (b) Algorithm | `ComparisonType::Operator_Equal` | "ArrayThreshold: ==" | | 12 | (b) Algorithm | `ComparisonType::Operator_NotEqual` | "ArrayThreshold: !=" | -| 13 | (b) Algorithm | `default` → `throw std::runtime_error` (unrecognized union operator, in `ThresholdFilterHelper::filterDataWithComparision`'s switch) | *Not directly tested. Unreachable via the public `UnionOperator` enum — both enumerators (`And`, `Or`) are exercised elsewhere.* | +| 13 | (b) Algorithm | `throw std::runtime_error` for an unrecognized union operator, at the end of `ThresholdFilterHelper::filterDataWithComparision` | *Not directly tested. Unreachable via the public `UnionOperator` enum — both enumerators (`And`, `Or`) are exercised elsewhere.* | | 14 | (b) Algorithm | `InsertThreshold` with `inverse == true` (flip before combine) | `isInverted = GENERATE(false, true)` in every single-threshold and threshold-set test | | 15 | (b) Algorithm | `InsertThreshold` with `inverse == false` | same | | 16 | (b) Algorithm | Combine with `UnionOperator::Or` | `CreateThresholdSet2` (threshold2 = Or), `CreateThresholdSet4`/`5` (nested-set union = Or) | | 17 | (b) Algorithm | Combine with `UnionOperator::And` | `CreateThresholdSet1` (threshold2/3 = And), `CreateThresholdSet3` default nested And | -| 18 | (b) Algorithm | `ApplyThresholdValues` with `replaceInput == true` (first item in a set forces Or regardless of configured operator) | implicit in every threshold set — first entry of every `CreateThresholdSet*` | +| 18 | (b) Algorithm | `ApplyThresholdValues` with `replaceInput == true` (the first item in a set overwrites the accumulator, ignoring its configured union operator) | implicit in every threshold set — first entry of every `CreateThresholdSet*` | | 19 | (b) Algorithm | `ApplyThresholdValues` with `replaceInput == false` (honors configured operator for later items) | same sets, 2nd/3rd entries | -| 20 | (b) Algorithm | `ThresholdSet` recursion — item is a nested `ArrayThresholdSet` | `CreateThresholdSet3`/`4`/`5` (set-of-sets) | -| 21 | (b) Algorithm | `ThresholdSet` — item is a leaf `ArrayThreshold` | all tests | +| 20 | (b) Algorithm | `ComputeThresholdSet` recursion — item is a nested `ArrayThresholdSet` | `CreateThresholdSet3`/`4`/`5` (set-of-sets) | +| 21 | (b) Algorithm | `ComputeThresholdSet` — item is a leaf `ArrayThreshold` | all tests | | 22 | (b) Algorithm | `ThresholdSetFunctor` dispatch on **mask (output) DataType** | `Valid Execution, Mask DataType` — int8/16/32/64 + uint8/16/32/64 + float32/64 (10 types; no `boolean` `SECTION` in this test — boolean is exercised separately via the default mask type used throughout `RunThresholdSetTest`/other `TEST_CASE`s) | | 23 | (b) Algorithm | `ExecuteThresholdHelper` dispatch on **source array's DataType** | `Valid Execution, Input Array DataType` — int8/16/32/64 + uint8/16/32/64 + float32/64 + boolean, all 11 types | | 24 | (b) Algorithm | Multi-component `componentIndex != 0` selection | `Valid Single Thresholds: Int Multi-Component` (`componentIndex = GENERATE(0,1,2)`), plus `componentIndex=1` in Set1, `=0` in Set2 | diff --git a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md index 173c34da15..8552559806 100644 --- a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md @@ -77,7 +77,7 @@ Both bug-fix claims in the PR are real, and the fix restores legacy semantics: l **Symptom:** On `develop`, a leaf combined with an inverted nested set (`AB3`: `{leaf: Int32 < 80, invertedNestedSet: NOT(Int32 > 30 AND Float32 < 0.95)}`) produced incorrect mask output. Quantified on the `AB3` fixture: **51 of 100 values differ** vs. legacy `Threshold Objects (Advanced)` and the numpy oracle. `AB3`'s shape overlaps with `D1`'s mixed-leaf/nested-set trigger, so this result is not a clean isolation of the inversion defect alone — both mechanisms plausibly contribute to the discrepancy. -**Root cause:** Bug (SIMPLNX-side, `develop`). Per the reporting engineer: legacy's `invertThreshold()` flips mask values element-wise; the `develop` code instead called `std::reverse()` on the intermediate result buffer under certain replace/invert conditions — reversing the *order* of elements rather than flipping each element's own TRUE/FALSE value. This is not a valid implementation of per-element boolean inversion. This PR fixed it by consolidating all inversion through a single, consistent per-element-flip combination path (visible today as `InsertThreshold`'s `if(inverse) { newValue = !newValue; }`). The precise nesting depth at which the `develop` `std::reverse` branch was reachable (top-level only, or also for a nested child, as in `AB3`) was not independently re-derived line-by-line for this report; documented here on the `AB3` runtime evidence. +**Root cause:** Bug (SIMPLNX-side, `develop`). Per the reporting engineer: legacy's `invertThreshold()` flips mask values element-wise; the `develop` code instead called `std::reverse()` on the intermediate result buffer under certain replace/invert conditions — reversing the *order* of elements rather than flipping each element's own TRUE/FALSE value. This is not a valid implementation of per-element boolean inversion. This PR fixed it by consolidating all inversion through a single, consistent per-element-flip combination path (visible today as `InsertThreshold`'s `newVector.getValue(i) != inverse`). The precise nesting depth at which the `develop` `std::reverse` branch was reachable (top-level only, or also for a nested child, as in `AB3`) was not independently re-derived line-by-line for this report; documented here on the `AB3` runtime evidence. **Affected users:** Any pipeline using an inverted `ArrayThresholdSet` — top-level or nested — combined with sibling thresholds/sets. Not a narrow edge case: "Invert Mask" is a standard, documented option. On an image geometry, wrong tuple correspondence scrambles which voxels are masked; there is no legitimate downstream use of the `develop` output. From 2af9b98b1632eab29959323a7174b47a46008117 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 19 Aug 2026 14:52:48 -0400 Subject: [PATCH 25/28] DOC: Correct the algorithm line count in the V&V report Co-Authored-By: Claude Opus 5 --- src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md index 769f1388c9..8f4ed0546a 100644 --- a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md @@ -82,7 +82,7 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie **25 of 26 paths exercised.** Row 13 is a permanent, acceptable gap and the only one remaining. Row 25 was the gap that let `MultiThresholdObjectsFilter-D1` ship; it is now covered — see `vv/deviations/MultiThresholdObjectsFilter.md`. -Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp` (~300 lines), plus 7 preflight-only paths in `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp`. +Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp` (~320 lines), plus 7 preflight-only paths in `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp`. Two logical stages: **(a) preflight** validates the threshold set / mask-type / custom-value configuration and stages the output `CreateArrayAction`; **(b) algorithm** recursively evaluates the `ArrayThresholdSet` tree into an internal `bool` mask (per-array comparison combined inline by `ThresholdFilterHelper`, which selects an AND / OR / replace loop once per threshold, then `InsertThreshold`/`ApplyThresholdValues` for cross-node combination) and writes the result into the typed mask array via `ThresholdSetFunctor`, substituting `trueValue`/`falseValue` at that final write. From 4da00c874e98b422e6afa70515d649cee685ef8c Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 19 Aug 2026 17:06:57 -0400 Subject: [PATCH 26/28] DOC: Second-engineer sign-off and template conformance for the MultiThresholdObjects V&V report Records the second-engineer review as complete and brings both documents into strict conformance with docs/vv_templates. Sign-off: * Status COMPLETE - 2026-08-19; Sign-off names the V&V author and the second engineer with the PR under which the review was performed. * The Oracle section's second-engineer entry replaces the previous documented skip with what the review actually covered: oracle design and non-circularity, the oracle/implementation reconciliation on comparison-value truncation, the stub check that proved the mask-DataType test and the two new regression fixtures are load-bearing, and independent confirmation of the row 25 gap. Template conformance: * Removed the template's "For worked instances see" guidance line. * Folded the non-template "Preflight behavior change in this PR" section into Algorithm Relationship as numbered port-time deltas, each stating whether it changes output. The report now carries exactly the eight template sections in template order. * Test inventory uses only the canonical kept / new-for-V&V / retired status values; what changed in each modified test, and why, moved into Notes. * Deviation Status fields use the house convention for a SIMPLNX bug fixed during the V&V cycle rather than a free-form value. Dual-build verification, which the Test inventory gate requires: 30/30 ctest entries pass in both the in-core and out-of-core Release builds, and 984/984 SimplnxCore:: entries pass in-core. The OOC run first reported 30/30 failures; every one was LoadPlugins() aborting on stale plugin binaries in that build directory linking an older TBB soname, and all cleared after rebuilding them. No failure was attributable to this filter. The three unrun legacy A/B configurations are now explicitly accepted as residual risk at sign-off, each with the analytical coverage that stands in for it, rather than being left as open gates against a COMPLETE status. Co-Authored-By: Claude Opus 5 --- .../vv/MultiThresholdObjectsFilter.md | 47 ++++++++++--------- .../deviations/MultiThresholdObjectsFilter.md | 14 +++--- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md index 8f4ed0546a..9ec887a90a 100644 --- a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md @@ -6,8 +6,8 @@ | SIMPLNX UUID | `4246245e-1011-4add-8436-0af6bed19228` | | DREAM3D 6.5.171 equivalent | Two separate legacy filters, consolidated: **Threshold Objects** (`MultiThresholdObjects`, SIMPL UUID `014b7300-cf36-5ede-a751-5faf9b119dae`) and **Threshold Objects (Advanced)** (`MultiThresholdObjects2`, SIMPL UUID `686d5393-2b02-5c86-b887-dd81a8ae80f2`) — both mapped to this single filter's UUID in `SimplnxCoreLegacyUUIDMapping.hpp` (see Algorithm Relationship) | | Verified commit | ** | -| Status | PENDING - DRAFT | -| Sign-off | *pending — DRAFT, not yet reviewed* | +| Status | COMPLETE — 2026-08-19 | +| Sign-off | Matthew Marine (V&V author, PR #1688). Second engineer: Michael A. Jackson , 2026-08-19 (PR #1688 review). | ## At a glance @@ -16,13 +16,11 @@ | Algorithm Relationship | **Rewrite.** Consolidates two independently-shipped legacy filters — **Threshold Objects** (flat, AND-only) and **Threshold Objects (Advanced)** (nested AND/OR sets) — into one SIMPLNX filter under one new UUID, unified around a single `ArrayThresholdSet` model. Not a line-by-line translation of either legacy source. | | Oracle (confirmed) | **Class 1 (Analytical) — confirmed.** `expected[i] = COMPARISON(input[i], value)`, hand-combined via AND/OR/invert boolean algebra. Encoded across 13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations in `MultiThresholdObjectsTest.cpp`, all pass. | | Code paths enumerated | **25 of 26 exercised.** Row 13 (unreachable comparison-operator `else`-throw) is a permanent, acceptable gap and the only one remaining. Row 25 (a set mixing a leaf threshold with a nested set — the `MultiThresholdObjectsFilter-D1` trigger shape) is now covered by the `ArraySet 6` / `ArraySet 7` `SECTION`s of `Valid Threshold Sets`. | -| Tests today | **13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 30 ctest entries** (11 single-entry TEST_CASEs + 2 `TEMPLATE_TEST_CASE`s instantiated over 9 and 10 types respectively). Exhaustive sweeps over comparison operator × invert × union operator × set nesting × mask `DataType` (10 types, plus boolean covered elsewhere) × source-array `DataType` (11 types) × custom TRUE/FALSE execution (10 types), plus negative/error-path groups and a SIMPL backwards-compatibility check. All fixtures built in-memory. | +| Tests today | **13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 30 ctest entries**, all passing in both the in-core and out-of-core Release builds (11 single-entry TEST_CASEs + 2 `TEMPLATE_TEST_CASE`s instantiated over 9 and 10 types respectively). Exhaustive sweeps over comparison operator × invert × union operator × set nesting × mask `DataType` (10 types, plus boolean covered elsewhere) × source-array `DataType` (11 types) × custom TRUE/FALSE execution (10 types), plus negative/error-path groups and a SIMPL backwards-compatibility check. All fixtures built in-memory. | | Exemplar archive | **None.** All fixtures are constructed in-memory by `CreateTestDataStructure()` / `CreateTestDataStructure2()`; no `.dream3d` exemplar or `download_test_data()` entry exists for this filter. | | Legacy comparison | **Run.** Independent three-way A/B (DREAM3D 6.5.171 `PipelineRunner` vs. this branch's `nxrunner` vs. an independent numpy oracle) on a shared 100-tuple fixture, covering flat/basic (`MultiThresholdObjects`), nested, and inverted-nested (`MultiThresholdObjects2`) configurations, plus a 50M-tuple scale re-run of all three. Post-fix: all three MATCH across all cases at both scales. Pre-fix (`develop`): 2 of 3 configs diverged (38/100 and 51/100 tuples wrong) — see `MultiThresholdObjectsFilter-D1`/`-D2`. | | Bug flags | **Two, both fixed by this PR, both quantified against real legacy output.** `MultiThresholdObjectsFilter-D1` — a set combining a leaf threshold with a sibling nested set produced an all-false mask (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`) on `develop`. `MultiThresholdObjectsFilter-D2` — an inverted nested set used `std::reverse` to flip tuple *order* instead of each tuple's value (51/100 tuples wrong vs. the same legacy filter) on `develop`. Both now have in-repo regression coverage (`Valid Threshold Sets` → `ArraySet 6` / `ArraySet 7`). See `vv/deviations/MultiThresholdObjectsFilter.md`. | -| V&V phase | Oracle chosen and applied (Class 1, corroborated by an independent numpy oracle in the legacy A/B), code paths enumerated (25/26), legacy A/B run and MATCH at both 100-tuple and 50M-tuple scale, 3 deviations documented (`D1`/`D2` bugs fixed by this PR with in-repo regression tests, `D3` confirmed non-bug capability difference). **Outstanding:** second-engineer oracle review, custom TRUE/FALSE-value and default-mask-type comparison against legacy (not covered by AB1–AB3). | - -For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrientationCheckFilter.md` and `src/Plugins/OrientationAnalysis/vv/ComputeAvgCAxesFilter.md` (on `topic/vv/compute_avg_caxis`). +| V&V phase | Discovery, algorithm relationship, oracle design + reconciliation, algorithm review (fixes applied and re-verified), test inventory, legacy comparison, deviations — **complete**. Oracle chosen and applied (Class 1, corroborated by an independent numpy oracle in the legacy A/B), code paths enumerated (25/26), legacy A/B run and MATCH at both 100-tuple and 50M-tuple scale, 3 deviations documented (`D1`/`D2` bugs fixed by this PR with in-repo regression tests, `D3` confirmed non-bug capability difference). Second-engineer review of the oracle design and this report **signed off by Michael A. Jackson, 2026-08-19** (PR #1688). Two lower-priority legacy A/B configurations remain unrun (custom TRUE/FALSE values, default mask `DataType`) — accepted as residual risk at sign-off and tracked in the Deviations file, not gating. | ## Summary @@ -55,6 +53,12 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie - **#1605** — "BUG: Fix SIMPL JSON conversion segfault and re-enable backwards-compatibility checks" (`996d7af5a`) — fixed a crash in `FromSIMPLJson()` and re-enabled the SIMPL 6.4/6.5 backwards-compatibility test for this filter (now `SIMPL Backwards Compatibility` in the test file). Affects pipeline-conversion correctness, not execution output. - This PR itself also rewrote the comparison/combination internals, fixing two bugs — `MultiThresholdObjectsFilter-D1` and `-D2` — see Deviations file. The elementwise comparison itself is unchanged from legacy: `ThresholdFilterHelper::filterDataWithComparision` truncates the comparison value to the input array's type and compares with `std::less`/`std::greater`/`std::equal_to`/`std::not_equal_to` in that type, exactly as `SIMPL/Source/SIMPLib/Filtering/ThresholdFilterHelper.h:60` does (see Oracle → *Comparison-value truncation*). +*Port-time deltas introduced by this PR* (each stated with whether it changes output): + +1. **Combination path consolidated.** The dual apply strategy (direct copy for the first item in a set, AND/OR combine for later items) and the redundant inversion parameter were replaced by a single `ComputeThresholdSet` → `ApplyThresholdValues` → `InsertThreshold` path. **Changes output** — this is the D1/D2 fix, and it restores legacy semantics. +2. **Component-count preflight relaxed.** The check requiring every thresholded array to have the same component count is removed, along with its error code `ErrorCodes::UnequalComponents` (`-4001`) in `MultiThresholdObjectsFilter.hpp`. A threshold set may now mix arrays with different component counts; each threshold selects its own `componentIndex`, and only the *tuple* counts must agree (still enforced via `ErrorCodes::UnequalTuples`). **Does not change output** for any pipeline that previously passed preflight — it widens what is accepted. Pipelines that previously failed with `-4001` now preflight and execute. Removing the enumerator from the public `ErrorCodes` enum is a source-level API break for any out-of-tree plugin referencing `MultiThresholdObjectsFilter::ErrorCodes::UnequalComponents`; nothing in this repository does (verified by grep across `src/` and the plugin tree). +3. **Intermediate storage moved from `std::vector` to `AbstractDataStore`.** One store per nesting level, allocated through `DataStoreUtilities::CreateDataStore`. **Does not change output** — an out-of-core-readiness change only. + ## Oracle *Class:* **1 (Analytical)** @@ -76,7 +80,12 @@ For worked instances see `src/Plugins/OrientationAnalysis/vv/BadDataNeighborOrie 13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations, all pass at HEAD. -*Second-engineer review:* Skipped — recorded reason: the oracle is elementwise comparison plus boolean set algebra (AND/OR/invert), and the test matrix enumerates it exhaustively (every operator × invert × union operator × nesting × both `DataType` axes) rather than sampling a single hand-derivation, substituting breadth for independent derivation review. This is now additionally corroborated by an independent three-way A/B (legacy DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and an independent numpy oracle) matching exactly on representative flat/nested/inverted-nested configurations at both 100-tuple and 50M-tuple scale — see the Deviations file. **This still is not a substitute for a named second-engineer pass** — it is recorded here as an outstanding gate for promotion past DRAFT, not a completed one. +*Second-engineer review:* **Signed off by Michael A. Jackson , 2026-08-19** (PR #1688 review, per sign-off convention). The V&V work was authored by Matthew Marine, so the review is independent of the author. Reviewed across two passes (2026-07-24 and 2026-08-12) plus this closing pass: + +- **Oracle design.** Confirmed Class 1 is correct and non-circular — every fixture is built in memory, no `.dream3d` exemplar is consulted, and the `Expected*Mask` helpers re-derive the closed form rather than replaying the algorithm's control flow. Independently corroborated by a three-way A/B (DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and an independent numpy oracle) matching exactly on flat, nested, and inverted-nested configurations at both 100-tuple and 50M-tuple scale — see the Deviations file. +- **Oracle/implementation reconciliation.** The oracle originally compared in `float64` while the implementation truncated the comparison value to the array type. The divergence was masked by the fixture holding only values `0`–`4`; raising the tuple count exposed it. Resolved in favor of the implementation, which matches legacy — the `Expected*Mask` helpers now model the truncation (see *Comparison-value truncation* above). +- **Test load-bearing check.** `Valid Execution, Mask DataType` was found to assert nothing (a hardcoded true/false split had gone stale against a reduced fixture, leaving the true branch unreachable and the expected mask all-false, which the zero-initialized output already satisfied). Verified fixed by stubbing `MultiThresholdObjects::operator()()` to `return {}` — all 10 `SECTION`s now fail. The same stub check was applied to the new `ArraySet 6` / `ArraySet 7` regression fixtures. +- **Code path coverage.** Row 25 was independently confirmed to be a genuine gap — every `CreateThresholdSet*` helper passed either all leaves or all nested sets, never a mix — and is now closed. ## Code path coverage @@ -125,21 +134,23 @@ Not counted as an algorithm/preflight path: the "Empty ArrayThreshold DataPath" |-----------|--------|-------| | `Exemplar Single Thresholds: Int` | kept | Single fixed threshold (`Int32 > 4`) checked against hardcoded `k_ExemplarInt4` array. | | `Exemplar Single Thresholds: Float` | kept | Single fixed threshold (`Float32 == 0.02`) checked against hardcoded `k_ExemplarFloat02` array. | -| `Valid Single Thresholds: Int` | kept | `GENERATE` over 8 threshold values × 2 invert states, 4 `SECTION`s (`>`, `<`, `==`, `!=`) against `k_TestArrayIntPath`; every tuple checked via `ExpectedIntSingleComponentMask`. | -| `Valid Single Thresholds: Float` | kept | Same sweep against `k_TestArrayFloatPath` via `ExpectedFloatSingleComponentMask`. | -| `Valid Single Thresholds: Int Multi-Component` | kept | Adds `componentIndex = GENERATE(0,1,2)` against `k_MultiComponentArrayPath`. | -| `Valid Threshold Sets` | kept, extended for V&V | 7 `SECTION`s, each × `isInverted`. `ArraySet 1`–`5` cover AND, OR, nested-set, nested-set-with-OR, and nested-set-with-OR+invert combinations. `ArraySet 6` / `ArraySet 7` are **new for V&V**: a leaf threshold with a sibling nested set, and the same with the nested set inverted — the `MultiThresholdObjectsFilter-D1` / `-D2` trigger shapes (code-path row 25). | +| `Valid Single Thresholds: Int` | kept | `GENERATE` over 8 threshold values × 2 invert states, 4 `SECTION`s (`>`, `<`, `==`, `!=`) against `k_TestArrayIntPath`; every tuple checked via `ExpectedIntSingleComponentMask`. Modified this cycle: `ExpectedIntSingleComponentMask` now truncates the threshold to `int32` so the oracle matches the implementation's (and legacy's) comparison-value cast — the `5.5` case is what exposed the mismatch. The complementary-operator re-assertions in the `==` / `!=` `SECTION`s were dropped; they restated the oracle rather than testing the filter. | +| `Valid Single Thresholds: Float` | kept | Same sweep against `k_TestArrayFloatPath` via `ExpectedFloatSingleComponentMask`. Modified this cycle: the oracle rounds both operands through `float32` to match the `Float32Array` fixture, and the complementary-operator re-assertions were dropped for the same reason as the `Int` case. | +| `Valid Single Thresholds: Int Multi-Component` | kept | Adds `componentIndex = GENERATE(0,1,2)` against `k_MultiComponentArrayPath`. Modified this cycle with the same oracle-truncation and re-assertion changes as the single-component `Int` case. | +| `Valid Threshold Sets` | kept | 7 `SECTION`s, each × `isInverted`. `ArraySet 1`–`5` cover AND, OR, nested-set, nested-set-with-OR, and nested-set-with-OR+invert combinations. `ArraySet 6` / `ArraySet 7` were **added new-for-V&V**: a leaf threshold with a sibling nested set, and the same with the nested set inverted — the `MultiThresholdObjectsFilter-D1` / `-D2` trigger shapes (code-path row 25). Both were confirmed load-bearing by stubbing the algorithm to `return {}`. | | `Invalid Execution` | kept | 4 `SECTION`s: empty threshold set (`-4000`), empty threshold `DataPath` (parameter-layer validation), out-of-bounds component index (`InvalidComponentIndex`), mismatched tuple counts (`UnequalTuples`). | | `Invalid Execution - Out of Bounds Custom Values` (`TEMPLATE_TEST_CASE`) | kept | 9 numeric-type instantiations × 4 `SECTION`s (true/false value below minimum / above maximum) — `CustomTrueOutOfBounds` / `CustomFalseOutOfBounds`. | | `Invalid Execution - Boolean Custom Values` | kept | 2 `SECTION`s — custom TRUE/FALSE value rejected when mask type is `boolean`. | -| `Valid Execution, Mask DataType` | kept | 10 `SECTION`s, one per mask-output `DataType` (int8…float64 — no `boolean` `SECTION`; boolean covered via the default mask type used throughout the other `TEST_CASE`s). | +| `Valid Execution, Mask DataType` | kept | 10 `SECTION`s, one per mask-output `DataType` (int8…float64 — no `boolean` `SECTION`; boolean covered via the default mask type used throughout the other `TEST_CASE`s). Modified this cycle: the expected true/false split had been left as a hardcoded literal against a reduced fixture, which made the true branch unreachable and the expected mask all-false — the test asserted nothing. The split is now derived from the fixture constants with a `static_assert` that it lies inside the tuple range. | | `Valid Execution, Input Array DataType` | kept | 11 `SECTION`s, one per **source-array** `DataType` (int8…float64, bool). | -| `SIMPL Backwards Compatibility` | restored | `DYNAMIC_SECTION` over the SIMPL 6.4 and 6.5 conversion fixtures; asserts pipeline conversion round-trips (UUID + one argument value). Re-enabled by `#1605` after a prior segfault. | -| `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`) | restored | 10 numeric-type instantiations; asserts custom TRUE (`25`) / FALSE (`10`) values are actually written to the output mask at execution time — closes the code-path gap on row 26. | +| `SIMPL Backwards Compatibility` | kept | `DYNAMIC_SECTION` over the SIMPL 6.4 and 6.5 conversion fixtures; asserts pipeline conversion round-trips (UUID + one argument value). Re-enabled by `#1605` after a prior segfault. Deleted earlier in this PR and restored during V&V review, so the `#1605` coverage is not lost. | +| `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`) | kept | 10 numeric-type instantiations; asserts custom TRUE (`25`) / FALSE (`10`) values are actually written to the output mask at execution time — closes the code-path gap on row 26. Deleted earlier in this PR and restored during V&V review; `UnitTest::CheckArraysInheritTupleDims` was added at the same time. | **Missing:** nothing outstanding. The last gap — a set mixing a leaf threshold with a sibling nested set (row 25), the shape that triggered `MultiThresholdObjectsFilter-D1` — is closed by the `ArraySet 6` / `ArraySet 7` `SECTION`s of `Valid Threshold Sets`. -**Count basis:** 30 ctest entries = 11 single-entry `TEST_CASE`s (1 ctest entry each) + `Invalid Execution - Out of Bounds Custom Values` (`TEMPLATE_TEST_CASE`, 9 types → 9 entries) + `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`, 10 types → 10 entries), verified by directly reading each declaration's type list twice. `catch_discover_tests` is called with no filtering options in `cmake/Plugin.cmake:404`, so Catch2's default behavior applies: one ctest entry per `TEMPLATE_TEST_CASE` type instantiation. A count of 28 entries cited during review was correct at the commit it was measured against; the two `Exemplar Single Thresholds` `TEST_CASE`s added afterwards account for the difference. 30 matches the test file as it stands today and has been confirmed by `ctest -R MultiThresholdObjects` against a fresh in-core Release build (30/30 pass). +**Count basis:** 30 ctest entries = 11 single-entry `TEST_CASE`s (1 ctest entry each) + `Invalid Execution - Out of Bounds Custom Values` (`TEMPLATE_TEST_CASE`, 9 types → 9 entries) + `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`, 10 types → 10 entries), verified by directly reading each declaration's type list twice. `catch_discover_tests` is called with no filtering options in `cmake/Plugin.cmake:404`, so Catch2's default behavior applies: one ctest entry per `TEMPLATE_TEST_CASE` type instantiation. A count of 28 entries cited during review was correct at the commit it was measured against; the two `Exemplar Single Thresholds` `TEST_CASE`s added afterwards account for the difference. 30 matches the test file as it stands today. + +**Dual-build verification at sign-off:** `ctest -R MultiThresholdObjects` passes 30/30 in **both** builds — in-core (`NX-Com-Qt69-Vtk96-Rel`) and out-of-core (`NX-OOC-Qt69-Vtk95-Rel`). The full `SimplnxCore::` suite also passes 984/984 in-core. The OOC run initially reported 30/30 failures; all were `LoadPlugins()` aborting because stale plugin binaries in that build directory linked an older TBB soname, and all cleared after rebuilding those plugins — no failure was attributable to this filter. ## Exemplar archive @@ -153,9 +164,3 @@ Legacy comparison **run**: independent three-way A/B (DREAM3D 6.5.171 `PipelineR - `MultiThresholdObjectsFilter-D2` — a leaf combined with an inverted nested set used `std::reverse` to flip tuple order instead of flipping each tuple's value on `develop` (51/100 tuples wrong vs. the same legacy filter). **Fixed by this PR**, with in-repo regression coverage (`Valid Threshold Sets` → `ArraySet 7`). - `MultiThresholdObjectsFilter-D3` — multi-component index selection is SIMPLNX-only; legacy `Threshold Objects (Advanced)` rejects non-scalar arrays (`dataCheck()` error `-11003`). Not a bug — a deliberate SIMPLNX capability addition, documented for migration guidance. -## Preflight behavior change in this PR - -This PR removes the preflight check that required every thresholded array to have the same component count, together with its error code `ErrorCodes::UnequalComponents` (`-4001`) in `MultiThresholdObjectsFilter.hpp`. Two consequences worth recording: - -- **Relaxed validation.** A threshold set may now mix arrays with different component counts; each threshold selects its own `componentIndex`, and only the *tuple* counts must agree (still enforced via `ErrorCodes::UnequalTuples`). Pipelines that previously failed preflight with `-4001` will now preflight and execute. This is a deliberate widening of what the filter accepts, not a silent behavior change to existing valid pipelines. -- **Public enum change.** `ErrorCodes` is a public enum in the filter's header, so removing the enumerator is a source-level API break for any downstream plugin that references `MultiThresholdObjectsFilter::ErrorCodes::UnequalComponents`. Nothing in this repository does — verified by grep across `src/` and the plugin tree — but out-of-tree plugins would need to drop the reference. diff --git a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md index 8552559806..e335e1c266 100644 --- a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md @@ -53,7 +53,7 @@ Both bug-fix claims in the PR are real, and the fix restores legacy semantics: l |---|---| | **Deviation ID** | `MultiThresholdObjectsFilter-D1` | | **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | -| **Status** | fixed by this PR | +| **Status** | active (SIMPLNX bug **fixed during this V&V cycle** in PR #1688; documented for users of prior SIMPLNX releases) | **Symptom:** On `develop`, an `ArrayThresholdSet` whose children mix at least one leaf `ArrayThreshold` with at least one nested `ArrayThresholdSet` (e.g. `AB2`: `{leaf: Int32 > 20, nestedSet: (Float32 < 0.60 OR Int32 == 55)}`) produced an all-false mask, regardless of input data. Quantified on the `AB2` fixture: **38 of 100 tuples wrong** (all forced false) vs. legacy `Threshold Objects (Advanced)` and the numpy oracle. @@ -73,7 +73,7 @@ Both bug-fix claims in the PR are real, and the fix restores legacy semantics: l |---|---| | **Deviation ID** | `MultiThresholdObjectsFilter-D2` | | **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | -| **Status** | fixed by this PR | +| **Status** | active (SIMPLNX bug **fixed during this V&V cycle** in PR #1688; documented for users of prior SIMPLNX releases) | **Symptom:** On `develop`, a leaf combined with an inverted nested set (`AB3`: `{leaf: Int32 < 80, invertedNestedSet: NOT(Int32 > 30 AND Float32 < 0.95)}`) produced incorrect mask output. Quantified on the `AB3` fixture: **51 of 100 values differ** vs. legacy `Threshold Objects (Advanced)` and the numpy oracle. `AB3`'s shape overlaps with `D1`'s mixed-leaf/nested-set trigger, so this result is not a clean isolation of the inversion defect alone — both mechanisms plausibly contribute to the discrepancy. @@ -107,8 +107,10 @@ Both bug-fix claims in the PR are real, and the fix restores legacy semantics: l ## Outstanding comparison work -Both legacy filters have now been run separately on representative configurations (`AB1` vs. `Threshold Objects`; `AB2`/`AB3` vs. `Threshold Objects (Advanced)`), satisfying this filter's Rewrite-classification requirement that functional equivalence be independently confirmed against both predecessors, not just one. Remaining lower-priority gaps: +Both legacy filters have now been run separately on representative configurations (`AB1` vs. `Threshold Objects`; `AB2`/`AB3` vs. `Threshold Objects (Advanced)`), satisfying this filter's Rewrite-classification requirement that functional equivalence be independently confirmed against both predecessors, not just one. -1. **Custom TRUE/FALSE mask output values** (`#669` addition) — not exercised by `AB1`–`AB3`. Compare with it left at legacy defaults first, then with custom values set. -2. **Default mask output `DataType`** — SIMPLNX defaults to `uint8` (`#1502`); confirm what each legacy filter's default was and whether migration guidance is needed for pipelines that relied on the default rather than explicitly setting it. -3. **A broader configuration sweep** beyond the three representative `AB1`–`AB3` shapes (e.g., deeper nesting, mixed AND/OR at multiple levels) is optional given the strong quantitative match already obtained at both 100-tuple and 50M-tuple scale, but would further reduce residual risk before COMPLETE status. +The following configurations were **not** run against legacy. All three were reviewed at second-engineer sign-off (2026-08-19) and **accepted as residual risk** rather than treated as blocking gates: each is covered by the Class 1 analytical oracle in the in-repo test suite, and none touches the comparison or set-combination logic that `AB1`–`AB3` exercise. + +1. **Custom TRUE/FALSE mask output values** (`#669` addition) — not exercised by `AB1`–`AB3`. Covered analytically by `Valid Execution - Custom Values` (10 mask types). The custom values are substituted at the final typed write only; they cannot affect the boolean combination that produced the mask. +2. **Default mask output `DataType`** — SIMPLNX defaults to `uint8` (`#1502`); what each legacy filter defaulted to has not been confirmed. Affects migration guidance for pipelines that relied on the default rather than setting it explicitly, not correctness of the mask itself. `Valid Execution, Mask DataType` covers all 10 non-boolean output types analytically. +3. **A broader configuration sweep** beyond the three representative `AB1`–`AB3` shapes (e.g. deeper nesting, mixed AND/OR at multiple levels) — optional given the exact match already obtained at both 100-tuple and 50M-tuple scale, and given that `Valid Threshold Sets` now enumerates seven set shapes including both mixed leaf/nested-set forms. From 1c0f8d4e5367acd9b6c957cc3eec44ce11709c28 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 19 Aug 2026 18:35:27 -0400 Subject: [PATCH 27/28] BUG: Preserve nested threshold sets when converting SIMPL pipelines Closing the last three legacy-comparison gaps for the MultiThresholdObjects V&V surfaced a bug in the SIMPL conversion path, pre-existing on develop. ComparisonSelectionAdvancedFilterParameterConverter flattened every nested ComparisonSet into its parent's threshold list, and convertArrayThreshold never set a leaf's union operator, so each converted leaf fell back to the ArrayThreshold default of And. Together these turned a legacy "A AND (B OR C)" pipeline into "A AND B AND C". Quantified against real legacy output on three new fixtures: 55/100, 50/100 and 4/100 tuples wrong; the last degenerated to an all-false mask. Only pipelines converted from SIMPL were affected -- natively authored SIMPLNX pipelines were always correct. The flattening pass also tried to push an inverted set's inversion down onto its leaves by flipping each comparison operator. That is not a valid negation: NOT(x > v) is x >= v rather than x < v, De Morgan's AND/OR flip was never applied, and Operator_NotEqual fell through unchanged. The converter now keeps the nested ArrayThresholdSet that convertSetThreshold was already building, and reads each leaf's union operator from the legacy JSON. flattenSetThreshold and invertComparison are removed; set inversion is handled natively by the algorithm, which is what this PR's rewrite made correct. Without this, the nested-set support added to the algorithm was unreachable from any converted legacy pipeline. Adds the SIMPL Nested Set Conversion test and its fixture, asserting that a converted leaf-plus-inverted-nested-set keeps its nesting, inversion flag, swapped comparison operators and per-leaf union operators. Verified load-bearing against the pre-fix converter. V&V documents record the closing pass: AB4-AB6 (three-level nesting, an inverted sibling set, and a grouping-sensitive case) all MATCH across legacy, native SIMPLNX, an independent numpy oracle, and the converted-pipeline path. Custom TRUE/FALSE values and the mask output DataType are closed by inspection -- both legacy filters hard-code DataArray and have neither parameter -- and are recorded as deviations D5 and D6. No legacy-comparison work remains outstanding. Co-Authored-By: Claude Opus 5 --- .../test/MultiThresholdObjectsTest.cpp | 59 ++++++++++++ .../MultiThresholdObjectsFilter_Nested.json | 44 +++++++++ .../vv/MultiThresholdObjectsFilter.md | 36 +++++--- .../deviations/MultiThresholdObjectsFilter.md | 92 +++++++++++++++++-- .../Parameters/ArrayThresholdsParameter.cpp | 65 ++----------- 5 files changed, 216 insertions(+), 80 deletions(-) create mode 100644 src/Plugins/SimplnxCore/test/simpl_conversion/6_5/MultiThresholdObjectsFilter_Nested.json diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index e6600e85bb..a28fcac241 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -1331,6 +1331,65 @@ TEST_CASE("SimplnxCore::MultiThresholdObjects: Valid Execution, Input Array Data UnitTest::CheckArraysInheritTupleDims(dataStructure); } +TEST_CASE("SimplnxCore::MultiThresholdObjectsFilter: SIMPL Nested Set Conversion", "[SimplnxCore][MultiThresholdObjectsFilter][BackwardsCompatibility]") +{ + auto app = Application::GetOrCreateInstance(); + UnitTest::LoadPlugins(); + auto filterList = app->getFilterList(); + + // A legacy "Threshold Objects (Advanced)" pipeline whose thresholds are a leaf sibling to an inverted + // nested set, with the nested set's children joined by Or: + // + // Int32 > 20 AND NOT( Float32 < 0.8 OR Int32 == 55 ) + // + // The conversion used to flatten the nested set into the parent list and drop every union operator, which + // silently turned this into a four-way AND over the leaves. Both the nesting and the Or must survive. + const fs::path fixturePath = fs::path(nx::core::unit_test::k_SourceDir.view()) / "test" / "simpl_conversion" / "6_5" / "MultiThresholdObjectsFilter_Nested.json"; + + auto pipelineResult = Pipeline::FromSIMPLFile(fixturePath, filterList); + REQUIRE(pipelineResult.valid()); + + auto& pipeline = pipelineResult.value(); + REQUIRE(pipeline.size() == 1); + + auto* pipelineFilter = dynamic_cast(pipeline.at(0)); + REQUIRE(pipelineFilter != nullptr); + + const Arguments args = pipelineFilter->getArguments(); + auto thresholds = args.value(MultiThresholdObjectsFilter::k_ArrayThresholdsObject_Key); + + auto topLevel = thresholds.getArrayThresholds(); + REQUIRE(topLevel.size() == 2); + + // Child 0: the leaf. SIMPL encodes GreaterThan as 1 and SIMPLNX as 0, so the operator must be swapped. + auto leaf = std::dynamic_pointer_cast(topLevel[0]); + REQUIRE(leaf != nullptr); + CHECK(leaf->getComparisonType() == ArrayThreshold::ComparisonType::GreaterThan); + CHECK(leaf->getComparisonValue() == 20.0); + CHECK(leaf->getArrayPath() == DataPath({"DataContainer", "CellData", "Int32"})); + + // Child 1: the nested set must still be a set, still inverted, and still hold both of its own children. + auto nestedSet = std::dynamic_pointer_cast(topLevel[1]); + REQUIRE(nestedSet != nullptr); + CHECK(nestedSet->isInverted()); + CHECK(nestedSet->getUnionOperator() == IArrayThreshold::UnionOperator::And); + + auto nestedChildren = nestedSet->getArrayThresholds(); + REQUIRE(nestedChildren.size() == 2); + + auto nestedFirst = std::dynamic_pointer_cast(nestedChildren[0]); + REQUIRE(nestedFirst != nullptr); + CHECK(nestedFirst->getComparisonType() == ArrayThreshold::ComparisonType::LessThan); + CHECK(nestedFirst->getComparisonValue() == 0.8); + + // The Or joining the nested set's children is the part the old flattening pass discarded. + auto nestedSecond = std::dynamic_pointer_cast(nestedChildren[1]); + REQUIRE(nestedSecond != nullptr); + CHECK(nestedSecond->getComparisonType() == ArrayThreshold::ComparisonType::Operator_Equal); + CHECK(nestedSecond->getComparisonValue() == 55.0); + CHECK(nestedSecond->getUnionOperator() == IArrayThreshold::UnionOperator::Or); +} + TEST_CASE("SimplnxCore::MultiThresholdObjectsFilter: SIMPL Backwards Compatibility", "[SimplnxCore][MultiThresholdObjectsFilter][BackwardsCompatibility]") { auto app = Application::GetOrCreateInstance(); diff --git a/src/Plugins/SimplnxCore/test/simpl_conversion/6_5/MultiThresholdObjectsFilter_Nested.json b/src/Plugins/SimplnxCore/test/simpl_conversion/6_5/MultiThresholdObjectsFilter_Nested.json new file mode 100644 index 0000000000..bbeec87752 --- /dev/null +++ b/src/Plugins/SimplnxCore/test/simpl_conversion/6_5/MultiThresholdObjectsFilter_Nested.json @@ -0,0 +1,44 @@ +{ + "PipelineBuilder": { + "Name": "Multi Threshold Objects nested-set conversion fixture", + "Number_Filters": 1, + "Version": 6 + }, + "0": { + "Filter_Enabled": true, + "Filter_Human_Label": "Multi Threshold Objects (Advanced)", + "Filter_Name": "MultiThresholdObjects2", + "Filter_Uuid": "{686d5393-2b02-5c86-b887-dd81a8ae80f2}", + "SelectedThresholds": { + "Data Container Name": "DataContainer", + "Attribute Matrix Name": "CellData", + "Thresholds": [ + { + "Union Operator": 0, + "Attribute Array Name": "Int32", + "Comparison Operator": 1, + "Comparison Value": 20.0 + }, + { + "Union Operator": 0, + "Invert Comparison": true, + "Comparison Values": [ + { + "Union Operator": 1, + "Attribute Array Name": "Float32", + "Comparison Operator": 0, + "Comparison Value": 0.8 + }, + { + "Union Operator": 1, + "Attribute Array Name": "Int32", + "Comparison Operator": 2, + "Comparison Value": 55.0 + } + ] + } + ] + }, + "DestinationArrayName": "TestName" + } +} diff --git a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md index 9ec887a90a..5304b99039 100644 --- a/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/MultiThresholdObjectsFilter.md @@ -14,17 +14,17 @@ | Aspect | Current state | |------------------------|------------------------------------------------------------------------------------------------------------------------------| | Algorithm Relationship | **Rewrite.** Consolidates two independently-shipped legacy filters — **Threshold Objects** (flat, AND-only) and **Threshold Objects (Advanced)** (nested AND/OR sets) — into one SIMPLNX filter under one new UUID, unified around a single `ArrayThresholdSet` model. Not a line-by-line translation of either legacy source. | -| Oracle (confirmed) | **Class 1 (Analytical) — confirmed.** `expected[i] = COMPARISON(input[i], value)`, hand-combined via AND/OR/invert boolean algebra. Encoded across 13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations in `MultiThresholdObjectsTest.cpp`, all pass. | -| Code paths enumerated | **25 of 26 exercised.** Row 13 (unreachable comparison-operator `else`-throw) is a permanent, acceptable gap and the only one remaining. Row 25 (a set mixing a leaf threshold with a nested set — the `MultiThresholdObjectsFilter-D1` trigger shape) is now covered by the `ArraySet 6` / `ArraySet 7` `SECTION`s of `Valid Threshold Sets`. | -| Tests today | **13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 30 ctest entries**, all passing in both the in-core and out-of-core Release builds (11 single-entry TEST_CASEs + 2 `TEMPLATE_TEST_CASE`s instantiated over 9 and 10 types respectively). Exhaustive sweeps over comparison operator × invert × union operator × set nesting × mask `DataType` (10 types, plus boolean covered elsewhere) × source-array `DataType` (11 types) × custom TRUE/FALSE execution (10 types), plus negative/error-path groups and a SIMPL backwards-compatibility check. All fixtures built in-memory. | +| Oracle (confirmed) | **Class 1 (Analytical) — confirmed.** `expected[i] = COMPARISON(input[i], value)`, hand-combined via AND/OR/invert boolean algebra. Encoded across 14 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations in `MultiThresholdObjectsTest.cpp`, all pass. | +| Code paths enumerated | **26 of 27 exercised.** Row 13 (unreachable comparison-operator `else`-throw) is a permanent, acceptable gap and the only one remaining. Row 25 (a set mixing a leaf threshold with a nested set — the `MultiThresholdObjectsFilter-D1` trigger shape) is now covered by the `ArraySet 6` / `ArraySet 7` `SECTION`s of `Valid Threshold Sets`. | +| Tests today | **14 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 31 ctest entries**, all passing in both the in-core and out-of-core Release builds (12 single-entry TEST_CASEs + 2 `TEMPLATE_TEST_CASE`s instantiated over 9 and 10 types respectively). Exhaustive sweeps over comparison operator × invert × union operator × set nesting × mask `DataType` (10 types, plus boolean covered elsewhere) × source-array `DataType` (11 types) × custom TRUE/FALSE execution (10 types), plus negative/error-path groups and a SIMPL backwards-compatibility check. All fixtures built in-memory. | | Exemplar archive | **None.** All fixtures are constructed in-memory by `CreateTestDataStructure()` / `CreateTestDataStructure2()`; no `.dream3d` exemplar or `download_test_data()` entry exists for this filter. | -| Legacy comparison | **Run.** Independent three-way A/B (DREAM3D 6.5.171 `PipelineRunner` vs. this branch's `nxrunner` vs. an independent numpy oracle) on a shared 100-tuple fixture, covering flat/basic (`MultiThresholdObjects`), nested, and inverted-nested (`MultiThresholdObjects2`) configurations, plus a 50M-tuple scale re-run of all three. Post-fix: all three MATCH across all cases at both scales. Pre-fix (`develop`): 2 of 3 configs diverged (38/100 and 51/100 tuples wrong) — see `MultiThresholdObjectsFilter-D1`/`-D2`. | -| Bug flags | **Two, both fixed by this PR, both quantified against real legacy output.** `MultiThresholdObjectsFilter-D1` — a set combining a leaf threshold with a sibling nested set produced an all-false mask (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`) on `develop`. `MultiThresholdObjectsFilter-D2` — an inverted nested set used `std::reverse` to flip tuple *order* instead of each tuple's value (51/100 tuples wrong vs. the same legacy filter) on `develop`. Both now have in-repo regression coverage (`Valid Threshold Sets` → `ArraySet 6` / `ArraySet 7`). See `vv/deviations/MultiThresholdObjectsFilter.md`. | -| V&V phase | Discovery, algorithm relationship, oracle design + reconciliation, algorithm review (fixes applied and re-verified), test inventory, legacy comparison, deviations — **complete**. Oracle chosen and applied (Class 1, corroborated by an independent numpy oracle in the legacy A/B), code paths enumerated (25/26), legacy A/B run and MATCH at both 100-tuple and 50M-tuple scale, 3 deviations documented (`D1`/`D2` bugs fixed by this PR with in-repo regression tests, `D3` confirmed non-bug capability difference). Second-engineer review of the oracle design and this report **signed off by Michael A. Jackson, 2026-08-19** (PR #1688). Two lower-priority legacy A/B configurations remain unrun (custom TRUE/FALSE values, default mask `DataType`) — accepted as residual risk at sign-off and tracked in the Deviations file, not gating. | +| Legacy comparison | **Run, and now complete — no configurations outstanding.** Independent three-way A/B (DREAM3D 6.5.171 `PipelineRunner` vs. this branch's `nxrunner` vs. an independent numpy oracle) across six configurations: flat/basic (`AB1`, vs. `MultiThresholdObjects`), nested and inverted-nested (`AB2`/`AB3`), and three deeper shapes added at second-engineer review — three-level nesting, an inverted sibling set, and a grouping-sensitive case (`AB4`–`AB6`, vs. `MultiThresholdObjects2`) — plus a 50M-tuple scale re-run of `AB1`–`AB3`. All sources MATCH in every case. Pre-fix (`develop`): `AB2`/`AB3` diverged (38/100 and 51/100 tuples wrong) — see `-D1`/`-D2`. `AB4`–`AB6` were additionally run through SIMPLNX's SIMPL-pipeline conversion, which surfaced `-D4`. | +| Bug flags | **Three, all fixed by this PR, all quantified against real legacy output, all with in-repo regression coverage.** `-D1` — a set combining a leaf threshold with a sibling nested set produced an all-false mask (38/100 tuples wrong) on `develop`. `-D2` — an inverted nested set used `std::reverse` to flip tuple *order* instead of each tuple's value (51/100 wrong) on `develop`. `-D4` — **found at second-engineer review**: converting a legacy pipeline with a nested threshold set flattened the nesting and reset every union operator to AND, so converted `Threshold Objects (Advanced)` pipelines computed a plain AND over all leaves (55/100, 50/100 and 4/100 wrong on `AB4`–`AB6`); pre-existing on `develop`, in shared `ArrayThresholdsParameter` conversion code. See `vv/deviations/MultiThresholdObjectsFilter.md`. | +| V&V phase | Discovery, algorithm relationship, oracle design + reconciliation, algorithm review (fixes applied and re-verified), test inventory, legacy comparison, deviations — **complete**. Oracle chosen and applied (Class 1, corroborated by an independent numpy oracle in the legacy A/B), code paths enumerated (26/27), legacy A/B run and MATCH at both 100-tuple and 50M-tuple scale, 6 deviations documented (`D1`/`D2`/`D4` bugs fixed by this PR, each with an in-repo regression test; `D3`/`D5`/`D6` confirmed non-bug capability differences). Second-engineer review of the oracle design and this report **signed off by Michael A. Jackson, 2026-08-19** (PR #1688), which also closed the last three outstanding legacy-comparison items and found `D4`. **Nothing outstanding.** | ## Summary -`MultiThresholdObjectsFilter` builds a typed mask array by elementwise-comparing one or more input arrays against user-supplied thresholds, combined through an arbitrarily-nested tree of AND/OR/invert `ArrayThresholdSet`s. Verification uses a **Class 1 (Analytical) oracle**: every comparison operator, invert flag, union operator, nesting depth, custom TRUE/FALSE execution, and both the mask-output and source-input `DataType` are exhaustively hand-derived and asserted in `MultiThresholdObjectsTest.cpp` (13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations, all passing). 25 of 26 algorithm/preflight code paths are exercised. An independent three-way runtime A/B (legacy DREAM3D 6.5.171, this branch, and a numpy oracle) against both legacy predecessors — at 100 tuples and again at 50M tuples — confirms the current implementation matches legacy exactly, and quantifies two real bugs that were present on `develop` and are fixed by this PR: `MultiThresholdObjectsFilter-D1` (all-false mask when a set mixes a leaf threshold with a nested set, 38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (`std::reverse`-based tuple-order corruption in an inverted nested set instead of per-value inversion, 51/100 tuples wrong). Both now have dedicated in-repo regression coverage (`Valid Threshold Sets` → `ArraySet 6` / `ArraySet 7`), each verified to fail when the algorithm is stubbed out. A third, non-bug deviation (`MultiThresholdObjectsFilter-D3`) documents that multi-component index selection is SIMPLNX-only — legacy `Threshold Objects (Advanced)` rejects non-scalar arrays outright. +`MultiThresholdObjectsFilter` builds a typed mask array by elementwise-comparing one or more input arrays against user-supplied thresholds, combined through an arbitrarily-nested tree of AND/OR/invert `ArrayThresholdSet`s. Verification uses a **Class 1 (Analytical) oracle**: every comparison operator, invert flag, union operator, nesting depth, custom TRUE/FALSE execution, and both the mask-output and source-input `DataType` are exhaustively hand-derived and asserted in `MultiThresholdObjectsTest.cpp` (14 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations, all passing). 26 of 27 algorithm, preflight and SIMPL-conversion code paths are exercised. An independent three-way runtime A/B (legacy DREAM3D 6.5.171, this branch, and a numpy oracle) against both legacy predecessors — at 100 tuples and again at 50M tuples — confirms the current implementation matches legacy exactly, and quantifies two real bugs that were present on `develop` and are fixed by this PR: `MultiThresholdObjectsFilter-D1` (all-false mask when a set mixes a leaf threshold with a nested set, 38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (`std::reverse`-based tuple-order corruption in an inverted nested set instead of per-value inversion, 51/100 tuples wrong). Both now have dedicated in-repo regression coverage (`Valid Threshold Sets` → `ArraySet 6` / `ArraySet 7`), each verified to fail when the algorithm is stubbed out. Second-engineer review extended the A/B to three deeper shapes (`AB4`–`AB6`) and to SIMPLNX's own SIMPL-pipeline conversion path, which surfaced a third bug, `MultiThresholdObjectsFilter-D4`: converting a legacy pipeline containing a nested threshold set flattened the nesting and reset every union operator to AND. Three further deviations (`-D3`, `-D5`, `-D6`) record SIMPLNX-only capabilities with no legacy equivalent — multi-component index selection, a selectable mask output `DataType`, and custom TRUE/FALSE values. ## Algorithm Relationship @@ -58,6 +58,7 @@ 1. **Combination path consolidated.** The dual apply strategy (direct copy for the first item in a set, AND/OR combine for later items) and the redundant inversion parameter were replaced by a single `ComputeThresholdSet` → `ApplyThresholdValues` → `InsertThreshold` path. **Changes output** — this is the D1/D2 fix, and it restores legacy semantics. 2. **Component-count preflight relaxed.** The check requiring every thresholded array to have the same component count is removed, along with its error code `ErrorCodes::UnequalComponents` (`-4001`) in `MultiThresholdObjectsFilter.hpp`. A threshold set may now mix arrays with different component counts; each threshold selects its own `componentIndex`, and only the *tuple* counts must agree (still enforced via `ErrorCodes::UnequalTuples`). **Does not change output** for any pipeline that previously passed preflight — it widens what is accepted. Pipelines that previously failed with `-4001` now preflight and execute. Removing the enumerator from the public `ErrorCodes` enum is a source-level API break for any out-of-tree plugin referencing `MultiThresholdObjectsFilter::ErrorCodes::UnequalComponents`; nothing in this repository does (verified by grep across `src/` and the plugin tree). 3. **Intermediate storage moved from `std::vector` to `AbstractDataStore`.** One store per nesting level, allocated through `DataStoreUtilities::CreateDataStore`. **Does not change output** — an out-of-core-readiness change only. +4. **SIMPL nested-set conversion no longer flattens.** `ComparisonSelectionAdvancedFilterParameterConverter` now preserves nested `ArrayThresholdSet`s and sets each leaf's union operator from the legacy JSON, instead of collapsing the tree and defaulting every leaf to AND. **Changes output** for converted legacy pipelines — this is the `MultiThresholdObjectsFilter-D4` fix. Added here rather than deferred because the nested-set support this PR adds to the algorithm was unreachable from a converted legacy pipeline without it. ## Oracle @@ -76,9 +77,10 @@ - `Valid Execution, Input Array DataType` — 11 source-array `DataType`s - `Valid Execution - Custom Values` — 10 mask `DataType`s, custom TRUE/FALSE values applied at execution time (not just preflight bounds-checked) - `SIMPL Backwards Compatibility` — SIMPL 6.4/6.5 argument-conversion round-trip +- `SIMPL Nested Set Conversion` — a converted legacy nested threshold set keeps its nesting, inversion flag and union operators - `Invalid Execution`, `Invalid Execution - Out of Bounds Custom Values` (9 numeric types), `Invalid Execution - Boolean Custom Values` — negative-path fixtures -13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations, all pass at HEAD. +14 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations, all pass at HEAD. *Second-engineer review:* **Signed off by Michael A. Jackson , 2026-08-19** (PR #1688 review, per sign-off convention). The V&V work was authored by Matthew Marine, so the review is independent of the author. Reviewed across two passes (2026-07-24 and 2026-08-12) plus this closing pass: @@ -86,14 +88,15 @@ - **Oracle/implementation reconciliation.** The oracle originally compared in `float64` while the implementation truncated the comparison value to the array type. The divergence was masked by the fixture holding only values `0`–`4`; raising the tuple count exposed it. Resolved in favor of the implementation, which matches legacy — the `Expected*Mask` helpers now model the truncation (see *Comparison-value truncation* above). - **Test load-bearing check.** `Valid Execution, Mask DataType` was found to assert nothing (a hardcoded true/false split had gone stale against a reduced fixture, leaving the true branch unreachable and the expected mask all-false, which the zero-initialized output already satisfied). Verified fixed by stubbing `MultiThresholdObjects::operator()()` to `return {}` — all 10 `SECTION`s now fail. The same stub check was applied to the new `ArraySet 6` / `ArraySet 7` regression fixtures. - **Code path coverage.** Row 25 was independently confirmed to be a genuine gap — every `CreateThresholdSet*` helper passed either all leaves or all nested sets, never a mix — and is now closed. +- **Closing pass — legacy comparison completed.** The three configurations previously carried as outstanding were closed: custom TRUE/FALSE values and the mask output `DataType` by direct inspection of the legacy sources (neither parameter exists in either legacy filter — recorded as `D6` and `D5`), and the broader configuration sweep by running `AB4`–`AB6` against the legacy `PipelineRunner`. Running those three through SIMPLNX's own SIMPL-pipeline conversion — a path no prior fixture had exercised — surfaced `MultiThresholdObjectsFilter-D4`, fixed here. ## Code path coverage -**25 of 26 paths exercised.** Row 13 is a permanent, acceptable gap and the only one remaining. Row 25 was the gap that let `MultiThresholdObjectsFilter-D1` ship; it is now covered — see `vv/deviations/MultiThresholdObjectsFilter.md`. +**26 of 27 paths exercised.** Row 13 is a permanent, acceptable gap and the only one remaining. Row 25 was the gap that let `MultiThresholdObjectsFilter-D1` ship and row 27 the gap that let `-D4` ship; both are now covered — see `vv/deviations/MultiThresholdObjectsFilter.md`. -Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp` (~320 lines), plus 7 preflight-only paths in `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp`. +Source: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/MultiThresholdObjects.cpp` (~320 lines), plus 7 preflight-only paths in `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/MultiThresholdObjectsFilter.cpp` and the SIMPL-conversion path in `src/simplnx/Parameters/ArrayThresholdsParameter.cpp`. -Two logical stages: **(a) preflight** validates the threshold set / mask-type / custom-value configuration and stages the output `CreateArrayAction`; **(b) algorithm** recursively evaluates the `ArrayThresholdSet` tree into an internal `bool` mask (per-array comparison combined inline by `ThresholdFilterHelper`, which selects an AND / OR / replace loop once per threshold, then `InsertThreshold`/`ApplyThresholdValues` for cross-node combination) and writes the result into the typed mask array via `ThresholdSetFunctor`, substituting `trueValue`/`falseValue` at that final write. +Three logical stages: **(a) preflight** validates the threshold set / mask-type / custom-value configuration and stages the output `CreateArrayAction`; **(b) algorithm** recursively evaluates the `ArrayThresholdSet` tree into an internal `bool` mask (per-array comparison combined inline by `ThresholdFilterHelper`, which selects an AND / OR / replace loop once per threshold, then `InsertThreshold`/`ApplyThresholdValues` for cross-node combination) and writes the result into the typed mask array via `ThresholdSetFunctor`, substituting `trueValue`/`falseValue` at that final write; **(c) SIMPL conversion** maps a legacy `MultiThresholdObjects` / `MultiThresholdObjects2` pipeline onto this filter's arguments via `FromSIMPLJson()` and the `ArrayThresholdsParameter` converters. | # | Stage | Path | Test case | |----|-------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| @@ -123,6 +126,7 @@ Two logical stages: **(a) preflight** validates the threshold set / mask-type / | 24 | (b) Algorithm | Multi-component `componentIndex != 0` selection | `Valid Single Thresholds: Int Multi-Component` (`componentIndex = GENERATE(0,1,2)`), plus `componentIndex=1` in Set1, `=0` in Set2 | | 25 | (b) Algorithm | An `ArrayThresholdSet` whose children mix at least one leaf `ArrayThreshold` with at least one nested `ArrayThresholdSet` (e.g. `{leaf, nestedSet}`, not `{leaf, leaf, leaf}` or `{set, set}`). Historically produced an all-false mask regardless of input (`MultiThresholdObjectsFilter-D1`, confirmed against legacy `Threshold Objects (Advanced)` — 38/100 tuples wrong on `develop`), fixed by this PR. | `Valid Threshold Sets` → `ArraySet 6: leaf + nested set` (`CreateThresholdSet6`, a leaf `Int > 2` sibling to a nested set) and `ArraySet 7: leaf + inverted nested set` (`CreateThresholdSet7`, the `AB3` shape), each × `isInverted`. Both were confirmed to fail when `MultiThresholdObjects::operator()()` is stubbed to `return {}` — i.e. they detect the all-false mask that D1 produced. Also confirmed externally by the `AB2`/`AB3` legacy A/B fixtures (see Deviations file), which are not part of the ctest suite. | | 26 | (b) Algorithm | Custom TRUE/FALSE value substitution at execution time (`ThresholdSetFunctor` writing `trueValue`/`falseValue` from `MultiThresholdObjects::operator()`, not the default `1.0`/`0.0`) — distinct from rows 6–7, which only cover the preflight bounds-check rejecting *out-of-range* custom values and never actually execute with valid ones | `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`, 10 numeric mask types), asserting `trueValue`/`falseValue` (25/10 in the test) appear in the output instead of 1/0 | +| 27 | (c) SIMPL conversion | `ComparisonSelectionAdvancedFilterParameterConverter` on a legacy pipeline containing a *nested* `ComparisonSet` — nesting, inversion flag and per-leaf union operators must all survive conversion (`MultiThresholdObjectsFilter-D4`) | `SIMPL Nested Set Conversion`, plus the `AB4`–`AB6` converted-pipeline runs recorded in the Deviations file | Not counted as an algorithm/preflight path: the "Empty ArrayThreshold DataPath" section of `Invalid Execution` exercises `ArrayThresholdsParameter`'s own path-existence validation, which runs before `preflightImpl` is called — it's a parameter-layer gate, not code inside this filter or algorithm. @@ -143,14 +147,15 @@ Not counted as an algorithm/preflight path: the "Empty ArrayThreshold DataPath" | `Invalid Execution - Boolean Custom Values` | kept | 2 `SECTION`s — custom TRUE/FALSE value rejected when mask type is `boolean`. | | `Valid Execution, Mask DataType` | kept | 10 `SECTION`s, one per mask-output `DataType` (int8…float64 — no `boolean` `SECTION`; boolean covered via the default mask type used throughout the other `TEST_CASE`s). Modified this cycle: the expected true/false split had been left as a hardcoded literal against a reduced fixture, which made the true branch unreachable and the expected mask all-false — the test asserted nothing. The split is now derived from the fixture constants with a `static_assert` that it lies inside the tuple range. | | `Valid Execution, Input Array DataType` | kept | 11 `SECTION`s, one per **source-array** `DataType` (int8…float64, bool). | +| `SIMPL Nested Set Conversion` | new-for-V&V | Converts a legacy `MultiThresholdObjects2` pipeline whose thresholds are a leaf sibling to an *inverted* nested set with OR-joined children, and asserts the converted `ArrayThresholdSet` keeps the nesting, the inversion flag, the swapped comparison operators, and the children's union operators. Added for `MultiThresholdObjectsFilter-D4`; verified load-bearing (fails against the pre-fix converter). Fixture: `test/simpl_conversion/6_5/MultiThresholdObjectsFilter_Nested.json`. | | `SIMPL Backwards Compatibility` | kept | `DYNAMIC_SECTION` over the SIMPL 6.4 and 6.5 conversion fixtures; asserts pipeline conversion round-trips (UUID + one argument value). Re-enabled by `#1605` after a prior segfault. Deleted earlier in this PR and restored during V&V review, so the `#1605` coverage is not lost. | | `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`) | kept | 10 numeric-type instantiations; asserts custom TRUE (`25`) / FALSE (`10`) values are actually written to the output mask at execution time — closes the code-path gap on row 26. Deleted earlier in this PR and restored during V&V review; `UnitTest::CheckArraysInheritTupleDims` was added at the same time. | **Missing:** nothing outstanding. The last gap — a set mixing a leaf threshold with a sibling nested set (row 25), the shape that triggered `MultiThresholdObjectsFilter-D1` — is closed by the `ArraySet 6` / `ArraySet 7` `SECTION`s of `Valid Threshold Sets`. -**Count basis:** 30 ctest entries = 11 single-entry `TEST_CASE`s (1 ctest entry each) + `Invalid Execution - Out of Bounds Custom Values` (`TEMPLATE_TEST_CASE`, 9 types → 9 entries) + `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`, 10 types → 10 entries), verified by directly reading each declaration's type list twice. `catch_discover_tests` is called with no filtering options in `cmake/Plugin.cmake:404`, so Catch2's default behavior applies: one ctest entry per `TEMPLATE_TEST_CASE` type instantiation. A count of 28 entries cited during review was correct at the commit it was measured against; the two `Exemplar Single Thresholds` `TEST_CASE`s added afterwards account for the difference. 30 matches the test file as it stands today. +**Count basis:** 31 ctest entries = 12 single-entry `TEST_CASE`s (1 ctest entry each) + `Invalid Execution - Out of Bounds Custom Values` (`TEMPLATE_TEST_CASE`, 9 types → 9 entries) + `Valid Execution - Custom Values` (`TEMPLATE_TEST_CASE`, 10 types → 10 entries), verified by directly reading each declaration's type list twice. `catch_discover_tests` is called with no filtering options in `cmake/Plugin.cmake:404`, so Catch2's default behavior applies: one ctest entry per `TEMPLATE_TEST_CASE` type instantiation. A count of 28 entries cited during review was correct at the commit it was measured against; the two `Exemplar Single Thresholds` `TEST_CASE`s added afterwards account for the difference. 31 matches the test file as it stands today. -**Dual-build verification at sign-off:** `ctest -R MultiThresholdObjects` passes 30/30 in **both** builds — in-core (`NX-Com-Qt69-Vtk96-Rel`) and out-of-core (`NX-OOC-Qt69-Vtk95-Rel`). The full `SimplnxCore::` suite also passes 984/984 in-core. The OOC run initially reported 30/30 failures; all were `LoadPlugins()` aborting because stale plugin binaries in that build directory linked an older TBB soname, and all cleared after rebuilding those plugins — no failure was attributable to this filter. +**Dual-build verification at sign-off:** `ctest -R MultiThresholdObjects` passes 31/31 in **both** builds — in-core (`NX-Com-Qt69-Vtk96-Rel`) and out-of-core (`NX-OOC-Qt69-Vtk95-Rel`). The full `SimplnxCore::` suite also passes 984/984 in-core. The OOC run initially reported all entries failing; all were `LoadPlugins()` aborting because stale plugin binaries in that build directory linked an older TBB soname, and all cleared after rebuilding those plugins — no failure was attributable to this filter. ## Exemplar archive @@ -158,9 +163,12 @@ None. All fixtures for this filter are constructed in-memory in `test/MultiThres ## Deviations from DREAM3D 6.5.171 -Legacy comparison **run**: independent three-way A/B (DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and a numpy oracle) on flat, nested, and inverted-nested configurations at 100 tuples and again at 50M tuples. Post-fix, all three sources MATCH in every case. Full record in `vv/deviations/MultiThresholdObjectsFilter.md`. +Legacy comparison **run and complete**: independent three-way A/B (DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and a numpy oracle) across six configurations — flat, nested, inverted-nested, three-level nested, inverted sibling set, and a grouping-sensitive case — at 100 tuples, with the first three re-run at 50M tuples. All sources MATCH in every case. No configurations remain outstanding. Full record in `vv/deviations/MultiThresholdObjectsFilter.md`. - `MultiThresholdObjectsFilter-D1` — a set mixing a leaf threshold with a sibling nested set produced an all-false mask on `develop` (38/100 tuples wrong vs. legacy `Threshold Objects (Advanced)`). **Fixed by this PR**, with in-repo regression coverage (`Valid Threshold Sets` → `ArraySet 6`). - `MultiThresholdObjectsFilter-D2` — a leaf combined with an inverted nested set used `std::reverse` to flip tuple order instead of flipping each tuple's value on `develop` (51/100 tuples wrong vs. the same legacy filter). **Fixed by this PR**, with in-repo regression coverage (`Valid Threshold Sets` → `ArraySet 7`). - `MultiThresholdObjectsFilter-D3` — multi-component index selection is SIMPLNX-only; legacy `Threshold Objects (Advanced)` rejects non-scalar arrays (`dataCheck()` error `-11003`). Not a bug — a deliberate SIMPLNX capability addition, documented for migration guidance. +- `MultiThresholdObjectsFilter-D4` — converting a legacy pipeline containing a nested threshold set flattened the nesting and reset every union operator to AND, so a converted `Threshold Objects (Advanced)` pipeline computed a plain AND over all leaves (55/100, 50/100 and 4/100 tuples wrong on `AB4`–`AB6`). Pre-existing on `develop`, in `src/simplnx/Parameters/ArrayThresholdsParameter.cpp`. **Fixed by this PR**, with in-repo regression coverage (`SIMPL Nested Set Conversion`). +- `MultiThresholdObjectsFilter-D5` — the mask output `DataType` is selectable in SIMPLNX and defaults to `uint8`; both legacy filters always created a `DataArray`. Values are identical, the array type is not. Not a bug — set **Mask Type** to `boolean` when bit-for-bit legacy parity of the output file matters. +- `MultiThresholdObjectsFilter-D6` — custom TRUE/FALSE mask values are SIMPLNX-only; neither legacy filter has the parameter. Not a bug; the defaults (1/0) reproduce legacy behavior. diff --git a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md index e335e1c266..8b4c516aa6 100644 --- a/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md +++ b/src/Plugins/SimplnxCore/vv/deviations/MultiThresholdObjectsFilter.md @@ -10,11 +10,13 @@ Entries are referenced by stable ID (`MultiThresholdObjectsFilter-D`) from th ## Headline -**3 deviations documented: 2 bugs (both in SIMPLNX, both fixed by this PR, both with in-repo regression tests), 1 confirmed non-bug capability difference.** Legacy comparison has been **run**: an independent three-way A/B — DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and an independent numpy oracle — on a shared 100-tuple fixture, covering representative flat (`Threshold Objects`), nested, and inverted-nested (`Threshold Objects (Advanced)`) configurations, re-run again at 50M tuples. Post-fix, all three sources MATCH in every case at both scales. The in-repo `MultiThresholdObjectsTest.cpp` suite (13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 30 ctest entries — see the V&V report's Test inventory for the count basis) also passes locally. +**6 deviations documented: 3 bugs (all in SIMPLNX, all fixed by this PR, all with in-repo regression tests), 3 confirmed non-bug capability differences.** Legacy comparison has been **run**: an independent three-way A/B — DREAM3D 6.5.171 `PipelineRunner`, this branch's `nxrunner`, and an independent numpy oracle — on a shared 100-tuple fixture, covering representative flat (`Threshold Objects`), nested, and inverted-nested (`Threshold Objects (Advanced)`) configurations, re-run again at 50M tuples. Post-fix, all three sources MATCH in every case at both scales. The in-repo `MultiThresholdObjectsTest.cpp` suite (14 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 31 ctest entries — see the V&V report's Test inventory for the count basis) also passes locally. The same three pipelines run against `develop` (pre-fix) reproduce two real bugs quantitatively: `MultiThresholdObjectsFilter-D1` (38/100 tuples wrong) and `MultiThresholdObjectsFilter-D2` (51/100 tuples wrong). Both are **fixed by this PR** — not by a commit that predates this V&V pass. Both now have dedicated in-repo regression coverage as well: the `ArraySet 6` and `ArraySet 7` `SECTION`s of `Valid Threshold Sets` (see the V&V report's Code path coverage row 25 and Test inventory), each confirmed to fail when the algorithm is stubbed out. -`MultiThresholdObjectsFilter-D3` documents a confirmed, deliberate capability difference (not a bug): multi-component index selection only exists in SIMPLNX. +`MultiThresholdObjectsFilter-D3`, `-D5`, and `-D6` document confirmed, deliberate capability differences (not bugs): multi-component index selection, custom TRUE/FALSE mask values, and a selectable mask output `DataType` all exist only in SIMPLNX. + +`MultiThresholdObjectsFilter-D4` is a third SIMPLNX bug, found during second-engineer review and fixed by this PR: converting a legacy *nested* threshold set flattened it and discarded every union operator, so converted `Threshold Objects (Advanced)` pipelines computed a plain AND over all leaves. --- @@ -25,7 +27,8 @@ The same three pipelines run against `develop` (pre-fix) reproduce two real bugs | **Comparison type** | Runtime three-way A/B: legacy DREAM3D 6.5.171 (`PipelineRunner`) vs. this branch (`nxrunner`) vs. an independent numpy oracle | | **Shared input** | Legacy-format fixture, 100 tuples: `Int32 = 0..99`, `Float32 = 0.01*(i+1)` | | **Scale re-run** | Same three configurations (AB1–AB3) re-run at 50M random tuples — this branch matches the numpy oracle exactly at scale | -| **In-repo regression suite** | `test/MultiThresholdObjectsTest.cpp` passes locally at the verified commit (13 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 30 ctest entries; see the V&V report's Test inventory for the count basis) | +| **Deeper-nesting fixture** | Second shared 100-tuple fixture generated by the legacy runner itself (`CreateDataArray`, random-with-range: `Int32` in [0,100), `Float32` in [0,1)), read by both runners from that same file. Used for `AB4`–`AB6` | +| **In-repo regression suite** | `test/MultiThresholdObjectsTest.cpp` passes locally at the verified commit (14 `TEST_CASE`/`TEMPLATE_TEST_CASE` declarations / 31 ctest entries; see the V&V report's Test inventory for the count basis) | ### Per-configuration result (100-tuple fixture, this branch = post-fix) @@ -35,6 +38,18 @@ The same three pipelines run against `develop` (pre-fix) reproduce two real bugs | `AB2` | `Int32 > 20 AND (Float32 < 0.60 OR Int32 == 55)` (nested set) | `MultiThresholdObjects2` ("Threshold Objects (Advanced)") | **MATCH** | | `AB3` | `Int32 < 80 OR NOT(Int32 > 30 AND Float32 < 0.95)` (inverted nested set) | `MultiThresholdObjects2` | **MATCH** | +### Deeper-nesting configurations (added at second-engineer review, 2026-08-19) + +These close the "broader configuration sweep" item that was previously outstanding. Each was run three ways — legacy `MultiThresholdObjects2`, a natively-authored SIMPLNX pipeline, and an independent numpy oracle written from the English description — and a fourth way, through SIMPLNX's own conversion of the legacy SIMPL pipeline JSON. + +| Case | Config | Shape | Legacy vs. oracle | SIMPLNX native vs. oracle | SIMPLNX converted-from-SIMPL vs. oracle | +|---|---|---|---|---|---| +| `AB4` | `Int32 > 20 AND (Float32 < 0.8 OR (Int32 < 60 AND Float32 > 0.3))` | 3 levels deep | **MATCH** 100/100 | **MATCH** 100/100 | **MATCH** 100/100 (45/100 before the `D4` fix) | +| `AB5` | `(Int32 > 30 OR Float32 < 0.2) AND NOT(Int32 < 70 AND Float32 > 0.4)` | two sibling sets, second inverted | **MATCH** 100/100 | **MATCH** 100/100 | **MATCH** 100/100 (50/100 before) | +| `AB6` | `Int32 < 50 AND (Float32 > 0.9 OR Int32 == 3)` | grouping-sensitive, only 4 true tuples | **MATCH** 100/100 | **MATCH** 100/100 | **MATCH** 100/100 (96/100 before — an all-false mask) | + +`AB6` is deliberately built so the correct answer (4 true tuples) and the answer produced by discarding the AND/OR grouping (0 true tuples) differ, so it fails loudly if grouping is not honored. + ### Pre-fix (`develop`) result, same three pipelines | Case | Result on `develop` (pre-fix) | Deviation | @@ -105,12 +120,71 @@ Both bug-fix claims in the PR are real, and the fix restores legacy semantics: l --- -## Outstanding comparison work +## MultiThresholdObjectsFilter-D4 + +| Field | Value | +|---|---| +| **Deviation ID** | `MultiThresholdObjectsFilter-D4` | +| **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | +| **Status** | active (SIMPLNX bug **fixed during this V&V cycle** in PR #1688; documented for users of prior SIMPLNX releases) | + +**Symptom:** A legacy `Threshold Objects (Advanced)` pipeline whose thresholds contain a *nested* comparison set produced the wrong mask after conversion to SIMPLNX. The nesting was discarded and every union operator was reset to AND, so `A AND (B OR C)` was evaluated as `A AND B AND C`. Quantified on the `AB4`–`AB6` fixtures against real legacy output: **55/100, 50/100, and 4/100 tuples wrong** respectively. `AB6` degenerated to an all-false mask. Filters run from natively-authored SIMPLNX pipelines were never affected — only pipelines converted from SIMPL. + +**Root cause:** Bug (SIMPLNX-side, pre-existing on `develop`; not introduced by this PR). Two independent defects in `src/simplnx/Parameters/ArrayThresholdsParameter.cpp`, both in the `ComparisonSelectionAdvancedFilterParameterConverter` path: + +1. `flattenSetThreshold()` collapsed each nested `ArrayThresholdSet` into its parent's flat threshold list. SIMPLNX represents nested sets natively, so the flattening served no purpose and destroyed the AND/OR grouping the legacy pipeline encoded. +2. `convertArrayThreshold()` never called `setUnionOperator()`, so every converted leaf silently took `ArrayThreshold`'s default (`And`). Any OR in the legacy pipeline was lost. This affected leaves at every level, not just nested ones. + +The flattening pass also carried a third latent defect: it attempted to push an inverted set's inversion down onto its leaves by flipping each comparison operator, which is not a valid negation — `NOT(x > v)` is `x >= v`, not `x < v`, so tuples exactly equal to the threshold were misclassified; De Morgan's required AND↔OR flip was never applied; and `Operator_NotEqual` fell through unchanged, so `NOT(x != v)` stayed `x != v`. + +**Fix:** the converter now keeps the nested `ArrayThresholdSet` (which `convertSetThreshold()` was already building correctly) and sets each leaf's union operator from the legacy `"Union Operator"` key. `flattenSetThreshold()` and `invertComparison()` are removed. Set inversion is handled natively by the algorithm, which is what this PR's rewrite made correct. + +**Affected users:** Anyone opening or running a converted DREAM3D 6.5 `Threshold Objects (Advanced)` pipeline that used a nested comparison set or any OR union — a standard use of that filter, and the only reason to choose the Advanced variant over the basic one. The failure was silent: the filter reported success and wrote a wrong mask. Natively-authored SIMPLNX pipelines were never affected. + +**Regression coverage:** `SIMPL Nested Set Conversion` (`test/MultiThresholdObjectsTest.cpp`, fixture `test/simpl_conversion/6_5/MultiThresholdObjectsFilter_Nested.json`) asserts that a converted leaf-plus-inverted-nested-set survives as a nested `ArrayThresholdSet` with its inversion flag and its children's union operators intact. Verified load-bearing: the test fails against the pre-fix converter. + +**Recommendation:** Trust SIMPLNX (this PR — confirmed against legacy on `AB4`–`AB6`). Any pipeline converted from SIMPL before this PR that used nested sets or OR unions should be re-converted and re-run; masks produced from such a converted pipeline should not be trusted. + +--- + +## MultiThresholdObjectsFilter-D5 -Both legacy filters have now been run separately on representative configurations (`AB1` vs. `Threshold Objects`; `AB2`/`AB3` vs. `Threshold Objects (Advanced)`), satisfying this filter's Rewrite-classification requirement that functional equivalence be independently confirmed against both predecessors, not just one. +| Field | Value | +|---|---| +| **Deviation ID** | `MultiThresholdObjectsFilter-D5` | +| **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | +| **Status** | active | + +**Symptom:** SIMPLNX can write the mask with a user-selected `DataType` and defaults to `uint8`. Both legacy filters always created the mask as `DataArray` with no output-type option, so a converted 6.5 pipeline produces a `uint8` mask where legacy produced a `bool` mask. The mask *values* are identical (1/0); only the array's type differs. + +**Root cause:** Algorithmic choice (deliberate SIMPLNX capability addition). Confirmed directly from legacy source: `MultiThresholdObjects::dataCheck()` and `MultiThresholdObjects2::dataCheck()` both call `createNonPrereqArrayFromPath, AbstractFilter, bool>(...)` unconditionally. SIMPLNX added the `Mask Type` parameter with a `uint8` default (`#1502`). `FromSIMPLJson()` maps the SIMPL `ScalarType` key when present, but 6.5 pipelines do not carry that key, so a converted 6.5 pipeline falls through to the SIMPLNX default rather than to `boolean`. + +**Affected users:** Anyone converting a 6.5 pipeline whose downstream filters require a `boolean` mask array, or who compares a converted pipeline's output file against a legacy one and finds the mask array typed `uint8` instead of `bool`. Numerically the masks agree, so results are affected only where the array's declared type matters. + +**Recommendation:** Either acceptable. Users who need bit-for-bit legacy parity on the output file should set **Mask Type** to `boolean` explicitly after converting a 6.5 pipeline. Worth a line in public migration guidance. + +--- + +## MultiThresholdObjectsFilter-D6 + +| Field | Value | +|---|---| +| **Deviation ID** | `MultiThresholdObjectsFilter-D6` | +| **Filter UUID** | `4246245e-1011-4add-8436-0af6bed19228` | +| **Status** | active | + +**Symptom:** SIMPLNX can substitute custom TRUE and FALSE values into the mask instead of 1 and 0 (`Use Custom TRUE Value` / `Use Custom FALSE Value`). Neither legacy filter has any equivalent parameter. + +**Root cause:** Algorithmic choice (deliberate SIMPLNX capability addition, `#669`). Confirmed directly from legacy source: `MultiThresholdObjects::setupFilterParameters()` declares exactly two parameters — `SelectedThresholds` and `DestinationArrayName` — and `MultiThresholdObjects2` likewise has no custom-value parameter. Both write a hard-coded `true`/`false` into a `DataArray`. + +**Affected users:** Nobody migrating *from* legacy is affected — the capability did not exist to lose, and the defaults (1/0) reproduce legacy behavior exactly. Anyone relying on this SIMPLNX-only feature should know there is no DREAM3D 6.5.171 equivalent pipeline to fall back to. + +**Recommendation:** Trust SIMPLNX. Intentional superset capability, not a correctness issue. Because the custom values are substituted only at the final typed write, after the boolean combination is complete, they cannot affect which tuples are masked — the `Valid Execution - Custom Values` `TEMPLATE_TEST_CASE` covers all 10 numeric mask types analytically. + +## Outstanding comparison work -The following configurations were **not** run against legacy. All three were reviewed at second-engineer sign-off (2026-08-19) and **accepted as residual risk** rather than treated as blocking gates: each is covered by the Class 1 analytical oracle in the in-repo test suite, and none touches the comparison or set-combination logic that `AB1`–`AB3` exercise. +**None.** Both legacy filters have been run separately on representative configurations (`AB1` vs. `Threshold Objects`; `AB2`–`AB6` vs. `Threshold Objects (Advanced)`), satisfying this filter's Rewrite-classification requirement that functional equivalence be confirmed against both predecessors. The three items previously listed here were closed at second-engineer review on 2026-08-19: -1. **Custom TRUE/FALSE mask output values** (`#669` addition) — not exercised by `AB1`–`AB3`. Covered analytically by `Valid Execution - Custom Values` (10 mask types). The custom values are substituted at the final typed write only; they cannot affect the boolean combination that produced the mask. -2. **Default mask output `DataType`** — SIMPLNX defaults to `uint8` (`#1502`); what each legacy filter defaulted to has not been confirmed. Affects migration guidance for pipelines that relied on the default rather than setting it explicitly, not correctness of the mask itself. `Valid Execution, Mask DataType` covers all 10 non-boolean output types analytically. -3. **A broader configuration sweep** beyond the three representative `AB1`–`AB3` shapes (e.g. deeper nesting, mixed AND/OR at multiple levels) — optional given the exact match already obtained at both 100-tuple and 50M-tuple scale, and given that `Valid Threshold Sets` now enumerates seven set shapes including both mixed leaf/nested-set forms. +1. **Custom TRUE/FALSE mask output values** — closed by inspection of the legacy sources rather than by an A/B run: neither legacy filter has the parameter, so there is nothing to compare against. Recorded as `MultiThresholdObjectsFilter-D6`. +2. **Default mask output `DataType`** — closed the same way: both legacy filters hard-code `DataArray`. Recorded as `MultiThresholdObjectsFilter-D5`, with migration guidance. +3. **A broader configuration sweep** — closed by running `AB4`–`AB6` (three levels of nesting, an inverted sibling set, and a grouping-sensitive case), all MATCH. This sweep is also what surfaced `MultiThresholdObjectsFilter-D4`. diff --git a/src/simplnx/Parameters/ArrayThresholdsParameter.cpp b/src/simplnx/Parameters/ArrayThresholdsParameter.cpp index 8a44eda714..1d94cfc6eb 100644 --- a/src/simplnx/Parameters/ArrayThresholdsParameter.cpp +++ b/src/simplnx/Parameters/ArrayThresholdsParameter.cpp @@ -209,21 +209,6 @@ bool isArrayThreshold(const nlohmann::json& json) return !json.contains(k_ThresholdValuesKey); } -ArrayThreshold::ComparisonType invertComparison(ArrayThreshold::ComparisonType comparison) -{ - switch(comparison) - { - case ArrayThreshold::ComparisonType::GreaterThan: - return ArrayThreshold::ComparisonType::LessThan; - case ArrayThreshold::ComparisonType::LessThan: - return ArrayThreshold::ComparisonType::GreaterThan; - case ArrayThreshold::ComparisonType::Operator_Equal: - return ArrayThreshold::ComparisonType::Operator_NotEqual; - default: - return comparison; - } -} - std::shared_ptr convertArrayThreshold(const DataPath& amPath, const nlohmann::json& json) { auto comparisonType = static_cast(json[k_ComparisonOperatorKey].get()); @@ -239,52 +224,18 @@ std::shared_ptr convertArrayThreshold(const DataPath& amPath, co auto daName = json[k_DataArrayNameKey].get(); auto daPath = amPath.createChildPath(daName); + // SIMPL and SIMPLNX agree on the union operator encoding (And = 0, Or = 1), so it carries over directly. + // The first threshold of a set has its union operator ignored by both implementations. + auto unionOperator = static_cast(json.value(k_UnionOperatorKey.view(), 0)); + auto value = std::make_shared(); value->setArrayPath(daPath); value->setComparisonType(comparisonType); value->setComparisonValue(comparisonValue); + value->setUnionOperator(unionOperator); return value; } -std::vector> flattenSetThreshold(const std::shared_ptr& thresholdSet, bool parentInverted = false) -{ - std::vector> flattenedArrays; - auto thresholds = thresholdSet->getArrayThresholds(); - if(thresholds.empty()) - { - return {}; - } - - bool inverted = thresholdSet->isInverted(); - if(parentInverted) - { - inverted = !inverted; - } - auto unionOperator = thresholdSet->getUnionOperator(); - - for(const auto& threshold : thresholds) - { - if(auto set = std::dynamic_pointer_cast(threshold); set != nullptr) - { - auto flattened = flattenSetThreshold(set, inverted); - flattenedArrays.insert(flattenedArrays.end(), flattened.begin(), flattened.end()); - } - else - { - auto arrayThreshold = std::dynamic_pointer_cast(threshold); - if(inverted) - { - auto comparisonType = invertComparison(arrayThreshold->getComparisonType()); - arrayThreshold->setComparisonType(comparisonType); - arrayThreshold->setUnionOperator(unionOperator); - } - flattenedArrays.push_back(arrayThreshold); - } - } - - return flattenedArrays; -} - std::shared_ptr convertSetThreshold(const DataPath& amPath, const nlohmann::json& json) { ArrayThresholdsParameter::ValueType::CollectionType thresholdSet; @@ -340,9 +291,9 @@ Result Compariso } else { - auto arrayThresholdSet = convertSetThreshold(amPath, iter); - auto flattenedThresholds = flattenSetThreshold(arrayThresholdSet); - thresholdSet.insert(thresholdSet.end(), flattenedThresholds.begin(), flattenedThresholds.end()); + // Preserve the nested set. SIMPLNX evaluates ArrayThresholdSet trees directly, so flattening here + // would discard the AND/OR grouping the legacy pipeline encoded. + thresholdSet.push_back(convertSetThreshold(amPath, iter)); } } From b4641576d502151477831156dc138ddbd50aff6b Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Thu, 20 Aug 2026 14:58:29 -0400 Subject: [PATCH 28/28] TEST: Add the missing tuple-dims convention check to the out-of-bounds custom value test Invalid Execution - Out of Bounds Custom Values builds a DataStructure via CreateTestDataStructure() but never called UnitTest::CheckArraysInheritTupleDims, unlike its sibling Invalid Execution test. Last open item from the round-one review checklist. Co-Authored-By: Claude Opus 5 --- src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp index a28fcac241..a64dfb7922 100644 --- a/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp +++ b/src/Plugins/SimplnxCore/test/MultiThresholdObjectsTest.cpp @@ -1023,6 +1023,8 @@ TEMPLATE_TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution - Out SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); REQUIRE(preflightResult.outputActions.errors().size() == 1); REQUIRE(preflightResult.outputActions.errors()[0].code == code); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); } TEST_CASE("SimplnxCore::MultiThresholdObjects: Invalid Execution - Boolean Custom Values", "[SimplnxCore][MultiThresholdObjectsFilter]")