From f56a69a716a2ea8d3b0d7cdf777f0649c95051b5 Mon Sep 17 00:00:00 2001 From: Kaituo Li Date: Mon, 8 Jul 2024 12:24:15 -0700 Subject: [PATCH] Add Support for Handling Missing Data in Anomaly Detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces enhanced handling of missing data, giving customers the flexibility to choose how to address gaps in their data. Options include ignoring missing data (default behavior), filling with fixed values (customer-specified), zeros, or previous values. These options can improve recall in anomaly detection scenarios. For example, in this forum discussion https://forum.opensearch.org/t/do-missing-buckets-ruin-anomaly-detection/16535, customers can now opt to fill missing values with zeros to maintain detection accuracy. Key Changes: 1. Enhanced Missing Data Handling: Changed to ThresholdedRandomCutForest.process(double[] inputPoint, long timestamp, int[] missingValues) to support missing data in both real-time and historical analyses. The preview mode remains unchanged for efficiency, utilizing existing linear imputation techniques. (See classes: ADColdStart, ModelColdStart, ModelManager, ADBatchTaskRunner). 2. Refactoring Imputation & Processing: Refactored the imputation process, failure handling, statistics collection, and result saving in Inferencer. 3. Improved Imputed Value Reconstruction: Reconstructed imputed values using existing mean and standard deviation, ensuring they are accurately stored in AnomalyResult. Added a featureImputed boolean tag to flag imputed values. (See class: AnomalyResult). 4. Broadcast Support for HC Detectors: Added a broadcast mechanism for HC detectors to identify entity models that haven’t received data in a given interval. This ensures models in memory process all relevant data before imputation begins. Single stream detectors handle this within existing transport messages. (See classes: ADHCImputeTransportAction, ADResultProcessor, ResultProcessor). 5. Introduction of ActionListenerExecutor: Added ActionListenerExecutor to wrap response and failure handlers in an ActionListener, executing them asynchronously using the provided ExecutorService. This allows us to handle responses in the AD thread pool. Testing: Comprehensive testing was conducted, including both integration and unit tests. Of the 7135 lines added and 1683 lines removed, 4926 additions and 749 deletions are in tests, ensuring robust coverage. Signed-off-by: Kaituo Li --- .github/workflows/benchmark.yml | 15 +- build.gradle | 64 +- lib/randomcutforest-core-4.1.0.jar | Bin 0 -> 322226 bytes lib/randomcutforest-parkservices-4.1.0.jar | Bin 0 -> 108053 bytes lib/randomcutforest-serialization-4.1.0.jar | Bin 0 -> 20885 bytes .../opensearch/ad/AnomalyDetectorRunner.java | 6 +- .../org/opensearch/ad/ml/ADColdStart.java | 46 +- .../org/opensearch/ad/ml/ADInferencer.java | 50 + .../org/opensearch/ad/ml/ADModelManager.java | 74 +- .../opensearch/ad/ml/ThresholdingResult.java | 42 +- .../opensearch/ad/model/AnomalyResult.java | 102 +- .../opensearch/ad/model/FeatureImputed.java | 105 ++ .../ad/model/ImputedFeatureResult.java | 24 + .../ad/ratelimit/ADCheckpointReadWorker.java | 14 +- .../ad/ratelimit/ADColdEntityWorker.java | 3 +- .../ad/ratelimit/ADColdStartWorker.java | 13 +- .../ad/ratelimit/ADSaveResultStrategy.java | 1 - .../AbstractAnomalyDetectorActionHandler.java | 5 + ...gacyOpenDistroAnomalyDetectorSettings.java | 3 +- .../opensearch/ad/task/ADBatchTaskCache.java | 3 + .../opensearch/ad/task/ADBatchTaskRunner.java | 72 +- .../ad/transport/ADHCImputeAction.java | 19 + .../ad/transport/ADHCImputeNodeRequest.java | 35 + .../ad/transport/ADHCImputeNodeResponse.java | 49 + .../ad/transport/ADHCImputeNodesResponse.java | 36 + .../ad/transport/ADHCImputeRequest.java | 61 + .../transport/ADHCImputeTransportAction.java | 132 ++ .../ad/transport/ADResultProcessor.java | 33 +- .../ADSingleStreamResultTransportAction.java | 26 +- .../ad/transport/AnomalyResultResponse.java | 7 +- .../AnomalyResultTransportAction.java | 2 - .../EntityADResultTransportAction.java | 28 +- .../ad/transport/ForwardADTaskRequest.java | 22 +- ...IndexMemoryPressureAwareResultHandler.java | 1 + .../forecast/ml/ForecastInferencer.java | 50 + .../forecast/ml/ForecastModelManager.java | 9 +- .../ForecastCheckpointReadWorker.java | 14 +- .../ratelimit/ForecastColdEntityWorker.java | 3 +- .../AbstractForecasterActionHandler.java | 2 +- .../EntityForecastResultTransportAction.java | 28 +- .../ForecastImputeMissingValueAction.java | 20 + .../transport/ForecastResultProcessor.java | 6 +- .../ForecastResultTransportAction.java | 2 - .../ForecastRunOnceProfileNodeRequest.java | 3 +- ...ForecastRunOnceProfileTransportAction.java | 1 - .../ForecastRunOnceTransportAction.java | 2 - ...castSingleStreamResultTransportAction.java | 26 +- ...IndexMemoryPressureAwareResultHandler.java | 1 + .../ExecuteResultResponseRecorder.java | 3 +- .../timeseries/TimeSeriesAnalyticsPlugin.java | 76 +- .../timeseries/caching/PriorityCache.java | 15 + .../timeseries/caching/TimeSeriesCache.java | 7 + .../timeseries/constant/CommonMessages.java | 3 +- .../dataprocessor/ImputationMethod.java | 4 - .../dataprocessor/ImputationOption.java | 98 +- .../timeseries/feature/AbstractRetriever.java | 17 +- .../feature/CompositeRetriever.java | 2 +- .../timeseries/feature/FeatureManager.java | 9 +- .../timeseries/feature/SearchFeatureDao.java | 49 +- .../opensearch/timeseries/ml/Inferencer.java | 190 +++ .../timeseries/ml/ModelColdStart.java | 17 +- .../timeseries/ml/ModelManager.java | 36 +- .../org/opensearch/timeseries/ml/Sample.java | 20 +- .../opensearch/timeseries/model/Config.java | 109 +- .../timeseries/model/DataByFeatureId.java | 17 +- .../opensearch/timeseries/model/Feature.java | 8 +- .../ratelimit/CheckpointReadWorker.java | 88 +- .../ratelimit/ColdEntityWorker.java | 3 +- .../AbstractTimeSeriesActionHandler.java | 52 +- .../rest/handler/AggregationPrep.java | 6 +- .../rest/handler/IntervalCalculation.java | 4 +- .../settings/TimeSeriesSettings.java | 5 - .../timeseries/stats/StatNames.java | 8 +- ...ractSingleStreamResultTransportAction.java | 103 +- .../transport/CronTransportAction.java | 4 - .../transport/EntityResultProcessor.java | 97 +- .../timeseries/transport/JobRequest.java | 1 + .../timeseries/transport/ResultProcessor.java | 239 ++- .../util/ActionListenerExecutor.java | 54 + .../opensearch/timeseries/util/BulkUtil.java | 7 +- .../opensearch/timeseries/util/DataUtil.java | 43 + .../opensearch/timeseries/util/ModelUtil.java | 72 + .../timeseries/util/ParseUtils.java | 2 +- .../opensearch/timeseries/util/TimeUtil.java | 19 + .../mappings/anomaly-checkpoint.json | 13 +- .../resources/mappings/anomaly-results.json | 13 +- src/main/resources/mappings/config.json | 370 +++-- ...stractForecasterActionHandlerTestCase.java | 130 ++ ...ndexAnomalyDetectorActionHandlerTests.java | 113 +- .../IndexForecasterActionHandlerTests.java | 1405 +++++++++++++++++ .../ValidateForecasterActionHandlerTests.java | 109 ++ .../ad/AbstractADSyntheticDataTest.java | 406 ++++- .../ad/AnomalyDetectorJobRunnerTests.java | 2 +- .../AbstractMissingSingleFeatureTestCase.java | 301 ++++ .../ad/e2e/AbstractRuleModelPerfTestCase.java | 121 ++ .../ad/e2e/AbstractRuleTestCase.java | 212 +-- .../e2e/HistoricalMissingSingleFeatureIT.java | 119 ++ .../ad/e2e/HistoricalRuleModelPerfIT.java | 118 ++ .../java/org/opensearch/ad/e2e/MissingIT.java | 181 +++ .../ad/e2e/MissingMultiFeatureIT.java | 357 +++++ .../ad/e2e/MissingSingleFeatureIT.java | 120 ++ .../ad/e2e/PreviewMissingSingleFeatureIT.java | 77 + .../org/opensearch/ad/e2e/PreviewRuleIT.java | 46 + ...alTimeMissingSingleFeatureModelPerfIT.java | 119 ++ .../e2e/{RuleIT.java => RealTimeRuleIT.java} | 11 +- .../ad/e2e/RealTimeRuleModelPerfIT.java | 143 ++ .../opensearch/ad/e2e/RuleModelPerfIT.java | 248 --- .../ad/e2e/SingleStreamModelPerfIT.java | 2 +- .../ad/feature/FeatureManagerTests.java | 46 +- .../ad/indices/CustomIndexTests.java | 8 + .../opensearch/ad/ml/ModelManagerTests.java | 35 +- .../ad/model/AnomalyDetectorTests.java | 76 +- .../ad/model/FeatureImputedTests.java | 74 + .../ratelimit/CheckpointReadWorkerTests.java | 29 +- .../opensearch/ad/rest/ADRestTestUtils.java | 7 +- .../ad/rest/AnomalyDetectorRestApiIT.java | 17 +- .../ad/rest/HistoricalAnalysisRestApiIT.java | 3 - .../AnomalyDetectorSettingsTests.java | 2 +- .../ad/task/ADTaskManagerTests.java | 2 +- .../ad/transport/AnomalyResultTests.java | 190 +-- .../AnomalyResultTransportActionTests.java | 4 +- .../EntityResultTransportActionTests.java | 15 +- .../transport/ForwardADTaskRequestTests.java | 2 +- .../ad/transport/MultiEntityResultTests.java | 28 +- ...ewAnomalyDetectorTransportActionTests.java | 5 +- .../ad/transport/RCFResultTests.java | 8 +- ...teAnomalyDetectorTransportActionTests.java | 11 +- .../AnomalyResultBulkIndexHandlerTests.java | 3 +- .../forecast/model/ForecasterTests.java | 16 +- .../forecast/rest/ForecastRestApiIT.java | 171 ++ .../timeseries/AbstractSyntheticDataTest.java | 260 ++- .../timeseries/ODFERestTestCase.java | 1 + .../opensearch/timeseries/TestHelpers.java | 90 +- .../dataprocessor/ImputationOptionTests.java | 78 +- .../NoPowermockSearchFeatureDaoTests.java | 2 +- .../feature/SearchFeatureDaoParamTests.java | 10 +- .../handler/IntervalCalculationTests.java | 143 ++ ...nomalyDetectorJobTransportActionTests.java | 2 +- .../transport/CronTransportActionTests.java | 1 - 139 files changed, 7139 insertions(+), 1683 deletions(-) create mode 100644 lib/randomcutforest-core-4.1.0.jar create mode 100644 lib/randomcutforest-parkservices-4.1.0.jar create mode 100644 lib/randomcutforest-serialization-4.1.0.jar create mode 100644 src/main/java/org/opensearch/ad/ml/ADInferencer.java create mode 100644 src/main/java/org/opensearch/ad/model/FeatureImputed.java create mode 100644 src/main/java/org/opensearch/ad/model/ImputedFeatureResult.java create mode 100644 src/main/java/org/opensearch/ad/transport/ADHCImputeAction.java create mode 100644 src/main/java/org/opensearch/ad/transport/ADHCImputeNodeRequest.java create mode 100644 src/main/java/org/opensearch/ad/transport/ADHCImputeNodeResponse.java create mode 100644 src/main/java/org/opensearch/ad/transport/ADHCImputeNodesResponse.java create mode 100644 src/main/java/org/opensearch/ad/transport/ADHCImputeRequest.java create mode 100644 src/main/java/org/opensearch/ad/transport/ADHCImputeTransportAction.java create mode 100644 src/main/java/org/opensearch/forecast/ml/ForecastInferencer.java create mode 100644 src/main/java/org/opensearch/forecast/transport/ForecastImputeMissingValueAction.java rename src/main/java/org/opensearch/{timeseries => forecast}/transport/ForecastRunOnceProfileNodeRequest.java (88%) create mode 100644 src/main/java/org/opensearch/timeseries/ml/Inferencer.java create mode 100644 src/main/java/org/opensearch/timeseries/util/ActionListenerExecutor.java create mode 100644 src/main/java/org/opensearch/timeseries/util/ModelUtil.java create mode 100644 src/main/java/org/opensearch/timeseries/util/TimeUtil.java create mode 100644 src/test/java/org/opensearch/action/admin/indices/mapping/get/AbstractForecasterActionHandlerTestCase.java create mode 100644 src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexForecasterActionHandlerTests.java create mode 100644 src/test/java/org/opensearch/action/admin/indices/mapping/get/ValidateForecasterActionHandlerTests.java create mode 100644 src/test/java/org/opensearch/ad/e2e/AbstractMissingSingleFeatureTestCase.java create mode 100644 src/test/java/org/opensearch/ad/e2e/AbstractRuleModelPerfTestCase.java create mode 100644 src/test/java/org/opensearch/ad/e2e/HistoricalMissingSingleFeatureIT.java create mode 100644 src/test/java/org/opensearch/ad/e2e/HistoricalRuleModelPerfIT.java create mode 100644 src/test/java/org/opensearch/ad/e2e/MissingIT.java create mode 100644 src/test/java/org/opensearch/ad/e2e/MissingMultiFeatureIT.java create mode 100644 src/test/java/org/opensearch/ad/e2e/MissingSingleFeatureIT.java create mode 100644 src/test/java/org/opensearch/ad/e2e/PreviewMissingSingleFeatureIT.java create mode 100644 src/test/java/org/opensearch/ad/e2e/PreviewRuleIT.java create mode 100644 src/test/java/org/opensearch/ad/e2e/RealTimeMissingSingleFeatureModelPerfIT.java rename src/test/java/org/opensearch/ad/e2e/{RuleIT.java => RealTimeRuleIT.java} (86%) create mode 100644 src/test/java/org/opensearch/ad/e2e/RealTimeRuleModelPerfIT.java delete mode 100644 src/test/java/org/opensearch/ad/e2e/RuleModelPerfIT.java create mode 100644 src/test/java/org/opensearch/ad/model/FeatureImputedTests.java create mode 100644 src/test/java/org/opensearch/timeseries/indices/rest/handler/IntervalCalculationTests.java diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 8a16b0790..910f6638a 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -19,7 +19,7 @@ jobs: strategy: matrix: # each test scenario (rule, hc, single_stream) is treated as a separate job. - test: [rule, hc, single_stream] + test: [rule, hc, single_stream,missing] fail-fast: false concurrency: # The concurrency setting is used to limit the concurrency of each test scenario group to ensure they do not run concurrently on the same machine. @@ -48,11 +48,16 @@ jobs: chown -R 1000:1000 `pwd` case ${{ matrix.test }} in rule) - su `id -un 1000` -c "./gradlew integTest --tests 'org.opensearch.ad.e2e.RuleModelPerfIT' \ + su `id -un 1000` -c "./gradlew integTest --tests 'org.opensearch.ad.e2e.RealTimeRuleModelPerfIT' \ -Dtests.seed=B4BA12CCF1D9E825 -Dtests.security.manager=false \ -Dtests.jvm.argline='-XX:TieredStopAtLevel=1 -XX:ReservedCodeCacheSize=64m' \ -Dtests.locale=ar-JO -Dtests.timezone=Asia/Samarkand -Dmodel-benchmark=true \ -Dtests.timeoutSuite=3600000! -Dtest.logs=true" + su `id -un 1000` -c "./gradlew integTest --tests 'org.opensearch.ad.e2e.HistoricalRuleModelPerfIT' \ + -Dtests.seed=B4BA12CCF1D9E825 -Dtests.security.manager=false \ + -Dtests.jvm.argline='-XX:TieredStopAtLevel=1 -XX:ReservedCodeCacheSize=64m' \ + -Dtests.locale=ar-JO -Dtests.timezone=Asia/Samarkand -Dmodel-benchmark=true \ + -Dtests.timeoutSuite=3600000! -Dtest.logs=true" ;; hc) su `id -un 1000` -c "./gradlew ':test' --tests 'org.opensearch.ad.ml.HCADModelPerfTests' \ @@ -66,4 +71,10 @@ jobs: -Dtests.locale=kab-DZ -Dtests.timezone=Asia/Hebron -Dtest.logs=true \ -Dtests.timeoutSuite=3600000! -Dmodel-benchmark=true" ;; + missing) + su `id -un 1000` -c "./gradlew integTest --tests 'org.opensearch.ad.e2e.RealTimeMissingSingleFeatureModelPerfIT' \ + -Dtests.seed=60CDDB34427ACD0C -Dtests.security.manager=false \ + -Dtests.locale=kab-DZ -Dtests.timezone=Asia/Hebron -Dtest.logs=true \ + -Dtests.timeoutSuite=3600000! -Dmodel-benchmark=true" + ;; esac diff --git a/build.gradle b/build.gradle index b31561343..2812fdeb0 100644 --- a/build.gradle +++ b/build.gradle @@ -126,9 +126,12 @@ dependencies { implementation group: 'com.yahoo.datasketches', name: 'memory', version: '0.12.2' implementation group: 'commons-lang', name: 'commons-lang', version: '2.6' implementation group: 'org.apache.commons', name: 'commons-pool2', version: '2.12.0' - implementation 'software.amazon.randomcutforest:randomcutforest-serialization:4.0.0' - implementation 'software.amazon.randomcutforest:randomcutforest-parkservices:4.0.0' - implementation 'software.amazon.randomcutforest:randomcutforest-core:4.0.0' + // implementation 'software.amazon.randomcutforest:randomcutforest-serialization:4.0.0' + // implementation 'software.amazon.randomcutforest:randomcutforest-parkservices:4.0.0' + // implementation 'software.amazon.randomcutforest:randomcutforest-core:4.0.0' + implementation files('lib/randomcutforest-core-4.1.0.jar') + implementation files('lib/randomcutforest-parkservices-4.1.0.jar') + implementation files('lib/randomcutforest-serialization-4.1.0.jar') // we inherit jackson-core from opensearch core implementation "com.fasterxml.jackson.core:jackson-databind:2.16.1" @@ -356,8 +359,7 @@ integTest { if (System.getProperty("model-benchmark") == null || System.getProperty("model-benchmark") == "false") { filter { - excludeTestsMatching "org.opensearch.ad.e2e.SingleStreamModelPerfIT" - excludeTestsMatching "org.opensearch.ad.e2e.RuleModelPerfIT" + excludeTestsMatching "org.opensearch.ad.e2e.*ModelPerfIT" } } @@ -676,11 +678,6 @@ List jacocoExclusions = [ // rest layer is tested in integration testing mostly, difficult to mock all of it 'org.opensearch.ad.rest.*', - 'org.opensearch.ad.model.ModelProfileOnNode', - 'org.opensearch.ad.model.InitProgressProfile', - 'org.opensearch.ad.rest.*', - 'org.opensearch.ad.AnomalyDetectorJobRunner', - // Class containing just constants. Don't need to test 'org.opensearch.ad.constant.*', 'org.opensearch.forecast.constant.*', @@ -688,22 +685,49 @@ List jacocoExclusions = [ 'org.opensearch.timeseries.settings.TimeSeriesSettings', 'org.opensearch.forecast.settings.ForecastSettings', - 'org.opensearch.ad.transport.CronRequest', - 'org.opensearch.ad.AnomalyDetectorRunner', - // related to transport actions added for security 'org.opensearch.ad.transport.DeleteAnomalyDetectorTransportAction.1', // TODO: unified flow caused coverage drop 'org.opensearch.ad.transport.DeleteAnomalyResultsTransportAction', - // TODO: fix unstable code coverage caused by null NodeClient issue - // https://github.com/opensearch-project/anomaly-detection/issues/241 - 'org.opensearch.ad.task.ADBatchTaskRunner', - 'org.opensearch.ad.task.ADTaskManager', - // TODO: add forecast test coverage before release + + // TODO: add test coverage (kaituo) 'org.opensearch.forecast.*', - 'org.opensearch.timeseries.*', - 'org.opensearch.ad.*', + 'org.opensearch.ad.transport.GetAnomalyDetectorTransportAction', + 'org.opensearch.ad.ml.ADColdStart', + 'org.opensearch.ad.transport.ADHCImputeNodesResponse', + 'org.opensearch.timeseries.transport.BooleanNodeResponse', + 'org.opensearch.timeseries.ml.TimeSeriesSingleStreamCheckpointDao', + 'org.opensearch.timeseries.transport.JobRequest', + 'org.opensearch.timeseries.transport.handler.ResultBulkIndexingHandler', + 'org.opensearch.timeseries.ml.Inferencer', + 'org.opensearch.timeseries.transport.SingleStreamResultRequest', + 'org.opensearch.timeseries.transport.BooleanResponse', + 'org.opensearch.timeseries.rest.handler.IndexJobActionHandler.1', + 'org.opensearch.timeseries.transport.SuggestConfigParamResponse', + 'org.opensearch.timeseries.transport.SuggestConfigParamRequest', + 'org.opensearch.timeseries.ml.MemoryAwareConcurrentHashmap', + 'org.opensearch.timeseries.transport.ResultBulkTransportAction', + 'org.opensearch.timeseries.transport.handler.IndexMemoryPressureAwareResultHandler', + 'org.opensearch.timeseries.transport.handler.ResultIndexingHandler', + 'org.opensearch.ad.transport.ADHCImputeNodeResponse', + 'org.opensearch.timeseries.ml.Sample', + 'org.opensearch.timeseries.ratelimit.FeatureRequest', + 'org.opensearch.ad.transport.ADHCImputeNodeRequest', + 'org.opensearch.timeseries.model.ModelProfileOnNode', + 'org.opensearch.timeseries.transport.ValidateConfigRequest', + 'org.opensearch.timeseries.transport.ResultProcessor.PageListener.1', + 'org.opensearch.ad.transport.ADHCImputeRequest', + 'org.opensearch.timeseries.transport.BaseDeleteConfigTransportAction.1', + 'org.opensearch.timeseries.transport.BaseSuggestConfigParamTransportAction', + 'org.opensearch.timeseries.rest.AbstractSearchAction.1', + 'org.opensearch.ad.transport.ADSingleStreamResultTransportAction', + 'org.opensearch.timeseries.ratelimit.RateLimitedRequestWorker.RequestQueue', + 'org.opensearch.timeseries.rest.RestStatsAction', + 'org.opensearch.ad.ml.ADCheckpointDao', + 'org.opensearch.timeseries.transport.CronRequest', + 'org.opensearch.ad.task.ADBatchTaskCache', + 'org.opensearch.timeseries.ratelimit.RateLimitedRequestWorker', ] diff --git a/lib/randomcutforest-core-4.1.0.jar b/lib/randomcutforest-core-4.1.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..5d76ddf6ab44be1cfdb3835ee85655a4041c4c5c GIT binary patch literal 322226 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ⅇ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


?i4Ct8`8J&)=4qGD_E8D3>dfH9*0hqdcTXeG`2x z4g$(iiWkQVhbGXdD)yT0tX$byZgk0(`YoCM*|9m7J||#cFi6uM1WFH~Tc^A=65-BS zI`2DC%MWj6GlntDs-upy-)Zl@EW$b45jyR4wXbx1{j{G*_c{nXzVa#vS^{a$4`&?{ z2#)Iqq(s(YdIZbVjkbSq($j?&@OU;9ldtj!P!Mp60dA#hf^aCbiYVYBOEZLmsR{jB~z0-`gY;I<6Dc;94V$+&x(eHN-G0 z5r`v`i6g#va?UUf^%Qtkc1|Y8>5nmhVpX@cSB@x}+qhVdl z!@Z-U@$hk(v_h*m+(QaDd3vI0OejsAsEDrUPnvxkn+T{gqN2*)@E-W{kaUOD`-h@@ zVwk*=3}5GY%2)O_K0I>BEZuErRwM6WI;1%&iojw*vEd_FG?Nm8fVpgUVuU;p7f_k z#Yeb`4OkA@^D-n4lo|#@4V)_t!A2p!ejpD2zaiy+4(4+c;Ew^TG+$8=lVwptqzgh9 z=>%D90(f-23KPtmB@lOnT_2QId9$uZCr8i?1kFr{_h*??wv{t37^YF%T>CyPPYETr zMdKWDQ=~h(I|`y}O0vrZwI)G3|6qUD&Qr(Cy~nQ5;;30cfd{(eQoDuz!5r+;hoT6q zEGx+%I}Slj4|CetHpzH>^doLmxDLj5iMB{oS3v~~+mAJ5A?+swd470ul{49u2eVFh zhuut2VEg|$4UjAve=+$OTzIp2J1KqP@(&RxPp#OSd4c;1J^KgtpP;=JD;#a(n^MXA z7WZrYH$l6Km7SZdk*k^M|J8_AeajwK4DE~j$@{+1DPJxNCcd2}F2wemcYD z_`~lc`|jgm;#mL$7_wvpPiRJL*C!ZiGz5o>=y0nr&>P{o8-~CT;n6SIWE=)%6tsfm zivq`x>S~xH-}Dj8{@(xSnIlmnA;DoPGi)>%pOF?AjfurS;gCYJ6!*&Sk*K+xlS68$ zqnUVwsP%C&q-SJg0v&|K%w%f>juGUONvpRedCrUB-{UIr6mk-ItW12tKd_Wb6hj#0 zLY`ZHOFPYi<_(S}A>{S8_^c%&LnYFbgGWcu^c(x0l^L`qz})ld`{<3rnV!Ck3Z* zhbOiJZ-%Tv$zgDMx<*3gzugZ995!r1I~}Ae2Qg^4WiG*{SNTOCy2rkg>V{3Qxsxw^ z8$N=QxAEFIScE*KLs|BgZt9=Xsy%*G6~d)k>U<%H4^Brk8kSJ23QHzDp3?klBm#Es zDrDi~%w(OK4h_5e%^|FOriiI1tz4~Ku96wqM#@A-^uo78KKp(tzQjC8Livhk(zTOD z2Oh~g-)T94w2pR5_9B17J~ObIdEk$*hFVf)nK1f-o2wgo=^LTQ-CO6}<+S(6WwC&{ z`z-JDwUL9*y`57I6k918d66N-$uvjQj6rU**n4M@|pl@t; z*J14412+blb4)YaO9MM3-cQ-Cj|`11LOIaLf+`Y^!3tgxC}n7jpA4fUoU)zKmZY2B=8+BsUZoBZ5&3B6)ie~j zw5Wkj_Cp)9=7^!DUH9X(=TGJN-?kc-lgV29ZeKv} zfae$#<|Ym=x3wb8tm`bNv8y?}Pp1o~)2u88gmtTSi<+-!F90@fxi5+vfgj+13hF0C zv0-Hza}c|g!Eu?KB$pa#Md5%EnGHXb!@6(Stk&HXDUR7|0D^(BADOmxurh)a(L5?N*t#nq9 zc%ZhZEbO`~?Fbks!nx)oy(X247yWP%XPF6^I?N*4__?TRP0d`rI=*$oupK>8x_JAu zUR#b^o}X=|!scIk+}}s$ken0{lRYJZ z?U)!G{P~PyInayiALob^S#FnIgHcR1k{MQo$>l{>qhl;l%T6pi8Vzdr*qW3$2oipt zNZXb9G9wtSJAK(@D$=G{K=;AXWLX$*7fKv_ppQr^!NzTP=<}JUfH!#mKb9*I`SIje z49(059+-h~O)+vd`{U?Nr;XN6zrC`>P7w*Tt|mTvj01Jz!c*nqm|pX?`>*oj5&6q` zT01Op)-P~khxlgn?GoVxjJ#fJP_g1-Yuo3aIdMi!Y3Z+O;sKF^z?WMz~VyQ z;9~78eeM->P}m9V%k})|9j)Q(y3-o~Qwn?o#OhJvxGOpJPwAi^P9X|2%yJs89V=30 zG2WAeT1P}d;uP6Fcw(Qoc^OUt|RcOsU zR#nJyDX~csF^ohiYA&Xy@9bSj`B>ND8&q@`s&m^N%kfwZUq<#ipr;R!RBWp1;B;j1 z9E~9R)kNJ#vPY76)juoUOITC&nq(Gh8SCm1>+xjMltcUejYx@KH~^qyKs3 zQg%RcbZ>d|L8Uws!eAKTyXe?0%5T8weP|;V<10{>;K0H5H=|&<8Qn|r>RAQT;OECD zagRS32U%~+D0+B!dLV|CLytfDBa0@URhE#f>a*I`o%;9ML}@@!d$7;fr?QFECFf3?V|GKwtug+-IPqqa z^3gP3o%XEm3YXot)_`B1`(y)T7XwU8_qjwIoZnm8od92OTH0p`>^a}@LP@}V){3Fi znhgrdiMOx&SyRWG+d^zkU4)JkD_0nMHOS&@Vis;I2O3wm2w)`E*K3VL4KWC~7qRfu zgz%)9XI$1c%fcU6w|GQArVCMKkb^B?7FKQJ8&{RUc<-Fh+juSxVF^g%R_Ty+}n5JYhW^K z4Z~;#H8ZhHXyafc!E5&KTn<^7cqQUh!#^~iLHl8QB#YXYnWMfFzz@Q(K3Fltj(@m7 z;Z-+5d;|x7Q7!K>_Xh(BH0qHUW=v)@*;bi^RT15*epbQPLteAl;PT?X$`U#7{dFoD zZo-qI>Yk?_e1Sk$!~vZ>ws1E-gnxt6^I(ImL#nU<&1 zKh=zs6NVlSuDPk(u~*(WL!DaTmBt<;7udd$XF)%Sx+GWFJlQ>z|4RUy=HhR(tcjVH zj~}ICUNi)6;M=bN-nAk3T{T>V<;XLgvG~I^u-e_*ZVkm%4~Swh33Ddw%puf9&%dzp zAH2ITTmluKnS{x`4lt89&=WSYuQzj{0`VFFqEKIh0fYhK3}3>cUy&ns9Hw_3Pw@eD z9v?{#Q7c+a|ID4bg!KLq)!HXww2lt1+!i<CZgVSjEry{R<)pgxu%X9KyB^W3s` zLS<^**lK6J<>vh-EEjK4qJ?hOFkV7)rbq(!3FYAqGsjsgvzUy)@mROv>(RHTmEGPk z&8UBgv6)PC34}nB5=kvNh`)k zu>bCMQ6xL2D1;6O6&PPv)J%Zkd+0Shehz9dn~w_Ikr?n57I^PB+4$v{T*QM06_XWK z7n<(r!kpCiIo^g{l3<52bF2HuGbZyf9VQ884Avkgutdm8z_(dfs@YiG4*g_jbF-N9 z#Uq1rx9d>tH?!N2wxh$3wRp#9!Os1+AX?I2TR4g@rNC(G`XSkw8w`f>{6a`*)<|OI z?1%sEofE0W_V)}MiRWL&dwM==s(+F9{3v_|YFM9oJ#ww5FiO=_-BnM7Wh2RpVpJE( zodokO7VMa9yLZJyyY|CPL_LmDT>g(L3IPl00*S)HUxa2%XdTW!JNoY)T~Y!T4nA8y z03-Z0vm^NdT#TRC^Ifu)?8~R1Ro;cpkWUfg@HO*YmX`?3+XIMd93||R1;&gRPmVE! z?d|d%*g`2bKs(&E= z3Bc(u_YP3LkMtK%|NWx-FO}r~T69aQIw}i_$X|$dIFv%v&(0{Ftq|q=2zC&#In*p_ z;!#W3x$;>cM)O5U6Yr(C?& zXp-B8wuCe0Bh9BC@Y0Bq+3gmxOcYIQ+K!Gq>%p#06;YOFVPcHF9KsuT*lW{|2H*i~ zdl>Ko=2$ZhcXqiCd<)Vw@9uzR%VQjK>G-_4S-Nx{vgjl#)5+rwpMERb=175m+mR-y9Dbbk0jqj=~Zxcf&fWoQ`PLQbxDy$_$ zvEVUI1Gl-@Y&(Ck-i$|BMX4ZM299$XdKu(%UEe6XQ%3RXPyhb;FiI$W$77h!OvB50 zJDtvIxf`rahWALB7V35EU-AMtV`Dwhnp~lA7?gRdd4H0shQeM8y=QpDq5w$ zT5uX|`IlmLLWy)8eOql0W$RD!P2Ab(1RqSA7tmqAPX!9I;NX)En~fqra>;w%;|Yj$ z`q&yusT#rvRlSiY$R$Sje~}gR>6Jy4HAK~jvNlDH@{A=Goq+89@)|b~#AH4lA}-ME zcZ=k&(C9;$1etG_dge@8w%bh54s%MY`qdPC{HH0HTTENn1G>>v;nG6x>VK>V{PV5N z!rC050&hWD+M$&0KHp-(3c(x?HGUp4!Y<9*6XTb7fhYq}`BnbnSJGA56WUpoje^XT z(7r%yaV1%*?*uL1NQ0=hvDX`J>I1)wACjzY9YQMh8Q9gJbHaF!T}2zYjrC);_mpJl zEfUTPY1a@3S$$aL)NVJJVKGs-k-YiT|J-v=M;;hxKR`eGFUwgupmBsDFhxqVT0Otuw{W zlX|5)*K`8TtDgqc1^U{5xa%^amO|vM!iEd%X-bR1n3Knr&oq+HM)9&nsVM zu`n|?v~&D#yIrLsX-awvBMir6d%DsTWO~iNBZ8OUOLj?Jw}(oqo8Niak@}~cUyM;DIv<(==MJPj z0ijjhkqy>q;d9spX2yB^+O%v>Ot!Vy@~c-amEy`+YZ}hE2(2u zQaE5U=WLl>>}jQ2GwSsB&`nx#By{%RUK&rbyJp4Hf?oIfBm3l3`dQ9sa(>7RKv)rP_T@>4t6Ihs~NZN*AqK&y~2Y9kHl&dq5<#Rm& z`2wyP3zt%<-g?!j>k(wY%3mUW=XSM&e~EU)u$q3%i{DZ=oUIIpE`wT1?d-Twu34nz z9ND9BQxkChNOE|9I5!s~J60=AX`7_y2HK^vu094NRS#=c>Z{-+T}ea_ov8Xfhm6ER zE!ixc=$v#vmPfLloY@5I@q8|SqqyH*ns&q++?oB5l}B24?y`0!YS$(w8qJ(FE>NPz zK2$~U>jUDTE9-$y&kC@Nwuvsb9+y941E>6~yfw;7#a!5Xq7m{m0rTW+3dm_*>hG@f zS|$7xJ_3}Odq6Qr7owF$nC=Q)%OV7Pze2R>o5F)Th}f4BiUqn$8FX?O3bzi=D-F0? zpdEkG?WEInOlyMc^GM=P`o)>{N}ApW#F@ zsWE~kEG?hKuCNkb;2?r;i+tc@xVa}w$C+CY_KdTSm(4xl1ZPZ0el`PI4A7qb)Jrt^ z?N|iVIkC@CB!Db!cNJuA?nNX{_ViOnUE3US3^(-@QES08sB+;b0iE9Kv7^fC%03`i;_r zb#N``Ontbr8K;{=s0GV}S`S@H@3WJ#oPyocqaSzZ&g}Di7oKSHy0vubx*^zlb>jpt zpZ7wFh=CA3exlyYzlICAUELh8gg)2|u|k9x*}!kwl0%qEG#b{?j>%$@i<534ZtP{^ zAecuG@|pY%UO|t&^W7oXJd5N2lP0TWGfq{4VWTvP9{D#Vi(Bd-I-tcE-2ihUy3V8> z1sMjV#n)_Sk)YQrJLu}X8jv%$JxAi@wt8T%wBKw z=gyF}lME;{q_JWU=ZwD;r1X3FC0I&^-Kk|ro=b2YW~WG+pjz>q*|leSsgXo->GU-B zPQ@*4$A7r>rUQ4n*1e7!gj$RHWzsd&rRQ7zD|RSe7en? z_b625l4-!xU+iL^gcA?HlBmRA&w=bK8|uNkTJ z=7D?{PyE9s-}P>B8`fP!5GTV8W|Bw&W0>PstW2Vf0Sw9&bjW# zXR~XTa|if+e&~kBBhh~?;Y?{d^nlrWXPJ*i74V;3>5`l6-ayoRI@(* z*qPpOZTlu7$o*@rLNn%*%OtM>?Zh*~n}avwi-SjcpCJBo>Vh@*V&oZgDmVD}bF7QK z&x&MAw8v@>s`jF?E#4ZF&Qfbf0*0Q&#AOj0zk zHMX-6a&Z>@#nL!A|0gnB`9Qj356!u{$lc^GsV9<5q*`N4h&H>tu{I~bq*emg%$Z2= z8XFxpHP)j^Y3A#^`P=UV06@MNT<-?+v(xDD{Qg$TwbDc-!69x0cvOO{=nPMYU4ZG`zHw)Xw_Q9)=aob z)_Td-)O7HyE-j;7Lp;9=?Qz81cNfrU)7)UNr-8Kt?-^lGkJVuKc61*31Wdd7*`2)y zve?=)r7y26Ttk0Q1@>(}^S97pUrt?}Lx(tZ1nq53eQ#cOPs_r)Mi8Ns7Oo{!tRqE$lEUQRlny15FBMe*I|GCAfhd~S zr&yD$hEp=Y3}PsHryN3aUCEsVL4u(7W`$mM$A>TJ&<}-UBFO9eOu)hkDx&$eFHCgM z_6^8M4v{|$G{iCOvj!7^XK zfSaTOOIeyaa#kCNKNZQp-uP$4lk8h5UGX4A*@8i(W-dRKQxa~>lJw1!Qcsjc^=ww0 zh5IUi*3S(h1-SrfO1U$-dik^#b(z6lJV~G4%ot4JJn#u77I*q@Bx^kkHGE?)HPi9rAd$riy2)Nfs zxYE8e7{!7CYnk4jpuXk=ixkY1*i^*+cWIpIzy=2h8@Oke!W0_~+)a(W0gUGMl@-x# zNHHH02X9&x?l_Pd?(A>c**!tbdW>IM)rKdWrf&IB>?)jF6;EFVm!#DQ*tw|{=ho!* z3}Qs9`@GGQ8`}r3jtvKIjgF4&a~)~5o!LF8`szb{z1C7X?QKNX=m$#7JGBLSECRlN zCsuo$Xxeg4)3DnkVgC*nw^fx@&N9H*zm5{0m^oC={x;wvN zWOM}s(yy!q@AQ^fMwnu+id@4>hVS~P9|9x@Y;Aw35nb5(uCO4@Gb^p=`?MBkdd zTdr~00CW>k3n$n``TYTATokU~GNVCc)O~m4Rtf3B4CYhMm-O%KTN_-DKN{jgH@Yc0 zH6AE)9S{L+k8pSx3;JQSfRGbHG|w$kr}XY;W?v=ZDgdZd&0qRL143P%o&dFoAPZb~ z93a|P9pb-rFj$h+-+u@2_T^_G#F~ z?UyHE?VenIpFe)naen^U?tfJ%xjgF9dZ(|1V?xjC*(Mc9E8E?1e(gxv_+6)cPX|n? zuy6P8Z)Eu3^al6|Z_bsgug1=)Or@^x4%3dVX<)_y_%0p@yS1Av_nEh1N=Hg^aD8-< zqKl2Zfy?h?_V9+uYu)AJ^oGhauw;p+l?@As1-sNONXp8K&mN2jm`?vw3L1{QfKY0F z&d4r1na@=Er!>Urqy#v~Z?1tj$D?v4w!kd=jGPr~;O|-OT%!o}*Kj6xjO^Aw}aCQsz@@gnQ z*J|-XUO<@EwPEN+`&lXe=b_eHb=NS2lge=DRHv}8W*Kce64@@zj;y5VP1i19Wx%w7 zgO*jz>;Zkc$*9n!D|`|)5?p7)C&%ae9S(C`_hLP>T!3{3_56~&83J6gwxygYcx)f0 z!^1}(@u^4HOsoNEazq}(>K|Q3oQFfGz7&NbolEMG2r-q>5p8pJYgM>IKuRC4GFb?6 z%Cc_szM`dCaf78RHrUryJ@vW*$=Q5GLy9UgmuEFL7Yk=a@&4hzu*9P$POET=JhNoB z^Gb>m?{0-|O*5<2_4NC4!nxsY zTc|+=aZSOfP2*!NK&7E#VUvdGb#Xe3SLRuXf#Q5N@k?+op+kRQT;oDuCEQSq$AUqE z3;lisc0e4=H3?>CBr@Cxb7w=S-u7_OeH47e7<|#4%CP;;gm`ip!!GJWLxY^Bx~qs4 ztc}1eoB$f9*^d@bLB>5-wUxy*wia4`tMrtA3$LlgW7cFFM~0=Rm&(s6gH((`Vb-gB znSq+`*<9IAG#8Xgj9fW6dxmD`m(-zHbgWZv~{g-1KTe$qdqHHNWeOk4B7p7^WDYxNT zSI}=|d&Qg`(K@jUh=B(&8n($5`jNlg{7WcJ4a~5n^G~h;1}b=4*FWO~YTjVBOtn__ zV2LxPZtoo}!7Z37wLG%I3mZI_oUUC_TYnp{)~?a%%!K7ec_9ykiejhn(6wyDFWpsW z*g7)$I{Pw4hiI)$#n?Jh1j-O=#7->y*rV0j0_1ILJDk*LWzhrrAlz54F5%EVN~W)k zy+SkEh@l=4VS=!{8Z_h(lf!(p%-rg3^zr||sk(vr?A>qc?$BC*rTmk*H+n%3+DQ>v zsz)??QJDJ@og4oxm!9#10#7$DFV4Y%kbcF)f`WJ?3{I3#P}Jc52<49Y z7B!So47w7uQ5tVd78GyT^<=!Yr`abFL;J1 z9LHIo=f*5&Lb%>!qBBTc`Rm3m2;Ee_b$r9KwU}VKk zN%Z^fnBF$HF$9VBE$!r5J-XOjS|l}s26yCl;4!#tB{#?yo2f7YN>g(%~$s_iJy>>Kix6^!91#p_sk4 zi_7dzaMA$tcUlX$IQ3icYK^+!WPe3>-gvv9VgS@hg0tZM6R>L`RK)|&GaDKl`ju-x zI{U_+8<8QmXgfZD{>SE;iM215a5Hp5!bCsDpoo??pf4M-`Tq89_Ie+gq87c=q;jcv zKs{F|T&ASV55b73ST@W#_h4_0JIz0D{O_jg(Ad0T_()Mb%(DV#v{4>X=7I}v(ClPW z6D4%*S^SvOyaD%&!<``50O;y)FN!y^2v;vBArRtDXuQ`AA(V5Qk`P{_6l%AD&3zB7txxxC zwn}DtLWes|V(b(VQb?3+Pxlz@%U#%Xg?p95PF8mcKk2ZDPRJ9)ZE0wHtj-8FW}A~P z;_lEImphlDOjIzbup594;Nd`Gnn^4y2lQOH;>Z^5&*0I-zm+2QZLbjnEyjfvCEeAu z*)!bUL~nF>?I|Er4x>*P9PKG~RQbIWzSsjaCX z+UeO*b zxoj$NeU4)VEsi|zB?`Y`ob6sWCyX6u>+-$@+?!p)furx?igTyWjgS|~%FhNLMNp0c zjYH@VsmzH8b+=T0NeK~w`mtOQo=@ha^nr<99twO3#I|GaS z|FHLw?j;1s23;7D=H^`-?d0s!+ZS-UHZ{|VLB_TS5xt-RPV{N;U??)!y3BN)*cb7q zJW8+fK?)*LxD*1MAi!`;e8;YezY)$x!v*` z&8I4M?p+SE3)uRR;BsBz<}Z9{hut_OB?tUmPE52Y)LS%CpT|-q#2pNj4XSr1t4Je79MQ!Gr(a-bN+DD0z>XB}$%pc1^0Xg$)Q6{M^nNMCj+yf} zwI7^Mp2*Nr7s}tq=Y8Q#>K1hRVFlBd&S^hEstO}6pM3ktayp)`jA^}HKC^fdHD#YJ zm+#)xs;r*3_$P3i!HwG=gukhUl1|bbmYBeCu z@ImsCNGowzlLlRaxxx|yW(5Yf{AxLN={yw>5OSP+azxpcG9XtT-9T^$Dv_xr^^5t^ zkt$g*gp3QS;2$nc^&kR739?90Svsn)%GAm##~jC+$m)7>sk$$1YVWRVYA(NPEq6|j zegm+d+FMp+*oLt_6D?=)&Ew9&z~Ow|;r+xg1bl+9p*E$_F4Q}tp8cmveo!X{ZDyz`S>FlsqeL?}6gD4+b_DsYG`tkiDaO)9Bo)g9LK|&82 zU70KzyD|(8nGPzSS?p|`5LU@y4&#)6Wl`S>64fNe-9(&=^G<;|uTFLT8XhZYCVO0G ztGk*%%j8a227gSmta-5*r_gF7(XkXB8YucxK8m@MXC0|38cc$t>sE;2HI2)31)=9p zyQ;)Qf{~|ZP|?aUKEu&`Im0M6D7j;fV&C3ec_U+5sDvh zr%J^12)CeRVMSJ~2m_J?tt9v6dg345j4VxQkDThJSva2(M%a|*avRU`I)N40zv#x% zZ_8+1NqcjuR%A$cRj_PY0_LeM#|-nX0SCuk6a4 z(wv)l+Sw-PrYNMI)QhTc51JI!@w5Epl&c$kBydHo>^5NFK&DFA}T63U+{vanRyT2j^6)L5ET z*;3V*r4$>i#hX+Y)l}5Hs5(_#rmD8vmrd<#RIE%?L_DBlL&U$@mzpLL6n$K*oQzad zkX^+nmSK{#oqUKCD>R$h%c~2Z%9K^BDl9Fl%Bl2pl~g2}651#d*0(wB8d^F_nmP*# z%JbKZXWFDq9Ma`P%2k^eQ&v2wG_fk;RqVDLR1+r?3EVH_XE}@38iA~q)GZBhS^g>- z`7|WRq`Q!2;CLdw9xrFULluhJcmM|x5y_>k)Kpgx?@wH;G^r-5RsO5@_Yy8r75U>L ztiP1X#v;OnVb)KQ5p6z?5}25snOUBx3Q}X}LLKx#lroNmiFvW&VKtFep*eAJZZ%=0 zshLFy@%?A}K()~`OOpx{aXZp6`#9J0KWS;gCf!OPRkLdSrUO=FejdLuV`k(TFSh^)fHz^1&@&rs`9q{#o4RQy^WN$V>2l%Nw8{uQe7BX{Gq)Fdc*Il zDbr-|pRwJ3Mu{wr$zarlHu=NiInY9P4fxV7fUcyon{x#*2jpCC+*{ob1XkoD=U-hV zpwMr%=xiG-^|*74XHw8mXKY6&|9oo5tN7Blslk`6x91;zU5ZI&&0SN0Qha_aSXuU>56Hel4Y@bMx!w#0u?zJK}Xx&8V1@H-O^t9EYMLv zSKO2g#KCdO_k>cal`ExNMJqj1oe^>r!9c>Mb+5!jXeZD?##ltrEDJH9W+Hi+dWEx7 z`(jPI<59J*`W({3*)rOT!MPs*NVN7c3tOX%OwAvQa^%C-!yA|oD!O8yqo1v}(?~$*39z{=W5&&`+d$@tyv4YKBMe$b+~ZJuA6(1a>*c}hHzQdd7MF;P z3`>GnN-I2TFcvP4<#Hc*gzi<~~NPg6XxsZcZ- z!dqv73qP`($olzpb*$9~Sv9!K6Wdz}hP#rAUERdj{s}6di}2{^6kUPzbJy2wlI6F1{nNp4`y*g3HEI5h&n=Ma{ZWS>3iG@5dc z?NICxi*Bc_6||aK@J;+MNrhvkLZ<9;L7@45TgDfRiD_u#QcG04%2Gnw)p6(;5Cz8> z8^<+gp8G_d0!}9C=A5y7P3W8mqfz`%VmRP55O#@Kgt)-k3AJg_-|AXCD?cqCWt^P> zm#2)^x&_&b6Q|5(Ab5N>a9+YP%fY^y038bbO{*SM#f3hZaVWkHfy2uf4_}|&PZ(C& zH7lYQo?9pq^2#5$2u5{v3Fmn?8aprkpw8})Kw&b>Omv|_+q#L1k&6>?c#OgZz92RLM6A=-|tPUQxi%5X1oI(>7TC(_;>QjFAWma*t|OCf@pt0Psmi=r`4v1M z69Pd`PZn?8oQ-z4D>gk$BoZVCK8YSza0)CLK?5GbMCeSI5ASed7?}Iw=hqIQRA{^j zL+>btgh_LdkAogg_>9OGol3*+L~kWhx@DF?OKBp8m434 zesK3|mGxH7F4i}%C$PqW?op{t?Kcd3VNC}=V?+s|BoDV8`n$>pLX8lEqQXA<7yLwQ zo1KU+@rHJD>E6D24FZ`4S9BLI{efg7pv=hT`t!dH=g~qR5u&Nl>xaF;JR+D$glaoM zA4CZi3gB}zwt}Zx+>PWovqA<89B6|qfQ@5hl<$x6a9*zVgtN^FIKvs4r$}IOzwx~J z9;2GCE(3tD9BE~5trcM8n`=%^ruZUt^C~|J-g2<7*uFm~Js!};c~EZ97Ry|c!eWy} zykg(%mAAdI-CF$SaM!+r#KM*``xwI3eC;7->lWeB0u3(KeHAfYUA4A9J&4M<-9S=K zaBvQkt}LlB>WB9Utf(<3&ouO!C$iiYZ_8<-G1}{9P>J1H`8YeRyBRas?6whR5LY3a zXC7j4AIA~}^6)J)&Lf`w>TMHp4yW6Pk93KU!N1iv=fi;vvqTE!>{=V*S`w?MFvap8 z6TG%l_E9t232b_KB@FB(l1bY!TIUWart_`$7&D01sIEWkf1k=42S!(fM!L}1Tqyey zEH0p2Uf4XSft#N=YHQy#M4$&X9`)&`>w=w^J%zP^Z3tQHdj)+4#AY5&Nf z?WVc;=6Ca39BMh-$~(J;)wxV?^ULq(Ip3E(l-~3={t*TBTNz>-g0ZOkdJgwjxUSKDvC!wrIkYKC=NzGvrVM6 zMbYakNjkfR(9x!|I-cKB*3l-amKpfrx<_aB6Id{lUJTTk`de*!zEA1Evi)aPC!e+& zL9L{Zs7xYOV=Q*<(kIOb&U48ihdR#N4ReZciotG>$ef3s!^rv0rCwx&v7KsR$YkHqyE*%s(%B)xTUEqu zsl63_g9C@^bh;u6x;O_{&W)wLm7lkwu)|X)JC4$sLv>E4kajv{+_t1+shwSGSzF5O z!r38I`Ps95<8PFb!kR>E%f$-Mc`KoX%^DJVC64CGrTlCGOS_ucMK?5tiJN8xk zz|CL2vVv_qi^_l7fu?AXZ)Z-4M0%EKI?noNfN=-JBdlqKN^Z^^@vDvG%>ma}eKx;( zIrDRcdCxAwvD0L38aKHO?XXLu_7_y~X|jIgHKgd9%PU3?FKOUb;x|;W*ZFfM`^})h zd8)6RTv`s#irvq!73 z)gFz?+Oat>CGb*wv?CNSvEtf)U>E%p6eU9UNJ z_VhXW!EEoCm-S%X*af~Fe@(gjNi}x6Qf=?pdia8G`!Q92=QzB0?O)fJZrpKdeW+q@ z*^Ml9O!T8YJ>;~WxQ*O7$R*>-^?%%GuJ1qxeowOM#ufJ9Qu}o*+Ev|2`K5?$>*f2h zP3Q^uK`CwPMSjiQKs|Y-QufGi@s;@IF|S*^SiDR;XVBxmx%1!O;cuG_2tRxi96r&k z(dK;j)_>9{eDOlhjP2<4Mwxzf*+c0k|9v`m;-&ULWwzhF3G65AG#fgr{)%sORrRF& zGLCwzd(=|zL}P?9y9$8}Su>=QrlihYf>#RXklBd)$pMrAkOn7{3SglAlP?~GEs9q& z?vG)OjnW{UJ~M0ueoD83wPgX`By8Uxuf;f*Y8cy6;n)e~pm68hDiTET9!Ji^{=>nt^~zL+Cwm=+GxTLoxt%iyf3iFC_GORwtvwH)7UBEq$)~NczX`vBUS;bI z<9e`E^<^LCU=qJ-w5gxf5r5|OzQ;>_8U`+@y|#~VX*1?(!u8VE?aSW!tPQ^L;1Tbf zLg2!ZZ?D=cKaQlRae-*8zxJ;(BEH{IULh$z9tW`48j9LT$pAD9tYR%;*T@5*|IQaR z3oxxN!ycH*-&Yl4rk2n>VJeW%D5qr&+dXb71b&VCHF_!y%2v45&i|l^tP#&2nZ~a2 zx#$_CRUb#Y>m9vXUi1MOn@q_d6Z21; zKD@EDO89%>O5bF^(5<@^!!~yveC|55+`(6zR-|&D+;cy)7e4reE+O<=%H1d1t2f>! zYMw}bUbt}p4G@|Z4Du=xxgkkydWs+km#Cnbx_b(wY|66|=DRaVz%CMmQS>g;m05z> zI`r$M2gj<1LMqbPiTg<}Zn3x~WqAm%&%NXxuX~j_-{&`}eFV>_`g`8XK$iM#v&L<# z!tJ)eD;YnZckoJ{7_KQjZ62dTeA}YZF)in4A`u(KzNvUIsq>ao*P1PL(*S`RPIMm` z28MMYwf-9T9E8fo7{$t_K5M$AGc(82`j+Whg1V~s=1qvq`J z6MXBbC!Lm9Tyw&}DkW>e!D{oLwJm5VD98x$fDjLICTV+2m-Foz)<>p1^SG+}L0o&Z z0y95COA9>_C9q~B5H4Is4Oq`T&BtCBbBibW3CzC%Re>?qu-+k6Ax^8Dub8sBY7MaV zI9!0&23UIzu7uoEntNu}$lkr)w~!l?%*(@x8eCMyzN~pJwMfk&G-g_BlBm68WOWN` ztgb8<#xHfY&2gY>QkTZhSg4VjtX?FBYnLhEDgSu<}%f=OB{@DomZ4e1bqC9= zP=DLUS4q~69JiZc_%$YYRy}ds6_}Zj{rB5R%EraGS zVAsmA=Am*Lw7afueT%V})OEbE-9|nPmqtD*O}kYn1su6+6D@-6MjLNm?}L@!Rfd}H z3h!ceYHdE%w1#pJYg${Zv3g9b807|EJ_BqHTAF&wPJ7qciFBRt;H)dPr*;D7@}wFF z7zuJm$%2qPz$3DUGLQ<981P#XMYFN>xzJH?^h+JW3rBQN(UMI=(JapGBOL<1#0GOfxMPM6DvSL4moFiIG&@iy!HRo69g^&1w^i2-+F0|g(;rwiU1YE# zLA1!I7l<2N@y$4WJkH;Odjts;$Ku|n=sEYf`|@4<3i#1q^fV~e$1+3e13dBp*|lx| zfZc?6ZBPuXBfUTDpT3?EY7uuZTpl1U?*gH=tMJbwiw80hFF;`pQ!5!yoad*SchVX#^}M*uMUZ%?qdI4gHh>%kqFPNe3C zgI$>J{(^KSnW5}5n8@mH3Nsf)X((coJ!>A|ADJP);DBekqmfi1P)14k5VoE9_qDB; z5sD(PME9y8Zyr-S+$)_cp~3m#4g>ffRdPBdo>uyw27(;`+3s<{ZIeVd5m9iY4{=1T zFjUG>MVS=Djkd4LZSpM?pb$G@1i9e4&Ltq_b_uR6eH~q&Xaj(>P=}uLBg`c!i%J9G zFr;k*gNjAtip52}vcA;C zJS^c*n8x@nxyB!I(iuTi6W(0L1*ANsN!|s_TE24XsyrUn2mB8}u=mR_h2v<%ogns{ z=)x|vITKj<@Z zK^ULT(9ldWsHWp4*g?NsWO2-gdUa_jTz;U?6b)68r`Gdw)tdu1N zMc@jPLgdE-0K@^z02x4zme(X4u6Ziqu-HTtp z+5huODfx^k`OGQ#%$0ok@~@XKRy<&AK=E&`dVrDqKHF;6KlIlksV}}k>3^1-O?qynMCcj=8WL@eZ{rZ8lYF4D7tPn$-FvElVB*p;!S~3V} zR_3blNCEpzxHF3fZec+WF$eThL(h;%-#SthnG|E2ge1LP_rSIa+!KWf&I8oPn%7DY z(b0C$B_2Q%_*xI(IYRjleqKRs?Y}%9S{r{SqrJmzb}98prZa|B3k)!U2?C`G|1r&! zA*ADs=qV-o;(lh6kBs<2Y6HgTs0NTi)ldzqYz-19HDBf@@2}1NTHNTFi&Tb`5Y3Np4r8tGW&U)g1q?7DCr70<{Z& zZ<`9xj$+a{DVa-Cic3cl%0sq$o7$}6S|-aFHme~6Hd$%;{UM_?+%zL|E@T6-BKH!) zr+UUJN#L?YA#d)ge{7cr8ZS8eYc$|*#Qs6)0KL4t#n1S8q_DAg9J9NxukJlgoP@4m z#^z=N&R5*z^V1`%dxFr0DWqm8P<3bQ-mrs__*xnpKN0}Wy8~7Ubn){vE33mRZJ+roupoAuAa-3Kc4HuR zX?}?({g2jjPyw=}MYE&<%%}jQh?$KYCu{(+s{oiSWf-l70NLdZVHmYf27zB^!^1AP zdG!R|;ZhU1ufU2h!d?SWn{+#D@c1PABSl++?P*x-i${_}ZPsVfmdDxRQu0AXOa- zb(Wam)pXRgw%}x}sno#+d8N3cXvO0DHUe>HVPZAf)(WUJY9Ggw(B9oObF7 zh5c}bg7yS4E~cQdEERQ8D6pr>1NB4%?qu_Q8B&&fYwOAkxN8H`an=&mO#;fKOV3@N zGp{7ZByv-G=%%(t4;n~gV3m?Co|z*HNb?uT)RM%*7}a{4rE=k>QNPss_a(F`#7wFp zV+&g;Nx9Y!l1${BWoDBw@_f7=Bk^d_QBzJkzBu`nG~|I7ND(-S{Rzr)hOxS*zt#1> zO$RJyR9>erCA1aPJu-^DPF4eT zKT1>!ro2kKPTp9@{TBul@%v9Cgjs<+l0WWmhW7A0ai&K2K3um_OeH6M;1YAPOp&=J zrDtY!nENLt**)r%064AWUdn#1d0x4;AkQ-CHbd6P-V&OeV+qM8X8PDc3VF#Bp@+yI zixyd((4^v4l>(qqQVLAC)HgSs+Ok=gO?10(ED~$L^WpZoLLyDL7+MWmZiYzKx=Bq zb^Bf1kVkIER?`5lO6|c6^TW z{Dq{D!q?4z(?^tP5d4YkkO#y5CimlvM`K80R37!x|7FBChVgas1?-ke3ysa$`M_OI9+j~p3S2+*WhgUc1md^$EExCSP3=~GKfdyy-epz-kp+Gb7y`C z@s2maN{yq&)&!YwZ%)FX8bq3dvi(vQ&|TRUr1& zj7ev|tv(2>{k+Y&bRNZr>TAPhwxY(|b2{6u z2+@8|BXk(VwqnHoY1!BB#9@u((ocIO)h53VwwguOjx%Y|&*2)>(#CF6X&K- zai`c%giR{icUA|?I$A>i7l>OXh$C~vr5G}X)DsSc$ExyLTh<>Y^M{wZt2$n-NII;O z(1i2o55+E4sBf&f7>XVBp%%sjf$4wamxfae#wHy#0-wDRu(wm2yA z{cemY{V4NUCekQrywmv3CVYY&L+JI++NxO>e7xez73LBda*U!oKTip2xWo(B^B3z3 zkNqY|r}d41@)+B$z^YD0Tt>L#k8uKijQVi&54itt*Wy5~gw<1qkluK0qUqG}OZxsOqt1gGu9s)9KI zDuY|-+@VBdzoAIL@g5pI!W*+K*8&69W-JFPp#*6HsTW~?=%_bweQzwpmxi&3aW^N~ zZYq}*W)v6xuDY?s4eQHni^VxR;if-sBI&I40)fo<42njqwOaFsf9RU+A}|IBn^Yi` zacZW`x<<2kDsEV{={TZ@8dGxNG-^fNBtgxxK0fV6q)P#)Mr3uzLc`N>1MKT$?K*O} z0n7?)s2hIA;VBbT#2h&DiU90Ek+oP4D`~EZ6?3*hedhl$f;C6sxW2JOSam~sC&Kbt z6>G%{Ht1S1K|7V$Hp%?r^y`TPN4hHAqsapN%fF!xbG$T#gA{7lQraQlO?R&sv6Tx5 z`>atfVhDt>GXzUx`khHasSAKZvS`%yqylA38jg>F>pzuVG@M{MRh|(4AVskWrUQE z`PYo81^%0n{9yDJ4F1_={@S=f+I51q{Q!r*&~pwBD)xuIBWizQGX`nb=>YB%ZehCs z(553&%L$pqRH@3=PD`bKG->LP9!)Z{pDb&_G&90g>|iWL$<%58ME<#?FLFv-=8!hg zkTyAxHbM9{{`uQ`A$Z#^A|KF#S)o`xn|UU#ADYf1RRm|51jC+WE=H_$D8?!*$-R~v zqzOluRT(eS|H!#!swBK#)POsmU7;V?fLNbXVG!$tlgz>9ztT2WkNLkSd*>iaqb2RP zyVzyhwr$(CZQI&q+qP}nwq4a#)x|F3tC@4=-tU|nF*7&fePi#~|L$D*&12q3CIn!LYUw}{d!@xbFV(benGtUOj|cnQkUcoRc4h0O`qe@q<5D<(c`IN|e`6Rie~^l> zDi(}r8lzt@a~a`;k&W!mP{Geu8noPdmf8OSV4hCv1tvdAP-Vm1Cnc+vRrA3ukw=BL zEbv+sgGFDFMoW-Jhfhe-pywu>MjabE9I{Eov1$9WFi{;c3zr&gTRUF=kJlr_U z5ph7<+>z`duC)*dQOBN_OrNH%%x#t1LT#%7>T=?$3w)TRrj^3NRE*278;_gz9Von9 zO1}fF*_0XxjMwXvC`%kXE0yaNO8gfh&wlKaYNfeq1TV-?&CBG^;4w&Z0sPN_kV>*4 zORyo3I+B4m9ASgVT@a9QRx0z*3|c4ySrhe%CU|0aYSY5OqdyQE$@VqzZV$6R^KHVs3VK zL`eEaBCW~H!j8Y@k!-SJP`Ta8tSlj=B(0ovS~-&+wFY68^pQC8z-_yiuZ*l67G?Cj z5d6eJ!|21}fS|g%xJ+GAnYdXr=lb0zgD>at5cP~SLzF?`Q4mo&$=m7ZZ6scXdjls( znj7J(RV!+)(2@;iU%`&i;ilqe0MhJA$^fBIBVwq}hQzILHIs3P#7dg@uboUW#(48n zIEMHwM#K799KlO1F|tPvh~dXMH&IbmCUYF2Dd^VBsG11I4U}2)Z%Bc8b=V;(dTBH4 zz>S8aoLkfso+V1q^Wd+}U2L3AlQCP41}#>QPB%-lv`fqjA&t)OsdMR=yO3p2h%&%ko*P-mwb+OP_tiP6phKuS5$*6s z3tOkGEotUPf86#4`Hz{)N&3&LyR<(?SEnj>Gu8YUYj>2@?7~}zweEi7+~z!&cD>5KYk3#s ze2VdW$a~jJ>^R(rLxm3RtEx%FAQ$EkEyvePFiv?Ms&vEQte-4m-=8CoGIuy)pQpuq z8TZC`AC?!6DO{#i{+@QDR#lTC)aH5c!;tqk=G2-_z|qmbL*@IM(?5p1qyG!!6rPA)+TIj^3Q4;p z17D3}Nm+kLn6Da72}()s{0(w)=^w5qS{0zQLaoz}9!h}GQV&o+`st4B4opn@)kdpa z*S|8A&cS>7zMFn~ikCa`qvpU66x5tELM2HaHEk&zmPK@S()N zrL9S&9|E0|Qbjt?6PZ^sXs$b%_)b4}UzeMEZ4G4(t#x%JwPjUVQfHWrQ7^TpCWhkY zW+ZqG_E@RI5h>$FIQaJ|Xf%=^ACKzYPc>>S?Pxde08zTIcdkN#UAaG#zT$F?XkLt4 z2@-ja?Tu-?kZDWjAH72?KL??FFsc)BZQ^onfC%^0(a2#A@edr3lNQHVL*tvLl|5ta zCJjoe2d5G`FBL%>RiyG7Fk%sBufZT|ZpEB6vhk>;?@*Tt1tpnlOFL>% z8xwUW@9N$3^^i`Oi2~GPkfbvo;ZQv9LBa z`6o3uS$Wq9Nd@`Smh}oyC!e9}`v6{opJrPWpqZ@MCrF72O@m3kplr~okq~0cX3H97 zGQzg=4vg(#pQLaRR7h`BOt^B}kA~}WuQ#v2eQGMP$jW&*_-EVe`sFtJ+S?58<3k^% z550<9P+mEh#W_z&f0X4A5KoCE%fQlTKh5|(_HWc;^hO4fG<0JPf)J1&s8gSnamCr} zUaWh62&LL}rU9_?aOgE=za3N%u2%^uF~Dkp!e-u;=iH@dNrkUjgx0<9mn#)vlE zV3Y8Y6;T@A)hL#1!@g^s zJn$6s)wn0V;TZWtk*NBw?l$=-9CE?;FjR%R#GpbHlyfcU7<-S3G4C>#XuIvS_VI|R zrV|U~qrS)FHLAf4EP!U7US`=q5#@`#$N^;qQq=+T}MOO#!xZ!jw zC6E^MEEB3sYX^B=>ps=u2{{w@q>QJ`;Kf*+b3_W>m_!~?eC`3Do1l)%{Jtbb#9kF2 zWvhlL4O$aSHnx>Kb}8N!s>#{JW;CNl4VC)3tGsHQk5 z{9Lm!PKZBMG~Yn#r6QUoDYt+Taj>hbnG6If6J4(^q6Nua)X8?6sd|xh_pz$hG&nPq zz*05rGa$QZVK<=1kbB*+SR$E9hPj#mSH7L}h!?UyNc`GjgC{VMzFO(kC+t%$50DD4 zYWN$n1)AASr)lvmb-o&o+9;K`EsEDC@9P_vOhZ&dHMurh8}vjT6Nuq?kp!AnIOZ|H z5X-(pf}DE(4yi~_|Gi@=@@J;bN_<2F-Wk&Je?Q0wem|%Xyo|H_Y)WaC)6mL96gDs){RF&S5p?yNc`&C%4a6cEmqv&QG za>S9!Jsl+V$S12Y8jLNG^Ljw7?U^z%M$;Zk$J;#<6GxYY&e&gSfF=kQh}PQCZWvci(2Mcq6sAkZEdQ@S%NrP3edpJINc6P2m%Z{b&Zq2S_d2(M(m+EUeyp(stAco(nm>F& z0C(Rp!CsJzql7AJr$b|8AgZ#Uh*mie4T=H*etET~e53+}rP#2c*~VAaH?X1H28XLU zb|nV5uxOX#$&^haDE8}B>@mmdPkZm^(_7Cj2iqxKw=-&=_4`0Lb(%GOr+$D6{#VM} zio6Ih@>Q9+MYo8R)Gu|k;KF_3X#ese=V~0+fIE_Eo_cY{{FfF0p~N)W=A{+PU-Ow$;M*{G6pv6r?9hCfNw(H?gJgRUVx5T)&GMsUm`dm(iSc2= zrV&tT`W60CCY#t&%=ZC(%Ouh1$!UC&k9yk1qs{>y2s9hU!C#Op{q70H|mfr?n~v{SQ5~x(?G207($V3G}#GHFC;IHu$)NF?vcMIb$UR7n7j!< zbr?6k3zoCikrwl-9(84dGQR70R7-7j;>nXGw8URLuG9$VvzLaq?DMx6FRq~n`Lxl< z$?LQ(`LL7d<0o~(I~5I@R4}@S1I^~}V<{N5F`!&Vx5AWHdD`^@A)?Ino*h!n1!OV- zc}=^7SGCH3Ac69{i?QQ5lV1$Dl_aoMNtv7-<;@1$zcf~c6RHuW!~d@74~nAM_C*63 zE!~^5t15KT}9p>h{RHy`XJq zn}efiC}TE+K`Vpp5(Hf<_pe^`?-ItP!uIeYLXi}OghAmo@&x0d4ATTtFdJ13Sb&y@I#Z}`lj~%`hn1_PuAZ?gKO9~{NU>W;?`D&(rQe&feBmtsFmJKBAB7sM<^HKzo z1`C!_PGDPUWdwh9xH%YRl)}YA$chb{YqQxvSHeU}iyA|w2ndE!Wi|e6K^Et#iF6%( zyD&wkx|(ferLuEF@g+fIS#jnrK6xeqp#xrrmD_G`Q3&e7hJ83Ju->>!rV>u74H964 z9mmU%{yv_dg*U=KMw8vUd#nRm8>klf1sbM=ZM^UG-dr(m$ldo{wg2$C_0h#?@WZ0J z4#jZfqsctuzCpJ>@lBHnY5B=64MnyhiyIjT92(E$Vc6EfCiMX4%iYiI&miZ}E%B1} z3(L<8AFr4^I9IDrTACljn|#z6jMV3?E#>hRANQa+PQtxDmLX=C-P12j4$htz%kyEz3!Oq?CxYSGmj}AJK(neWmIjJbs@8p^4GMr-K2YX(XnBS z(SO1+lV#TF8nyLmXYGgS^|tc}$E>dPX@_B_wz~DHq*C*s@|82!S=~GX+2)n~CTsd8 z&xtE%&5XmT@@a?Dc1$6+&s!a@PLO3-!QFy5#=*qUHSMEVsv zaXRQ%pM?;fS+GXgPkRX5M(zD}{sV~!9IWgrHM0a4F))o-zksTH=my-CaZ1P@SPh>X zrWsQumUE#20a*P zIl5wP%=t1me-5Z2Qj+bJM7sI%#~fZ+~lyu*XsPeH6GO@M_MQ)W` z_sq@HLCR3#{_=@9wJUc2l#MtASK{_#9KL6w$mvZuX(vZ7fE5Fq%S)%i|78rR+Tu7M0Y|kWS+@=W}KmuWI5}EnVEe+n@N@aiyfHwptgNaJ)T}@WnjS9 z@Zl@P54*^MEob2W+RCtEoptzVpQGZFsWtv$pqM^han_MDz9C+YE#JU^a-uVMqs%U( z!<&2G?(Jzyo+SoAZcK|X5Ee7Yv^N*_V{7bH61b<jYA02z0$GW7MbWGtS*3{L>bN60!}VeICV%b51>AXRoJqXC{m84_hU58p z?9&#Qg53?fzpay`W8BTsR4Ss|`DpKB2V4D>vUH8-UB8w9s7QVzxd9jaE7o>PCT(bx zEh?CDptEvOyEXp>Xn*Fi_`=1!;XVI^W4(G>&*IZR{D6n>4&D^s>AbbO?DrF|lM_Gr zGW0)e%<{uQLq6&*GAtA@q9Vu^;gt0Jv)`I6yMCo!lf!A@mQ9$FyrM>Vw{cz=J`kT| zf}j|}m1;S5stnPH{$vEE0Nk}2&KVh!JhU3#0bSEIOqdGu0!d);jL|@>(+DOJnV&NS zWg*d-OSDT(yH_FL!6?aBgi-pwz%=HGm!0O3eZ+BS&baK(lTcPA=9|8H4RfitX+?PX zMJ-jRTO=!%dtWS-^n`!d1w!{BQS_eG?06D45h&UY@q1mCv4Lf9kkmeF9ap}zj+&$c zX-d$tEPnc!idmUYP-t=yQ!^)@z?C3#tTX~jo_xeG!hUe}Vs&{A0!214Kc4{rnDK*w zS%zDU8=R95smaH-H2?aM@#dhWDf}WS9IRP!7UXNLS5aWasG`gvEuzGv(&Tp~j`1Nn zZoUV_h`1QO0^KG;K?=cF63&wxD=&4dOS8l@mOHx!!iLo;~NnudZ{4L#>c}3C?gpQ~Yld-d5aFl6u z(mcf9<-KE3cITOKMS-JNC^d;d@*1$kQk<7uu`trWE$+)VqSROSjk>0hx(80+)WEKl zI^tQ5!L}5SgWID`E^zzq1lm|m6W{CPQinxOlFir&*+LebGYXLS+{7pYqKF}wG+JB3 z06Wp-wcwC0S`-@!6t%lVOYz}VOofSfSXX~UFF?1Vngc=AOR$n})zL;>jy!5bl&miX zYbI0uxuNzvw6!e8nlok!7_>F29}0O3v^1rh(PUX#*)>Z&j>iEk;@9SoG5Ex=tYHK2 z{V)#&w|me%W=!I6KFBPZ8g)KmDvoSDEkLY?htXe)iC|ws_7Vq7 z;%CmG2K0pb0KKa~v-Y=n3iWn+LIuh?TM;>={q8?78xfZr*qB}Cv&>=aJm^?)cVyTfjX-r}GYTlXJ&sF*|OrcB1 zUUj2jS}{&jYkDx#QU+lot{=+Byn`8Ko z-B+_ZlpE40%I8Q*Q$tsZ4pN+&+B z(X04wQcKeV#s=6%3EcNn3X>+5x!{&b`faKAK>T5<+G3*htncXwYkobm)&7*})$3)p zV%Oz!@A5g2*n*;j+kT4TCI~*5fe73bosKywP#Iup~MAW-5w*u7T;qBH$#ynAWps zzAQ$Rd44lVlK}tqVm^um^Uq>E>IF!FWp)FnF-kcCxu%&ks1?6ZWnTtpi0&#&NwF#j zjicAo?3MI1Z(*Eh9WK^U3tmzAv%aD7tJUhQtu=8}!D)u5?%t=np}9dZUk0{9Y>p}5 z8!1g{eexJ3NWexj*COuMWn9V=*?&$4#q&3eC_i6mbkMz)e-g*qNz0=UzmBJ;pgFk? z!a9B}0VuP=Xv1h7z{R6YuQTYR9dgNl6I&R$={7A*F}?Sv?6=?egyh8_Xt}|WC!srD zi-mh{%+*?d6P?;VZX@Zv`!05uq^$Xw#?VNT66Rz&g7YB+PUlS#zmF(PG-i9kY@jkz z`xezxmzcg5cQR&$U|3G_;$oC$k@KYpUyy#$qG`YnaJ0?4^TkEIWp6)~H=h_C>SGzc zu{UREeR+PP{7aSvB%mvmLhfF22F#JTz95Ui7M$8{{bI7sQ5~{f+qEmxo;{h?Q{-hh zef2>BoY0%7z7rJ5#@X>xoVQ=yD4>`NNT!|*tJ4dq)7v$Xxn<(u=f>;nxV@aPlg79R z>R5PX35?|q%A*X}Xuq^_d1=jkJ;al3L9v$mKY!;1c?&kwKQwe*a==t zFYNoiFMTCSU}B}Vm5(2NYKyG4!;+g}YL6s&ae0U0`EwKZr4HjvGQlNZ7#4Y~<<(pT z$ZU>8VOA5akNf@E3>XNqh1@n(&Iyi^HXk~MQA?qmR1@87Y{?j|3uI_y0~8SF7OhEh zI1G=V7M4yVb0|CDV2c#Pi-RiG(Ii2Zo4hIInU5Xt)qniuSe)@h^2$n}5d&KrfS}TG zUA@MvfMriC7$$TR`=zNM!zgW4xq?S@y|MmcRMMb-KIg?@+>&s^otUO2nMQZmm$eMFTKsmwQ{zdnQctezuddJpV?u+ZMsA1TZh zq+uxx>GuG;+8$grvuZuv8W1&IW3#iouV#tY=#E+AiusOD$8TfVs1r`tJET6TRJAhH zxmQe%?FhQAWBfjSEi$ciX2_AI3WIzew>_0t7SCvOZd8>Ge_tlvxgqW|pgz&=u%deL zRPAU6%LL=jYN8qkHB$rqq7^~v*GkYR@NO*U}_!9P;vb-5%#V{KYPGk&iIqi z&Je{mK1|p{DEK8xles7)#8}cwx?}_5HO)?%w>;$W@`qy&jChe52H7C&TkgSjNb2@Y zK)B3o^N*jY4Y&8T)cv|_P~n_c9Xe$8NN@MRa4Yg3!jf>S`&q2JqyJc~wG%C1uIIOc z70~I9pz8IqEMSI(*cm=SRzAR(VUhVqs%9+FEpl z^I9C0V-5{41>?#SYy!YOU5Ns5$7#;w~fy%oNt^x3Nr`5AgCwLX?UZPb3khFWj4OAoR56WYZ_j_L@aI5_HJ>I+({im z^>Z*;@XxFXim!3Wj_NV@|6`ZcS8Lj#~!9K2b3H)f5G5D@= zLUgmSMs&jIP>kP3T@!qv*g2=HlE2}w1ZM2|*v55a42N4M_HhRzp^>qOl$_!fX>9%? zZXl~kr)pf+ax?WR0;X0ug=jDuR6(#@BU>>@U<_bpKfyD}-!uCki5{~{!Y->Gh@@^% zNb6(^B0`Q~*+$9QkAUo8GJ%!6d&5P`Db4~Qo$!k=& z4F{U+QMb0J^9R64);V@XF?-^?VCX4GO1)0%rbzR3PAZAtT(lNBQjT_>)Voa^T`RJG ztV>ZcnK(lf93p6ot`>XZ%dcweH;TSMmxpgp#O^l!7Js1_CgT(08;V`JAAOdC14;WCN6_OG+HP%z_^Ut z#$cMVysW)zhx`2DmvjqTRZ>7u2WNJ=!|}A^^k|BQ`?U?cHfjn>0dUMCiWPgWRn6J2 zC@jQp(fRiO@%-zwo~oLO3HQs+X^fuw@tNgGu!gpK(Nzv4g`jpwX26(z+cxUlJQ`lN zTjcDkCV7M*5x_e*mENViH6lu{Exp#QNUKJcC2zsJJADApl#hlZ&bp2%(qwRmt9#~1>Db$6eMc6LaZTH|Y&t>cm%9JAemCX3u34W7Vg zAa&4km%F>OGHF$t{r#QR|Gqo8m8fG0WmKU1$~0P4ki%58P)!H5x>j6sOy!=hffSHt zR~922^XdE)s>h(BBHE`N|HxO|aHMK3_^|$1Z9=W=EA~)VQ%$3V3(hscr?Z!f_{@SE zQ_ONg;9al)^WdCT+>&uG-{u-%=0)J7l^??1f7+)xsi(gLh}+LBoXze2S-@s1=_nx@pz}uS21L9`vxrA7d_mUod4;6$ZmYx8i>iu1``9Xw^!a#ipW(#@4s#%h+(5~Lq zRQjiKJjIeZF}a$j+Cb?N-8K?El_5aF^39EZ=d7hX)u(xRnUZTs>QdUZrr4G9oGH*y zt7Bd|x4I9y?JFtDV#xd<%J$4vwNL)i(3C+paSvt=vxNAWWDGG7aR8A}Ix~?W7PJL( zM^3Cyk$Pvpc6-}0wcS){mqcUG! znoeatit~#3k@pyNWdpaEoN?5Sw1Y=lx7LsRU4ixfydVQ`B4{B6*FlO!sap!_xLLw_ z2f;^(2l@wud88G58Ok%jEBLgb={ta?B6+Ma`{=HVNzH{XNaLM-1wekpLfp3taEJe5e5qd}wx}6d_S!uSLJKVDDNv<3uS|(^STeFmgzw8B+H2T~z!q}K!-X;4SCC{|N zSTulk>6TF&`E9-NJ4z08u!$HmwasZCuCnwuT92L2!hGvm6Gf9<#_bLacI~E0YA@5p zlX|V!hc_x$_ai*(^q&rbTzfbGF<5~Cd(0e+7GTL_#&yo@lCERd*4De#nEP&)4(Vi- zK|Ao2?msuE;59)UHC>EinRxk7#I;d(kw4%`JIWlSOI{F2M3dezq!IX}RzL{+mSA6b z_y3S6;*A@omAFX{0m5=epa}ze`Nz5~BV_dSpAn<(iTrNN&Z~9mZ-6)ivsXz*!DWk` zKqbPa5hQ~r9;VhfMr6MVi}pxz1<6SW;S-75f#lNVD&D7D#1;r5<3$bi`dy1*iH+3$ z@H4m;;%ppTy&SlQyx88kvT;0xI4Z>!J8^Ra5l2C2phwW>h=%0r>fb^8l=iYH5&A*< zG_5zS-NUoEFMlU9(4J3-e-(k?QmT`k@4#;P76G>Zl?W(18rV9S+Bw?%s|IL&M>o1} zhf=4aaZHe-JOX@6zA^|mOgsZ5Ekyelpu9-v1NaAH0-~^rQ{O zyNOTd_q^BndVcTkcQ}2_4TL~Iq?NKdEWby*MpbnNcbtDOm$V+YnGPyd#&nz5RFWKE z2`{Tj+l{VA3G#~zpd$3KEDYif+|+FLP=YN^7QRCT9lXe=r<2`0~JLARs+aH7=aqHW3BBXt7} z#XFMrmYf5X?CKamVtk;(&sXlD+Cu$Ovx>1%To-2SwYtQxoUUtKJ{YOd_$$s8)=3CJ zhBgX5c_Es+G|kprXR?%OY$MSpbs*)^V#17mzz}@Wp4^fBhZd9tG7y6Zo6>CFoq#+L z)0&+mm_Qh$x6o4(PzuD)5fISkXF4Q8;u=KQ0G<}%Z`k9zCTzO$;%t^=9xoLUe%2>0tpcPlXPq@NcVa}~ zm#b|yZ!&0$!H~uob>`MBr}fHQ4Xzs1&Rk}*?mt?JT_ZU8-XJ5hy-Flov?-=)Z zBS;`^z8P`#j@sfl`!ft6C2(1be8ox}3i}v2NF^FNguL_=I-(%$>%<0eFo~FW>_goj ze(d3CCQA0D=Kdo;zk2s5oEXx#<@}03gP+CA)sL`(CF?@+bRnAYe<#~Vx@w0v&DE$4 zp&ELMSmrDBayJC0rrm$=zWQ3}jnm{n;}W{~D%*lKHNe z*S_EX;kfUgS|IeF<oXZd!oJ6$u*C-)a0zPB>MTA}picl0d6bK^!DvNlR)07-3Pf>HhfQZ>V&741E zQ`KJWc)3pY{PFDGOyT+62`k`pvuRsKL|}Fbf^zR#UcO|6In)&vQGQUVE-SrpznnNq zp6O(VsZbJ<08*^*RdV6d;d+rDma(rHyp?G$nhVj;l3^eCn_v9TQ2uNo!e8K=2h}Jr zBh2vNuP_ZBG^xOoyx2o@*Q8bb&Sm-EBF*r> z%4PD7CPo%c-}UT&X|&ZpG}=XLRkMYmeGDWyvIAD5UAngpA1YwT7;@Ym;x@>GVw?P& z(yIn?T-re#CUymW}nqPTE9~- z(Y|h?U6#VSD{xgdX6aq4O&lY-I_XZUw;Uiq5de7sIz4;D*5~VLgK|*O=vTB0LBf3A zLpXOOypOHCLG!s-fW_5X!QiD#6`?bmn)!v% zgPeG#(m~r#K@t5Pa@hnziUu@#xvo&-X@m$dp3ql>b*m%ZxZ#_yo^y6q*>?$UQNj~7 zh!8cJx;qqGy&wqR132ICo((xv=OhR;5go@aG-Xa=@%lB$d|~obv{E_-mq$Dj47nlX z`hn(l?YFuuBubPXVgPuRIs%PoeiynLk3r;z~L_hJ6thZHtB^iQa}W zv1ID3cA@hdq}PXc?9Lj^++3;T6@TL0HOt}U+Q{eY`v$v5a;G3npC=e@051f6$o8Is z#bT=#z0iE<3H_U?6J?B#iAuEKpTy}4Rg#NLXd;-!w~wCMP$>5r6SYNx+4JFOjoX17>u5I2^Cb;r zhp5Ul_{lWmv`b@T>)EOd+QBH@U#)o0iweZ_#fF;XX6OgmQ3XayQV<`+)JU=mQwRAG zVuJT=R^~4mgFFls-s(qQ5-hP~lsS+p2?hm7vmDcr8RPWTrvnWwna+{!C594bDV-~> zm+UNwn}#;01_pa_mF_z#r96D~Cgtd=^`e8du7KXB>wWOTKijYcNmUio-6c|1V>l^r zo=%L2uI*bve8hTy7Nx2(HF?CJBp2F>>9++YnRpq0p%!VJ{MA6_S*mt4?*1faRRw9+r%idYPb8!S^DBghv8FUl@zj{WP#!Sj2xL zLWZ96^LDYR0WA^vlSldd+st-eNn5oKd*n=NqomcC3yaHrfAP?{u$wT&eRCMs1nRXX ziMM39_3)c*TE+=3h&BVmO{|HC!)66 zl+1Mo{OqV_F;;r{s$#mK@uX59fYQ03ug>sl)mTT2_iC3Z}MM^;mWawhS ze#J8QO4{)RYU1x6Mk%PzvWVIq&B{W!*SXOaBdFyWcArN%N9@0yY)~PHK!@xY1Jm1Y zK2P4KS=T!shu6El;Bx^ee3bI%A;g10lz=})>5=6eF6+#ODV9Z3+6(ok(o$4w^?`|# z>-K zu?Wkc@9k9mU+b#fhNWVJZvD6Ykldg$gEDBb67MpRzDQ0CflH&aHKrOGe;~{Jyiyn% z7Tezd9sOU?No$>*m~R-cR36KUPMDyS7|nTWC5)969r?r?ayjkiV!vQ?nJ3u8NC7R3 z?qE~eGt8tGP#blu>GKItAF3PU2&4eO%@nC6skIc_!ghSQB{Nf&%4Jjux~y#qewtH6 z@i7HKh=NI^rU6w2eZHj~nRYY#l!sSxK4(@KR7!hUL{uTWXrg6>I?v$CjlH*hCw(S) zx>Hn<{%r!TTj;iSlPbVIJwoIXMn=xen&wyxE5-{U1wkOmU^nsuAd$qJI^0=iieqCP zv&evrvEEX!8H<{Aq>_CgCFvf%dL|+*$~6;bP$s(2GBhv-eR@zOtx9I(!Ja)}nawzJ z2m@B0M`tzQFu3JlH*qEkhw+j^4hVp`JJ-cPdvS)*u(AG=k+F~slzW}(5I%cNmW}GN zNFhCU?|#d%Ya&0Q6{hssWa{LtW$A+OTDCOF2yh6wITw}eoR;tP(SQ-9|2?dNVT5jG zPMg2pVVEQ~ecC8>w4fMeoqKnHDI`KT)9tJ?LR>P&N?{4TERM*5L(*`0k}jaT(wQ}F zL8xFXYcJF@YM<3fkFdM!0^I}gTW8so8i+xq!of@Q(Q+aOk@TV%jzhmsTH&(;VSBD7 zVx_ZY#*~FNFkQJ($+_WX_fL_rne*@8Gmco-?zr=|pG6j$6xkI8#yx_74L<>y#kTsj z$D^8R8M#XsC3HSD7Z$21&wiWxuZVot0o6pPLQCInQd*XDQax^xn9Blamm^ivl)H<| zEIPc6#IV{6+F*2M97rTe?X5@(*C;g8dgcS7YkR}Dfnr1(W%|ZSuk)}1qT>$JK?aE=G0+L;kf-_uN}HYjXU1>H^(f31m;JoP$z zt%MR~GDcJ;8W?UKea)xzD@@O%mEZoQN_V~9?c8te&3Zugc#unj9Y>zt`5SSs77+;h^8wm^WY(dciCm<9X24etg5phERyuJNS8?evO zF%ZzXTga;YbOm=K`9z?yvdT9c5cfa?L_m@D>f!T@OG9^1M==~SkVq?aU@6kah}pqjitZ9X z!x|K;i)e0i9q6;t+Xt;Z*y5RZN7o7HeA?rAz~K%6S9tZ=hj=XrAZwFfrwAc-JC{R` z$k{uLYl;XsHTQ_hKQ;eGjH>N%>~J(kgjdVid3Vnphs!E;1mzHuyh$29eZ7RNJlxVO z!2JRcONI^5{jw=iODCm#AqNwG7NDez0RfEZr7Gm@O0_iz_yEW$;oET=M%b+hIFg$Q zMj^4OAu(0$p;|+*sgel9oE?5+ZDD{<{;42A%vW410aGM;gL1i)^DLyOxMQGs1IrNTkRy(%*eQf zw&sRXbzRjCY+pEe6MXu;(EaPaP1jtGsN!2`E^&YS5cvP2Hex2WCXNlsK-GY) zU7)IHQBf*X^Dt|YzxyBJ2-x3F*@fZ{77JSof%gvNWZUej5lunGv)1NtTJ1_mx2KmbO?F=i z%3a?vjY2PtM%pD{GzXsoR^)~Q^|@N;%~tK*RV$9gF$oa#%QKJ0{_<&bTx|>#!^=?D6r6H&W_&H5^K-ClnDDmuJI*n8O=uFaMx)>~IL>>Mb}Yt_F3xABQ1_gi{HPgr;pDX~fS3fS$r8D7Nso`0?ub25 zdeJu=lZ9?I1&1OE7KZHYmM+`jmSI}cZe8cs-rF$u9gE=RcQ7; z9(V5b3GLfDZ>6fo&7GS!F$C@iKh>y&LtcPDL9b1^#|6xQ2|;e2<6-If+>}Xy|P7Og>}lv?g0Jy_b~SKkn1^Qa+JPk#+~a| zMr;13$^q)-+^q!?sw@@ga}PRJ=*v-vAyRIH}PoX(jn4kLeD_(;zF#7NVIdEW{1VkGB< z;AB~|y9J5G$GinLf1YP>oLq)mjV+BO>G>`FuUtxZ;C1)i)ldr405i}WZh%ae4xyoaYdJON$ zJ|Cd_NX_XyVIqFNFdpR^i<(~jfv=4XV*wl! zOgkcALdux>m(`+JxnGdu{p&h@Z7&4PI?JOot&c^>x2$p#oeBNz{fWBwS~HcX$Q6oU zx{|gdx&i%}D>&rn)$GRpOqPz7Azpb*^EWOI^UW`~7~@V`%|&+xS^X;MqAj^Cr=)(2 zDwla%tg~t425A=w4cE%j)X7Q`I%-3_8i6{v>OHvL=5r?4LU~krri_Pu&5ga-tqqyF8&VzNo{Y#sd{aFW}4~DSUYoT2JxWO!lsY*DRJWXC+ zZ9gEW7M#HW2;qxZd{3a}&6)}x099;(t(4DI4+Qg4sL!1tPAv{83tZ3C>2eabIuu=s z6Kd(Zm3%bn#5^E2Kf4k~eY&=cb<1D&(_7?2W`L}?zxP0ZhR8e{}&wJt>qivWtajV=K2RbsIe*>Kk$YRwBGzC z>9mpciZ4K6ji>yFXL$KohwqoM_7_@X!z-p5@8qOjsfh$sQ{YusBPItm*+qx=fs{){ zCaUg#JjyIoo$8Txo!&|Ydr05dcQckqk6KX&1bdP82ikFO2Z~V))136R`&?0~425o? z^!jl{%YP~Ds_$3dCSKwSzBsx0JfLvm50V`RH&G!Wwhkd0>pZw|GwwZ1OPGy#pBMp& z6YYwAcA@;}h~YE1)$?t5g8J7pP*2OwD+*lc4At79)=M7qb=SE1$AGnnX=AslsXWId$-~DwniF}XlDRHBMidW*f;o9 z*htB7*O7BA_@+a$RjkqCy33eDCauwOv-m^AU_yzbzG@v~|M9RxGWVp@J~eIuJ?@uiDYBVuSp(DYDa)EFgy zX4)oHpO8{5n_m^kc7}!Q_+n-2uG$hRd9~<75v~OSsHswX#u>$gW>ZQYs*S;h*9-Od zV)b3I1PGcPqQfVP$@GNj6GcV!*d85{O*p2+@ANvjc}Z6JOm#414?8WGS3{$$>xNoX z$#m@`KW0-NGKAk}m?wHIQ+(-71I_PhvQ5Rfq$n1Ilr3XZOQC9y8nEFH|61t}I&&7` ziVlM2E*5i&rXE_6rKg<6cb-z5CE>3gXN=$T#_=euORgT{f6dKLVE;WY3=30#gfC4DEMM9_`=p5vFxI64m-CEQK9>cXVaJ$}5Yu z7g28HV*}7kja2@YJO>HCmJsj;sH4lz1SQ9%QFM&6zQB!wBnHsJ*?siop6_m&giR}D z*u{gQUN?C99n2cPg3S@IL~gYa2S()I=wU$(eeFk)8`lI???(tU|6JY?0FL3CModiL zAl8M;^(iRbp=$0{=a}^oWioU~MQ?1j65*ejHwZsdF#(4BEns{Xg?HfqG z(u+SDM1~P-%Tc02$&#T)focd-lN5Ro;@U8e1}$BlJAsK>3V z;3STo?cNW0NEbFo&yx-IH=y}-Ws2yRC_9qg0Q47=_LT{4XLjfXh=2TdgU7l*6vGr2x9vUh!6>Q4_D9e^XzX!U)&MzC-Z&Munv9baSi zp)MxI>ps-IY@I+@`H-@Q7VZd(5irCs6fcF|2+}=uT$KJ{h6luK^bY_^b`YV3<}Sz$ zPj4yhP+W^eZ(-%IBn>dRVSaCXZP#6NJzTxPU*``dFrPOh-@@S_^%qP}9qN#>MSFr@ zu*|VfV-lLmocy>8L%={6W)JQYLC&ETxIXgzjcE0gldS3@Vn9Qm^wIVjX;tQv8T*Q}$*u0{(Zg9>qI_{bKBXs0ZdN=fz z!GcTH9sPh0)?4dsNz;#`t-%9qM-&Y~o1nIdw=H53vJz&J?_rAadkLT_PIeXsz4PfA4T{h1~h~5 zpf|!EI+ZE+-!N1hcz#XD)5Z>NZJ=DUy;GZd{{gutS)(8H%}f1-NB?2=J-V&H9|gVU zENWWQS+eW;>UJvhf}Sc0WGnW zWcBbz#IE2@dqFqe>*Ny8IxI5@0Y1x{&l+2pJ{HLVU0c}V={E(^H(c6Y+ZCPPsC?e; zpH&YWo_qMmIf9JRo&9wICSE zf3ctZ#1PR^*0xCGQgZ5#tCW$`K8A`&r?Fna*q@#yE`OC4VH>&F(}M84!JNUW&7=|# ztLn%Jy2GiCPiOEz+}%%s|K12HN90JD=VnQfl9{DVfZ=O8V>O{jp%Rw4*elE{o^ zpgHm|1$AMg35;&8M^}JdR=j(G4$M>kT zJRhtz@E^6I3fX16ueTgoYk}-T+8!H(2U|4I(K5iow-}IXCL*fN*DtTf`p=& z0bL7aUb2kc7{__OQj?1BAch?JS&-b~{Jx@NXfiZG9o|d5zmG#dv>BGF-NtfLMe_CM# z7{}e=sxV-Ec6S~9=n6R=CcjXSkJQYTaZZt!6|)7)_wL;qf=0sXRJAtRLypQH>7vM- z6mLNGBWhn7GHuU+t<|CDN@<0#b4fbq9bSE!5^ALqN}?I6RaXLkzKD9pvQV6Pscexq*(d z8ucDHU_#xJ5wK}?!0n=7|3PSM>e*6TIprA~>LOHJI;m|#ja?eg!q6&`gd7F--2d~pwO!}65pmgPS{OPCij4Sb`9Fgw!HsP?BI_y|26Y`^fvT> ziB~^=NrMNuBC7A2@g9fF)i8hPv2%tfzZ!Wmca6ibOLCZ*XIg`QFknJ+ zO+P&c)nS@827}V-Cxv9i4>y58F)iPmaQ##uW$_mq7S%V3yAX5>kt!Hp(8+i|U;}*RT@@9Ys zZp->W*Ta2YX4<1G)qLvfr4W{k!*zr^bikMV2BTbG1bnj$RdT$$m>j=9m^O@-c~hwp z3oBb{-EJXT7mL^C0LfBPFjAO5Mm$ql6lTV_-B2psTw&OWCsr%}YJ5>~H%0$B>LC zqR^4DcQALUG=3_w7pl(M%0DbC(*`fhuxh=8!>#tMVl5r!g>aUSv~XrH{*eLj<^iA8 z-bt3;;)W)Dx!bmlA`-bS$ZA#bn&kOC)4#ueCtjar+<5N;5IpkdZP6AJq7XeQ$ zNnPM8mu*{vF{nH%2>=S?pKK4*V~a1(98kXI&Y{|9HMCop{?Z-hOjA|A9<;^?s(V}* z{m>w)RZNQ{+Pt3p+{$i49^9;rDc2^00j=G!)8>K;kE)^bZ71yA;&vz4;b3?fCcgN%xGIG{I-r8ENYZtRAleZS0cG zxSp`y`nWu5_z;ORug(1t46=Ez`(=t^X=iA zsKvlHHYB9A>j;prI!ITfVxQfVJsEoWfqLWqA!{O|xBdZXFxRMiCtZ9g;us*XbO$?Nai&2y-0fDN?szU-+4lYklRIvoBP(rF9#4?GwVRq`Ou~ z<7Bt7Hnj*(lX`JXf(>HrR=()gN@9Av<_J4%oY-ao5Ph%W$#R?iJ4 ztVew~Jw2N@P^4TD%Mq6gXnJGO=y+vS%M}Wq{2|q62pm{71Dy+j9#RNJzi>f&)qc06 zle=#oIF577S>YaX4AY|N(F(*FuLMT%*jO}!@q9m$PB%x~!Rh;C#m=Xegt4r0&9bib zmVR{VD?!rB5juL0HV0gCwxE1z9Z<~Q!K&Tr`hho&ELz}=(!314<_$HCG=^l1m}(zt z*kllF3~~0Mw`EiTWjCN^H$v9~u&LdpswwO2sj!xU^mvO^t?yxzmh%^v{dzuNsxX(T zjXlzuXB}<5UgP&YZ`$F%q;L3&Vo{I(O47O0(>eR(gwZ|%H_PxhGbtp`S1cQGV_wVA z4m`(*VD$W0)i%@IDal)g_5lhgD#Fbs)`tPz7iYy4=pzpUTyejblga$sZ~6*jwukY% z`c?uSmTk~de@=wBZgb)vx5;K|xgk7Ar}A>Z6Ly&S2a*Q-qx+l<_q(FI(6%;+8ZgEG z1~ck{5fER<7qNSR*H?Kpo$1}XX7|x4pd|b;Cm3za`kg&yYf%TqA6r`xB3Gy%waR%- z9$e>F^m#7< zPJ)5hI_NQBB5TMtX8@l>(g`8VJmZZxG{PZznH@TT2>y>umb@{l{$Ontc=(bjXU)of z#72p7LgFpaC&(*c>JP#mK?ih#9~eLIyVc)a%JX~^ z`2>Y2(>lF=aWF0&-LXs1LLWl zTDbK8Ui+QTd~iHsBLWejYu=7HGL)Tul^9J|l< zZ1~#xy8@Qt8qF2Ww4pt^p`yA1hW`$dzN*>+0lktPJT9L{@ZDbBf+GVriNtkeuHkQ< z!;FCpr|POIY0gVUUu{#_!ej$E-0MVV#ot~xoAyq6ZWV&L$)O&OZB5A|Unyb5jPZSk zTty5#xg{`HxYd?i<`OBXBDuX+w4UcYJ{`WJ7x8~&U|8TgH@8Ul28rG2UbYboMlq5!( zK2zW#)QXa~q1@x%TT4LF%{hd==^-y4JTYlOva`v9>_m^K8 zj&wrSR`9H|sY1S@kKiO{Ad(N#gM90$i#wc$QlCg=T?j)uDX)Ig9U4OKHsg#VB_pd2 zi%4~#BFL8?NLw(p4Qqqepxb)%U9RzLyiDI1fs=pY^={~qI@J{>|Iz*je-+7frK9X4 zEAKWUjqoA?qHN>1mQtzr+26J3-P!$8y-lLckL%)iSK_%ke^{ z#lgfk_^VvQN=K9TJAIog^_j~a?8_4CJ^_7p0JSuLrbR;4%&8Pzh4mp@=UB?rETlWc zFO@_0iFGpINK)6Wo`aN4KFW=RNp3gSOjJO*3_vX(%GmYN_5`G*GO+#bCK>vp(;VCQ z$sixzcA6^|^p*OeF2lE)!@ zf8vOnuO~B!6+VFpkve*Y$+B+Ryb~-!x+BB38gm$5j(~SOMQl1n?L#(A{Gf9J$Eazf zp@17-pScmAUXK^uHnqkbEZ}LB)j!ar6LDDw)(WX$6!#M3qhfU6dg@SpT&MR)EHP}HiqQ@dB=MXAO2Yt??C2}WkpZrl> z%alW|!Nf>C7ZPkidDxx^rl(Z|78eEFFhx3TZOs!is_)(giYR6zL4n|vkQ@wiZOl?# zswc`1M$AFPFf493WeflG=)QSp~1Ju9-5QuA=dCQj$SCfOO2vfo9jz!I{Aca@pVY1`eV6rWh>3v8CCJ6xWIPyr~4m`7X0Z>>Bz){R?;1 zGTU#7hB<63Sl<7hVI1aZQ$laFt|4ZyPeh!k;~I2YEr8CnHP*%)(Y`artRCg9 z$=joE?$U>iwzbC+t_m%ge#!oX6?U<#;f6>1Gbf-CMkU!DJDC%tte~m|ehiub?hu52&8=5_L=1MXtw=m@s?h;*g;BAwGHy_EIe%kDEf8`lkN=z;tsd(=@UA@qo(MdAF1$23c95vWe$ zR};)Gc&U2{T=N#R`;7vcMBISK`~74E`qBYSt975?|NSHt94xmo5d#5%>Hq<8{oh<) zDF06n)&DY0P3yoIs2n%uy}KuGU*(x_WZSIC0@D)xM()S#mq7jvEJ?`Jjg0@GutrK@ zGHu3jWB1!<6QF7@T`Ir3RJ3YgU8@T7i=d@d-Mf0{qe4xa;90T$d8JiLo33ap^CyRI zoirOF(A!>kE%!11C*R-fJJbH;XEspZ$L|OUD1)M|H4rEnYzn&MH+9gX&kwx63Stenw_v`DDmPGfMOnwgJ$O}K%~oK$F`~427-FUq9c7)tYkaWo z1ZfnvcLos&s;WXao})n#7#Sr4=C=dD^APaB!pQFw2yqaMN~L%&8dO4=`<4&kLWil< zqK~Dzn>Bj*^|Sjs19Z3)$+v=bdvy!N z3VvM0yvF&0gSt9rbDS9oNJ*PpMQ!eK;x(VHRfO3L)kaEzo--{nq-{O5o_#2C$oQbt zJRW?Ak>ZL~8t|WeR#5AJl7~?Ml3As;47EmmoBH~`3+11FT(A=WFX-wVy~!)Qze}l? zp-h$$N<5|do=k>3I`sI-RngG$9B$zRa4MuJi3Z`u;_`7kDE-4IuuGd?rI|2~N&F$2=!?E>&t_o1a$Rt?Z)XxhNiqbMFz65y3mz3d>ghj!##D&oZ7x>vFP#!4rEW7v{YXwIJK6!uGLh+f#M8(;Z6OD-LIu2Rl{PLsM5 zE|n!^cO+?=;t84gJNr_W*_qdJ^mC!FRrHgu#U!jUsCvs+sA5;hQAG6gb@8-EZ&h1R z{*Wd$iq5L;@vce1&x`dQAhaZD&q_0QGL3gj1K(#>bEd2W=?9|6VbFMPdc{fP70CWV zqhzlAZf#w@3WqdDUtV3#dBeS2EC2yU=hP3BD{#%=F~hK0saJVe?}lr6o=mIX*4kcc zx3YZR_)w*3tJB?4+3faFHHLc+j`>OeHasiqzkLz^94Npp`K|n=%j)sFM#R**((TWL zC?}>OAvCP(_ljCecEq&kcr&vLw(POVYkHV)N$vzvCy1NYo)DPZfG8wN7*Y%%B}VDe zo=oML3~h9;g6o+KT4!RT+{#8L0I?B(2D#&>l6g#0a87u4KMd$?Z9wY{*dt=yo3xF?D?U1F#F@h08hGMo(QG7iR`r!b_44m-6WXMqT`2CVa^{0&Ex{=uBrGV~gE{ z9X!)durg2 zE!*~=!M~LO?*?6t6(F_LhLhCzO(C%ds3#$wYKXxb%_q&| zO#XA_f_d`#q!UQn`xb=gYlT{Sn~<8pdvPeaa4*5*;1GN4Fk3V0ue*r2U&KpbgMN|s zu8~3GYD!%gH_iRJHr2PpGPv`ze=$rd1jPaw(eLfRdJ$e7#^?TV<#J5+$lVIO(cXwQ z$45FOt6^*Bk@$etilR=VnoKd^OCqq3Jw`#k(lGLt zY!o%h6LW86a!0s3yzyPJy_W)Dgn>bqO@dhP*6=D}TUlz^jct@-ux)mcl18D)hFp{a zGnCO3EWeum;yaWo9uxA)xDlEftnpl5TaC*EL~DW2e9gUGG!NW7!re0hVNK25rX+pK8u zkww|!s(h~%{nH5o48ur zu*Kl2^f|@7za>sEr&+sZngIDoRvrS#gcc@ly434q7}t=mpqrR@RDocYU=i^@fZyu! z>G9BkcXBPUTrDW{5WXgtc?MHpm3L%e=?G|VG(IAs@PpFEoXxsM4j?$sx6nRQVyf8I z>h#)OK9@i__ZnKMpl3NsNEHBGy znNs$(uGCs>bQZN*YSzNkoEgAySMQYw}bsJW&)lb;Jt0 zB1>`P)aJsKDeEd+lHJ1xvjc^bQ5{{h>kqFu(O~E}We3i?4af0%X~3B1a=|PY>DgD2 zfQ%x8SRB#Ou(uO|jK}+rS_CyVF}6)n99aMLReQJ28nTpF5PWBMnLuw)OAFAte3*Vp~Jy0nJ9M=;^Al>EyJQ)w*%uEEoR9+!$|`32SecZw?NkoKBGQgcaxm$ zrY^1D;ci(B$U@tV(2@MT?UynG8(db3V%-_td^(~dawQ6t!lPls*E_O0-B*LLGYPJRWhL%FZakU)T~x#7{s*Gg1h|S{trptR%tljb7De#1$xz;B41f zK!I$nM8XN{HxX^xUP|cHEvhJlOLz0JniH*)C)w~0TEa1ge%XVs3-9V$MkK3Qj}(i~ zkAk;cIIT89$6$`ug?tBbuNUUgtJx#-pvvS!U$KMdUtTJA4nzUzmQz=Fk5a!7AAhC? zg#5nHBnbSJH zYO(7vver}aGZo#ixuUV+-+M}xw8#IHkuxz0^i_)Zd&4+m^GMU(%Xw;dqa(tmDdV;sSB(9uSPb9upUZ~+Z+Spv(3yQL|Vv&_Z1Jc7*F-E@JN zdR~DikW`o(Vi28<(H7(xWFnGerF4nSuO^<+6BV}3;=_o7AlMy3 zi>Gwdo!CSK9Bm?QY|oepLiZLj#JfoP@Xj!on;$dHd*5>{qtd}j(Z2#c) z^5+;sQS0MCF8=$o*!_Tzm}(^{n%nv|-ggDJxhCV*0RnsF1{eN-Y{tY(L7%tlXF(t* z+=8kZx^jqsE>zj-Odl4&6_iBB+$Yub7u1p5nKkZ~+I=w$x`|!vukjlR`3U8+3&Cs6 z+^UW3A&5m6XUiPh^R*szgw>1tzkNuhy2XFD5|U4n1w&QGW89fJgX>=;hWiB2_9@Eyh7X_!aX3PNBbVKFIgLBaZR5+{1a^EEkQh?Ys#eq zGb#25vE%1ux&L_(hDuqf46#LLk2N!3f&c;@S~v)JUe8USexV&4Zdrwk>gYR`X2Aqq+{*jJb<(_0E;u z-6T8MIVtvkNf|dCZihLt^c|_RkGz%ZQ(p6a-(!K~9}wDL?Jj1MUZx&-!XI~o(qgy! zvMB1|wv!~TEO9`(Qk~Z0+ao$U2cpk@_A4&GBAMCs&Gci;I3MD;|uRZ@NEF`c%Q~ z!F+USZG47~6C`7#O&?Pn|NDGgIRMLhBg^%$gx?9?k){?l*uVqGv%D(=@`*LY7JFh2 zWN(qL(=S{?9`3udRCMA4KJ}gu)6gKJd-}Wfkf=^I3x;QuMpPE{Q_11j7ciACW-4bAmmyND$-q6f@5749pwm-) z0o*HV0pM6gN%{%nu2EpYdh^S|))vx9Z_ZJogBGfX62axUMPA*VOPs?C-xw%jp6=~= zE>)&{m#B#p54pto*cTmzf=GJ%MKpf9f;9>vaT$B4)!>f!L=Ki`*x44y)98(A1Jg!$ zLgyLFk$oHrGxdx)Kx&uv;GDzYA;+TSln{yJIO~wvsEOmf4W=2X#>RQ0cx}|g!kjq( zK{&dtxvITY$9j}kC1D;88+DORg&g7lhnX=NS0y9&2OUAtwNN2-qF!hk0QEu+V?H+V z7NV+1s{_xylGWM(91mI|&6Z0`bUnRNh`gSz5lVX0B<+Eds18w`2<2Hi6XFKajfyl5 z8L-0I$L3dUqk~Dh4|=M@vr19b2O-@=da{%to7Ng8y!(rbI@~n%Al#Ap&|z1lEZ4)@ zL7{v4NAQ4Ikg1fQlrLq8SQnx2E=}$C2TCt-RRLrID{+?1d1uxhS+|oE+}to zRxN>Vuu3u>DeWbWK<9wxBxLjtdTGQNj^C|ODa7(o6s5}P_pV7y)&UtEL%;@1vKnxo z^}7!SR5E0pm{z3~j}mjd4F?<&n5`m(L@t^CDNCio@}#+4){yTi75oBD!L#mPLdgjz zte(4aW{X}~6@ejqIOOy0L^To(=O)74M*k@+$>D4fzehw~nQPL#^EQJ}SRfz!6~i&% zH3W2Lh78#{DqqGH69Z)?HpTi($W*GGl))0dEg-$JiZw4(YHH=4%J9I0;@3BAvfz(> z_TpXQn^z#(R)1#!`b9I}F(u1;t){{Yy7Qz)qzxf_PJl{?L=nOnp`M;NtG3Q(?&}?X~XPux)(Z{f+M6b)^*a)Z}0t>g&!e z`*INQoelm{pL;AvAQ#fJB>M8Q#xwYrG*yO@Yuw#Spl{u__ZKg#JO*HDU3?EXU~rB^ z!h(ZMuZB&pjDwjpqtxFQrfmMSBLVDmz-0}bZI6P%Ixuxk%&!;#zXq?42(1hEYYpaf zkRMR^H3t?i2$>hg_zB&)PZrUCi5SXpzSGr!@(qh&^h=ftj((uSfr>tG(gAJ$a!<-8 zl)(Vh?NGM?!vFU!g2E=!o4OBXZ%q7v-Y1(*w0}Uo)Xz7xrzxDqowf&F=+&xW?vo3T z=ZlSG=^@u#k@DhRwn_%r0KB>ZnIBp`Mao+ApL=ktKLCE(0i4QwxS)1qdq6f8@Eu<_ z*sF6p;aWC#TZL=`=$SzfgCyIw{cDqY|Khr6ixTohJQ2$+FE~4Q?5!K-_J7&# z7rrRVxIsCGa)yzqVGmeTm>fS;asm(-q*23PYjg?r{}O3`Fe>MPqiH?bpdz=+6_VXh zm8eY+bJpB7I z0r+3`9|nahI6|rk3Yy{jgxfPOX4YR@dTl%Q&kOHq}S?~bmrB_DY*#3xA`s}>Iz*`fQc4q3Po=k}S_pMD=ko41b zXS;q3W@>WA0592N&ztTCANM>@?ROF7cT&5YtC?iuDO-p=$=4E`eMd!p(HBYwj4>XF zJ#zap9{KFd3gBGgv+qN2mgCen_R2gxMQfsqHQV-%KF6%QKnR{;C0$*hW&LswUU zPn5aU@R6Td`2H&7*XNOG_2}|NCU#xMN#;r8Vgv6i=xp<)eHd-5>$}Koa}?CjNLNc7g7#w^EOKd;Ihoq{Z!GrG9`N{Lue*z@Rem-`N2MXG!nvPRlObbJI4V+SSWU_yF(}#1I(0J~X(?I>>e3Ypl=tno`}gpSxAfjaOT8 z&MMzJD&5gXoL$m~=hO_=1Gm7pjz$ka(^}k_HD`Kt|3!M~eK;3OQvHSia~jHxBt%_bT@cCa@*=2!yUKT0L@t_ha+-# zD9pZXP4I3p^zEMMoxTTIcXZc2-xh@bkoKN$H^#S!!q+ds=kPbmPqh5NzI~WGcONMJ z5%qnPPrOf%-LPU%*ZhOO4zL0tv!Rhz2`VCq!9r7_$rU1hG$N>)2W8PF=ZV#dRBHsY z*CN~;>8Szn@Q!R|QJ3>%b|bWoG-lD8^K>_)_^$=(Plk0KkQ7nkj!kb=A47*4gn1)E z_waHg5jl2Fhp>wxw9L~0WM#G)*5yN+MK9iI)P81V)YGiqfa5ZT{me2P$KG?E_I?*0 zdgkW?qeXmu!U&B2LTEVbU?56h1&%QNTn!r50?-@Mi5cdpU{7u_=bM46l@ zg5dL%sMKMJ{QLnUm@6hGMT@{MW)^j*nMU!#6U-9j!BfUTU^M3OyliS$q9P1ef}k%+3EJAPXd<5JyQq%SX9yF z-V5O-j#VHF?eGpM9(WM@AeX*(ZE-elL-P>gG`V6)>E~;gOVPW?_9wv&7R8&(5c%G@ zcIkTW^hHLmh((D$`S;xMgmlKGGz|X4L=BVH_gcrac3qoSlK0fU7EA^S)XWfIb}*ck ztvNC`PTb&>BD3MoYfyeyMSC#HU+c2VdteR-z5>4mzZq`=^!MaEez}iHJ=%?h&=}U$ zkiGlYdSK}x&tlUJ8;W-O2n516E* zLc{^L+`(0O1s`SkRb_L9;Ld^AHnF=Z4fkf^<_JZ&S-&$I=rZf1U9xmb{QC7#v&&-U ztCydut2f`lXYSxVzUPMFc*sZzQ7t>t&%_z*^Uj$Pl>8~fZMF4ymUYIr@byBHp6=|j z>Yb@`%kic?{XCYrfJIE$7Lqn=+lz3$lR_f#cQQw%Z8>`-JoBqd+mkRbeUfdB_&V8I zgMYQbr_S(QXYipT?AZBmPt?}tSVgiiwKmC!rd%@n{V9>*fMx@h^+DD5=-==Di_-JQ zPS{dvU-zlPBQmPj7`l`>J|)U^z890i6hL8$-hnIut`wGC^}AZe(e&6PhqQ7Qn{rt@ zokZGIX%ELP^SJibg`~6gU&iu~LLH0OKA7ezW}UeRrD~l==4q>j z%@FIlI;`7ql##tr2du52u~F+`Als)M#fBv8k=Lyo@; zcY*P>QZA{k>q%H zQtqlnF6v|#a7jUJ@!loDS*AEV7EvW@f^aW9l598ikPSlEZ3~Z8Z2G?c+x%Lcg#LwtkS**$$ zHPs7lcFE3y8<@yfaQyL@ahkOKM1nK`VnqqLs^T!or%JLYok8ozcqyNcHW;CX<*?9K(JxJE0+NW6sXbf_PU?2Vv zZ}gxK;(p{Ym;scSa8{xg_i&juIh*L_d}CqG>E4PGq>#|sF3<<7v|+35m})wQ8~$tI zV;gO_Ocz$qX59-eZD~II;yU!06PF|om#L6Ee%EO>efg9)d+BMH73KgRjd-t=!8G&l zeQ%`&I01$xNv1lr@t2i_wk1nKh}+h4lae>WtHDb_9Kq>Cxafv5n-Pzj5z!XjV$37Y z3ugK&xd-1|7i@%-+Rn-Alfh*YQ`7{@2LK_Y{Ss9zLxlqREHSZ#d98m=NGlkWOfu@` zukUp`eglPy+HJtGCa!9dT})J|5!bIu;ea|fDd>W-H*yjUV_+9*S)8f35$ z#*ve>3-b<+`4XjGS>Ng8a0KX9orkr)3M-g5t@$IS7a#vR3>M%Lp<~G(SLCNakUk@9 zZ==2Y%#c?40>I6(2Iy1v%{krgU|GcT1A*=LV9zO7g}*t?#t`9s!<@c4{wNjQ!dq-Q zy!K<=-Gq$JMmZd~rj*!zI)%=VNH{m}I-B`QZm&wkYO^y7@L@jpIRIXA|+y- zTeV8rc}Pc*zLI6wN0F>D^nkWeLrGdBR-^1yyY4lcWCoq)8L4{MtLGZ`>&DEnZmn$h zIhs9JNidq{Qf>whCz#RMSL3jaf`x%_i< z{U7K(POf(U5DE-}cJ{W0HlE7H_D-fs|GD))xNEs;mue{LsNe8yP>xzg3N;pN8qHAY zLoh9^jg+93YmjL`X&Wo}P2pg8y)oNZX z8J20|T_^4s7eUsR1UeW1e~c?^&R#Z~jX4B1Q8DusP{p0#yJE-Dwh$ffINooHf(6S| zqNUObLZ-tk(PG4j6Kt#LAtWK{F`#=fK>@pp4pyJk(*7M-bnC;PKc1Fy8>8KMa8O$7 zB&jpZ29lpOMm##_AHkOu)LDibkBhIAu@x1lD0K|PjkjV2z)-~txPyyP-hgQ0;sa@1 z*QjBk%V>|ofyHZG%~FcCYs6?{G~8oBX}jkdR46%2Olm2FsiZ`=VtlPG%_y6{*3--K z^$Ry_*ecsnDDuP_#OM^(XWp8^zj(7~usegiv3gTiX(#8EqotngX`}gAYD=vfM;&%0 z)QBkKf5DhcZjVo5=N)vYwrp3u#|xA-QakMPBWYOkFlIiL;RSH8N;E^ku4Zf5x7KU$ zlln0y_8U{*$2^JT6;_j0vrhS%ZlXLEc9DOQ^`0P7=%%@l*EsC1xCF14vfEtDUO!06 z)e!IN)bM{An8P{T8}8hFHHKZ%WZN{2Fua#A|QX#h^eLp^qOu z*(fEu;Y8CS#8z;YTcz$a6}?-TYC)atXMk1TrEvr?IbFD~YQ}724m*RC5d7gji@u@s znn7`m|3+f;%Ey=dqw7-Ut-p`j5Z)_5vJB=$eh$~kuH<6MJKyF4+ux>9@_Jjb2W)9! z>`#bv&c6YnytcdIaJ^^rF+m?D{>s$U8Q-{!cjWHijNtyFur*40e}i&sG?cwF z7_7^?l)-G6r3hOTo>zQi81qdUb4dG;QRHm`&rwtXZZxX_VD3T57=a!hy-T6YJ#ar8 zdQEIGe=AHJ=DznEa)$I6l1z?4Q7^)9eN*84`QK14nZW8M6C^@6un+OZ+>C>vK9|B#kkQd`ORg`QHB0lXRhZc< zi$|#gIgkJ_a1b#!A#;tHtgf2NG0tKF-SAkZ*Gi+itTf$v(=le4)wrcB1Nu^kRSN50 zhwT=))pho!XgNwuz3!5vJubay1_vQ z=?$rnS?*mW+fnAL!6L0B8a?YcT^G(I{wEkr#Cv#pW|>}D#4tZV${cNyJ_}r_0+^ey zm$Qae*p}?%@<~#}BqphAu8~}$Y8d+rBgr1MaDBGp=nC{hkSSF-RjrA0J`vLGA+~UN zwCE|KO!1MLAaeu_<_Q`LU@tjoKZbyS*+a|?1kzaLx5yCY2-cBvP^pEtmS4P(Em=^) zePTdGM7(2h9V1HFA7w&hK%kPtQj6|_bde969pQ;`JC%Xf5Jg61bV4iL^jLbeth&S< z7S(b)_NEluixBNrcQpvp6m$j(Q~V~ZE*o1%0%>~Eq+z&yxgTl7 z$3+lwu!ljo=+RS41KCCI5-3g`PHt$kZ@omLQ*g+X%3iBT+qGAo~eDxT9 zZ~0=OuK$O#cMOg^+_s04nIsdtW81cE+qP{?Y}>YN+t$RkIpLdg&%IUmocrND^{?u# z>RrZR|Tv&y-y<$r&$oz|RmkpL)Cvxh2d>db?O z#*n0D%SJ3t0nu2DC^VKP9*d(_ zEEF3We@D?ecCF?^lCU9z(T|bd{z&T5K-8m?>Qa_x#&O86JV}07U`^}}dVso}A@EjP zH`@q)dw9<@k!-xmbG3UQUNt))1?Qcg3~=^v>64r>e>sVV)B`h5he>ob{HY6c6)I}{ zf`ZPK%N#z&fj0}$+jjy59!KbeljmB*;Pb4iwPv-SK-k&xjEGz5Vf*^(4H#e?91&dc z?&)hd#r6a)+eYOc7J5ZX;N4ID1t&wZWv=x;J>v6neE8zXv39)IBXZ%#e)CoX=St@f z`l@N4LN}~GyE>ddVIDs#A+{@N6;HZbUC-Go7>UG*@V45oI%pf%iMwm5V3M)SeqVn` zuZ~>rG;og*|GV}#+8flws{P^Q{4myHJ>N@q^zOU}=8%~CF<8aDDY&q}EhHZK3fzoJV}D7A3Y&|{kWMpKaonB;mv2D}^t%J- z3r*EwMXzq&CTRB@q#JX{LfotYyL75&QkuqC;h%xa+AT^XA7rMglXi#xJ8&bac@D`*fA`6wQJxmcqj%30Q2SH z@3RIIAubsZ+R^>j>0(?$S?lEQIep%5Uq+GtsC4o>m^xd3dojxCJ2)CU5DD1YS{dux z{Li=l?C^?|G-Z+gF}5#(I&MJ=9*!z^Fz=w*2_>8#3ol)sOYD8J?!;-0+RQn1K?U(i znZv~O@-wn$%&nRFN3%KA)prtmCj0Rut7$houTPfGuN!1ddT6~_zQIBGeCRPjpGoQz zPi0F@301q8KDHwpyeA51q*DWumEAaL`Eq?E!ccnXTaf{7{ZyG2?w^;fD#SNdmtr%2 ztfAJ>q_i0AHifVtf}J&&sdbKX#zgIFsLow-DHR}eH7Rbm@+w)&F&2YLp4;w%(zzAU z^E0B4PpE@6UVpT9szj?!J(;gjVnb@0dJ4=SLl>gyJj1kU*Ek+vs)A@uBBK#-)>EPF zv5z3(`g@3%{?u_8iys%y=m4$Gl(WSoY(IK|UT^yn&N4>3wNyCOPEj40;+Um}?wGG= zUKX9`Z%JAJryS8s77qGVO%oVtpeHPm>{^bq;z!`!+c}b=r(dErpB&Ao5a3*46v~*I{}SernTUuy;_E~ zz<#D)S_6Dq$&44|&Sm(f{D70e*t3YJqV^my;ZSvLJwL*xKGB$R#YcD1x_awpy~)(C z(mjKhp3yaUr%lFO)k}RV2}fTwNL=n8-mFtD&W# zgC67clQRg|Yv=^E9#$`NIM!4TH_j-BZq90Z3VdU*%48Us#6IILXdXKrEPY-(9xP*? zTMA#`%ZYK{yB~3y(M_yD+@A7)HNu@r-h`<@ifaJM;zB%KcfK>4N=N^W^m=we)M@!j$CguHcvd&t6uR1@$ zac$P_Ko3=|HhmFz{xL$&<-KNaZF9Mlai9&Qjg(dGEOAdhz`~Uuu7F-F`$Grn(g9CJ zpd+g5G z>J8Zpz00AjsN*6~;WD9URS+`9P@dWkB3Rj+*-wOjQ`#)8i&Da8nEKXWq`bgh9#Neo z65|zsujA5EhV&D$9A#BAxMg}^%c#O8K@;XG?R#B$f3HdWaryJykwWCG8r?@P46Nlx z09kNR;3hSpv8(n%9_$DUh0)8Lq@I(^%2e`zFrIal=+(Tvy<{M((BRC4cHK&!Op_&d zDQOJ5ZveZO8Z>u&uZzV-GN5S8`zGBK{2W<;-ssF`hdBzMSWf5C*}`~70)1A_G>-D@ zJ)l}UN%uu7;VkcDCj1qE3_}pC`7klQCa^%xe++Whh%%~?cTN9?@mu2#DwCDti3JQG zSDDUiXk$~+SND=>Tw|{>#0i6HQ&H0zs`d_I4YgHssWS`&`7<)}RGUY}Ls`ht|=N)xCB0mIC&RWZJLNc*$mfhxz-_%vBSIj$Q#@h~Kt)XQ?tr6M{NMMtk9 zD@ZI^=obf!Q`$fHfVG454Tshi8vqs#2=s8foZ|)n`j}zOK)b8dYh81OG}cIsTC4?w zB93Ogn~Yr3V?#V;-*A4ND9$Ig`ek0a0w%7RyI%V3E(K3ozu3Kr!VDj#%-9HD(Ag|r zQa-#m4DJSBkaUfM6IvCH5F+VPEonAly3xdf_Ail#{W9OU5biYnfy{)I6vTI{v)-^o zG~iUszQ0d2G8nHdG`Ux-*@+83%CABhe-9%@gepWgebKEl%XShKd z!=~v)2K%3^=zpwg=!?MCy;yB_oBcRRcm}kC`$mTe3+97K6y&Lt^sZ8%ZnH63?}z3m zD?Fip4w3AqBRo!^@vHz|{q+eJ?soS3+H(n8Yj$-J?Va5+)r2MjT+zKx*Cib9Liae7 zCPlSzb5PF{+(P|bxTfqXu3G1Acz;dQiZMYzv!1DlPNdK_ zhvsMz-9(aQCufh+J0TmTrMH!sc^c3-tpxTAu3*I%hSv{f8DFh?=pwn4!epE|w!G+| zs%B%gYPm}MdR`fm)s2A2mw&Qk%<_6~Co14znKXVNW!@X}By^cfBx!Hpd#}k?2Md5z7)xqMgGyxb1mQ?^HE!R#tTqd)RW2nJ)N}RAULpQ+|K%7w!pi&_dQMylQs|6sGvy2@<^ugCZTGc|ft4@1I@3yG00Gl5_R3G^L>IGb zE+jnLvC)&y7Z$C`R8+wV?y~HPmF?$6!Ewx}Q+BqvhM&H_>8+M;xgk(@-;b&ueT0-z z0nRtd6p+TOjOG4k@;_=)>YlmVi zO)(>$Zu#`W{fh-?e5l+bCxP~_C9wnUYHooUsY61@%D!F<$v6JXAjP#(xAdKbSu(ep zjr$#=>lEEJ#&t6FxHphLaKse~_lc5DL;yUpL%e0Vk+UKu(w}mc%S5A?6Mq(2 z_MpIwUx{Bzm{Q4a$X|Q;q5d<;+C16@aOu$Ji2Em6-kEu%fofoGK3RKN64QLec~W_X z+1hd%7tIVyO)r!}H3cTb+)^JT*g=#i?f#N{ChO^P4(-ou^-gOz(g{4;`?Dly`!aM3 zLSpaXivx*0GWduc_lN^4g^2P*s(ZN!Cr^9=MfOm*kObVUl80@WR~IityXePxLJvx=OkykDk=GF`;*(WXd$(WZA3{e6HhnQw*L z9QcOZwe(KnKZ^Lw*pURWAV32s;?X>c&={IqGzk1qR)6_Ym6nV-!lMRLho zO9_HpLI1d@OZZO~p+-1Z+xX^1kt7opOH$BCO6@v3T6ck}hFP*y?X>gFd}<|Rt5aae zK(EX$`qKj|e`ljW{;d>y4h(;`Pk22~3fHHo$Q#l<<|DdWbQq^`xc*~Ys$SD+!3Ndd zlY!0Nk(+YYZYaTN7rg15@Yw8RH1w6zz%4;Wb;VwAmbB5Sa~D~jXzgB-dA8Ia)_*`d z5ujKn9dWo&e$Aa$w!)KC2|6+}ab`2Q{85ghg<-}}YkQ@(k0NXLQRl8o%auJx*;jwk z*=BMY*yr;OI=RQtzTNj+yUr{E8Zk z={$T=P3E)Oddl>tx0a|jg{2He2nz{eq>L(y*Qvr#2HjY}{Oan3m1-#&bc<`e(MKA+ zTfyI^=ypMPB5a*v5w%R;$M-P|SceZ7yUyb(0`x<#voO_lh11PABd^d)R&=mny57+5 zJR2$b)!afHv5^>lP6H5pP=Dp{i`(q3O3j{nMQ6Vum|N{mJz8g`S+^rzLBd(**&>bY z=9#*6P-eaYU%!X$!e5Dxczy@b1`fIUesH`8)_o5b`rQ2-4zuy6-IV@guG5)tS545( zba%jd-gASnbxS^J=tmh=8SRVUYVY?_@Ni%s1N~|rwDoq-J!n5xb);T=49tBA-ZkhB zdW;+y%4>&^^Xl(C97g%V6}zMP4b_XD7^j(|o|iABorpNAK{f;qo-`d&G+SzRt?$P3 zVzNPcFytk8yrH+kbaG-gVw8H1K}3mMo%dH34(td9=gXnl)2~R zFDlTvTj0s|zoH&hMqiBPZ`6bJje5lYqssVSDUkoI-w^%t;eWvXKPiC!f&A9rkRRTs zS+Ay%Gzdu!;ZZKO*^N54R87dT0HQ7zcp-3b(5gn#bG3ueQ#JIRLK+!SIx^=&ez?uj zeFW&$*wxhJ+5O1a)aT>#>1VGuGC&BezS#1(b*e>+l(g-8nYrP#rd`3!M}`GkbabP$ zYwKZ1foyOt3<@%z0(lTE-qI`J7-OUCFdXK}9a=*AC1L{w+Ploje7o~Pa-!_eFqCnU z7D8vKsN?iBBUyLFdg6Yx)b>V1(T&4B&sJgr)wjnWh(@`=zO=P8S5p{~8yczPmtt&F zF*Vi~OiFev^ql%c-8=g?fog9XV>xdOiZQToj~LasbxmP-Vu(f;>7pirmaOmimwyxu z46Ru6`LUR^y#4x2p%RI$Yw_={)=6@~n{%FO+e`z!mVPJ|%HGM4UTlITbj>vK%9_D- zzw@`%wPH}Te(>Jk&uKA9HVxn^Y*}`2Q+z34!MukY^y!hYm30acy7AbZrL$^3eHv$< z8}3yn_F(#8n}M4%p%wUGQ|qwbR8<54_6*|s9he3=HD7^{Aar2g+aQsD=d9FB?IJ<_Puqt&eb0Pwf(Bm$T;#+cm; zCW`>0*2X2eEv25infQz)$xO2N3FlCxiOk&>EJfrx8o;L4{DoHVzXCXL!FQ<;4rHHq z^A!kW2$baXQvV*OD-^rNpcM<0GvSa+_u-Z(22P7(q%0;7RV{27Hp&_^HAlf#S^f%} zbbY9%yNqA1(cbD?szDn!>ee&EY+?A5WnIYdM_Yq8L=DaiTPSfEKy(wmx@CxrwO^`- zNP-hDY)!v|U?L!kL%3{`Q_NjUtnspzllt2D3lm1V;ujvGbtw4VE=L3@zV|P027n{@ zpEMKn%Na4B=<0uS3RGj|zXbn|`I`HF|Nod){I`xEWo~2qfBFIMSZSF7I^@8SBBsK3 zz$m)h>Y{*w>R|D#GKfOJYAr*bP}zF`7G@Yu6zH!VxzLnSic;jxJ64};*7TT}7FQrs zw$NF9HtNbFfrvKd4TyU)XN30z=lZ7&P{YD~b>jo-t|cPNQv#>&D8^fYR>a~1Ryam= zx8M(j*RslhJ68cvbw;Aok3;cg88BUPhVqY4{OO8$aNJBIv6aY7)9s@+*CcXL)Q8K^ z_nLelq!ZZ4wu=w{8> zgjowL7`Ya8+3I}my8h_-a_xK$KHggOh3`$vm+%wad58s+EfM^|q13RnoT~*u5NFy< zEbq%yWrd13cepfdCWM~In--XiIIsT#t_k--21l-B$`5Xk(6i4q?r2u5U~V##weB#) zoXc*n<4(fnkrK%JfjdKssOE4nv>qikxww81!KayfD0EwFM}##{c_||#7|qX^p#TF> zhL!+Gq$^bLn}kfnCWQ@ulvbtx$p|{m+%QrP zQ^GngWrdM$exYCGNCGl#!zL^YY65jIqE^=X(3`wbI5Wfr>H{d*V~UR_2v+VOncJ*d z_h^+~>F?Yj%D|u&Nn}d;MKNMwFbSnZHAn!QBkoWYCWb1DJQYcjn;-vJ!QH-)n;|l2 z$EHy(SCuO=gt!=Ou%axnvroTZ=MW-i4C{$DqyU((3+9ubWXO-U4Eb#;`Ez7Nog&!~ zvVTe;PLraGUUmhpEv^tjGk$MIfB`V=5kQ&g<1nmCUJ|s#TAC|X2bGdc zK+fI-iV^PeK(RBKGsY769Pf#3lAX$1K{Unmy%sf;%t^-Mu};q1{3evFjNJ~?mIg4_ zPNCydI<$g@^W-vhq67Lu;QJw+0GqjJP%^a+8D2HAY|?kye5V-votgDJFQzo3J1BHY zR;C-1=@}%L?W}9bcF~Y(J?@{F$qpCaDUf zN>|6=y>I0=JCq;Bu4?%osbreSrah{aZc(%jO%_Qo%x48Paw7A=vYn&CE$y@T@)FQB z5zN@kS8WyTKftY2tF>1KI_Ei+I+0l6c{n$xB&#^biljO6g|!j3 z0yL33RfpbgeEh;8!a1t)h73sL8^}7)+TY}~4_9eGapBcGE~Y{#XGkz26kUZ{*ll@A z_XJ<)*o7t%M;N0GaXBRb{4b-U$$Lk&R1xQ7!t7Abd$85;uJM~|C`->4E*(C?0x`i( z>PJ_@+>lqw_P9J8_2OdI71o;P1>m%yoQ>?lsqfVL41}hw{?Gw92R(1Mzx{mxxTbd{ zMh6>Zg&y_Ne9)S*@=@TlVY$KgHUW>!f2yrAoFKkmAfLPbz1vw+w7%#{IS6dj zjI&nh9x*1-nXVYFj6p^NL|!tvHVxLop6@mvcbc1@S?OuB`#zLs$3KI7_1(gH>{;1G z;1t)llkDC%RS3puCB59hW+-0#!)f>05OmumMXx;bHFmw-rcn9BJSCl(PN9c37qQ|tuTWW1fUr4%zFD&$3 z&-0n!tju?6ZTni3y+T}9eh_X6&5dYpE8co1KN~l5J^h7Keh248ArpV=6m%E~VV-AM z-fh_{q3{{jXGkh$5;2g`UFO2|@zbSn6CSF$IhX&{07f}yeCB1P&nU9xz|rG(CfYHF zw3Hbr&7dmlWT5R%Abd{*IY(hox7%8cKhg?6OJBDInZQm=ead)6A{{TcY!JlMAlLxo zBkSyur9lKQ`xoW;i(i|~;gV2SddeOtcK~*(gl{zSNOZe)xPwye=~}9&egqGcaU?^QQVoIv zcfo-oUySMSu&Q-)PJnaSk{%re5>}B~JY~0)Ah9w-=sx%%R1@J4q4iY=yvTk1&?Hra z$0dUZU2_?yoJsV1Sr}B*xe7~?#=ny@HFHskahC~eJ#_#$@8$c{y7Y`U4a zG7d%8P#v~x*5KT^WCe0Wa_FJJVqL81nrsY}(cBfeLCHS66z{6cXTi_=-Eq=wIAu>x z+%NSU>3c>Ap`?6|iQacH*<8p~gY)4J3iUi-89a5lDhO+$*ilcO=5?oho8k-aqlA-81^MX~0+u?+(WBY3zZPH?%CPD-#3QkI1 zcqwu!XB}BkHf5xuc??bQewM$(XRKhA!zYknOW3yDdcb!7#J3@X=U+(5obT%GWl?r4 zK$UNbFf}%EZ8m*K}@ zAn(umN7VNuC5Lm{#k)}y!HI#2MHNLuMsNci$ct^_zh=Lip+6<3Whw+gZt=L1_r)K-u&_T;xDsz0U8CVIpX~7H9gaL470iMHFG` zZ-sKhuP{QNB5emws&rjO_@7||g(5q;Zq&vz6(E#3*_z@@nbXUrvltg%Sod8iaC$y4 zv~h!bME=JfW!|q1H=w5Oa3uC7vBp<@(5ibEU1Y_6XGAJbxacmLs1LaAL}D`@uN`!P zGY;rzDTv7vp}(tyCJxo4WOV)MXnxBWIU8a_YYDZyN;{EW5S8c#mo^?t37m~6bjqmE zjcG0j&){YBIKomDf zS#Kc?tJqxD+|CC1@LcgwVXX2%WA(HHZ-L!uj$}ne5bc$b$k#^3=^!tA4K1Ry=h6OJ zw+DFG!#rwb0JJ>ky)N?NgpCww#|Mez#W1?wv#5;^vxuhNnQ?lzrW0d+*?U?p!C#f1 zV)c)(T#p3w(&0S)4z|S{HvCZl2F6`lEpFh8C~E>Fo8sq0D10rQ8k12Mf3*+ON=%7> zxd4P>Vp~MIMr**LD*|0AW1^u4E5Lo6!iVfOP=E>BN+T+c}-CpF!aY+neTs9l-bM z?otds=3kqviJ!suGKbf7rx3IMV0Hn4BD#$~Pe^$-&?$G+MBFweY-5kab({So+ecOP z8OC>R7C!yRAllF=8mGkA9XZ&kU`~tnHNuCe4tqQNRmr&f@9{OC$-abBwZ~k6M7cl*@AQmeUe!bfpD=%eWQjyJ zDJ6SiYY9I#NfAs?5t0kY!l4-xWv;koG9ox99VErl{8ox|BTx{4&5tkW@Iva3nk{&FYEJ7m?Oz+^btG0?&U-s7X$wyC8y>XPXclI1kgmx@qPDZpWMYO0|3!XPxg zCRJ>vM=440pg_4^gb*JH{rSI<1^#S2T`J#6;oh*{0|Nh1%l@y-0MY+E{*xl;`sPWI zME}8)s)Iuj0ndR$Qke1du3A_Fgy4m(_eC)!BrjKDsr2<5GOlb+>YhJ-cAcK~%sL*Z zahG&m2+R5k`-;hKr)^q+kA;l}p=PAHA7>qVXSTd6eIoS&*ysai3-Brl{K*8p zGOq7B`=%s5=Z2eA9MJRc+sitjpt(;cHE=!VO7M#FaVZ9x;+3cFDZa)S?patTRGO4r zS*bN0H#)UhrKhH8*OEMx88=5ZT$nt|>5Wbto2k~d>D_|x)y;;f+na4vj)V&N0fGcl zHTr3QUGhi>fVlnzt)3 z(PbYyKrywL>0Uv+LSBbtKCPOL-oFj6nR4pJv7OuA*8J9SUfD&JC#^}x2wg^slQCgo z+WD1a;S(j4pClN4lH!{kjgh1#og@^TKOIzJ7w=i-pH6V8PIIZz>71dPC->sbx`RX3 zQohCKF+e-dPiPKWu^3hizCXQ^_g3VsHk9e>T3ulU|WW8jJo2NuSwmgT6a?O zR8c)ft9DwC{OfYtXy%ukb=fR^->vAjMxmIS=Wk;HcfJHOrE`cG+)1huhH1I6JhaLh zyh**OpGt}KH(POZPUWDa3G8)mP9M|3hkXw;_fS;)E`yQE#p!%=Ljs}v$>uR4)l!!j3Ys{ujUVQ08{%W)r!++p}udH&Cu=RLX?#0K^8ptGgHA6pCgtI{+D0= zgd``N+Z3-9g8C?&7Gq9yF1GW##e$@mkJQr6QWQfDLlbUEDHA;f|LNbQ^Ghxi4*3W` zRlD}QiW7oQ>tn7C@~6^;je4@AAN#FKOJzOMNK_tKYw0c~#qwb(RzR>cC;*^2dy2zq zczRUbRB#9tPr`pAt>ZwQL80X9{B@G^k^dpAGwA751wEBUp|&j3wfte#NQM0TV3wdW zdS%IY*)Xv|7v5eWZ?g2ZcSVc2Vb3J$*JpQXO$mDGIFQ?GkjkUvgZ$oW*WN*iO+1tK zIp%r#2txLNmv;>R7{Av=uwJ-5p8x_4wFtLM5_LL<5Q%CoB^##KDYTu2@`d(z8k7Cn zGI~eb1lh#6mZG<|fs=SKi33*O;2CPFxXTS~su;(OHB+4LiI$KYc6_iMVEoD*!}FUM z!DQgXkPyvigl>`dkkAccl zKv(D~u%7Hnf%kl76JP7)llAAIU&R zV{ecV<$`I?N&!;s1_k5?qTg>JJUj$&vG0%FK>VKbIhM0BAdN^o)-e~PfttNPUjarb zr2u<^HB`B5P>_QD(P?o9oVZZgE1L-6Km)f`1e+bajUd5(?lW*yUhou7q76f@v+GHp z*4+TRzk+@4?C+6QL%$DgnJB#=2||4F6DG-g@fx=X9ptp&pe*gRCCI8pg-`abN4X`t zQMq>j&z+s-{ZNuF0S3Bj#$*?eCTmOHR8ZDbP&^aLWl>Y{w~m#6nOeq(VCUVE{jp}c z`VZkx#^>}RyTeplQ3UQoJ@k)Hwx$dtH)HihyB_=Ku}4rD>=Jt2VjpQaO}zK?mQlA! z&Tms+;O-6&jL=@QC&ow)26@DSYeJjEihkXK=uSLgdY!)|hEBV_WOrMKLZanUv;|No z<03JyihaQSE5G|1JQ_m&69@?ZTbmdDe<|6D-vXG8>Aw|j<$sQI6+aLdH-Q2`uhrrR zT65i~^W}u29^>`gaR1~{*|?^!sdo;t?tjIRBI3LQ4{sZBHLRmp)&Vm=%kEl*hcqXOlh}<7 z(kVp0N;IB2V*d0qKz^pESAl&fSL$DBhUAhZ^VCreNWXcYtoF)tkWRv9KX;4HHhkei zDROF4ZAxi$=(Nz!Nlu|W&##2iF1tRWl9(aoD38^}0TP@{bX{F^3Dm#TGdoOC9A((p zb|o!v;{14r^IE6!y+9W1B%}1lS9IXIB`B=|e@w4wH9{vFV{lDrbBNrc8-7gRXPiv= ztQ-6xSkpt2q~qkibZ~|@Z8w^v!+iIw9H^@zvpH*8OJR`ot6JyChA*=obHkZJE8++X zZQ6-CL%+7G`T5brdtvi;Ll)Js6uzX)(msb=;FlX-z zeo8-%oJ05cga$=>;+sW}#kTL^Jdm(fj$Dk^?y-czccks6=rR~n$lvQs`SGh&uacHg zZP&3me-i_4<^Zbpjpv}8e(vD1kLWC(w=ByWvA3eQey)KcZf2?=f`PTGyay}czE(a% z>Hhb<2Zx7zT>4bhMz3j7&5MEBrd~VH~WYFQy=W?qbU?i0C*TRA4)- zfZgB^HopWg(Rq0BIysE*h3G_?IApk@1-1Z?>3A#VJ=uLM_34a~EHVrO!Ux+>i1GMb z<^`hVUsip8paoscxC>fpkmu9G`ZXIFs5R11s-z-RNb-}UGZMDM*!e5MVxoM_jm0sb zjM7#IAQaT2>37Y zy;QV?WG3n&Ee#qNnIH6Gc!3Je3A0H{64GPE72*c|l%81ZhK2R}QG9zOC|p2rL2Ub} zOw`1UP%+0^N1Udgw=c&v*StVn!kc_x>e%c^7LL{&$4l`vAFbTE!I;%Yl z6}05|sFC^mOOungMn_s?!eD<)=)<^ua)G&Wwh{C3mD#%Sn}aV3wro4ui|uBb`fG_0 z{1xc8#g9&b%R&B?s3-RoQrS5O&yDMwQAL-t$uU-(rY{4Pr0-C%T#28mS8WBWDcDyd zG;q3M$|D4V;!(WjKaol zvbP<`ijykiOcgn~E8RQetW-X!a#2Lh{8>pQp+|vcdpK7}_Jr#4#cMw+3lBKdBe&X+ z2EFzwh?yL*WX%nOg}cYw&|*v(%9RW&)3X?ung$kPO%>s(gJ!ph#ylP{|Mg&UT-}eb zfdc_8ApECP{QnjK|JlrEsk(b1t73n3V@cJck0MqFGg+JY*|5aI$WhlaTZ`pV9rY4{ z2!=b=bBcqxm^3#uh|Oxiag{%ot7yW>aowoEvoMu4ZE2h<+g3e$=lGW4KE9c-j0IZ> zoZxocZ*^|}{m{0>dAoig1hN_A1t3E-4qG$kr~R>C4`)kHs-1Eh#;(Waz7)xfG(MFf zGAU-L-nz72e$7BJWivcW?of4{Z%6vg7Z$ESg{bD#ppS0Tz-M3DT@xNYu9QfKCs$si zFedM3DfoC9(o~>9w2v`1DmNV70p@%?PMr^2s#d8NmZ(ZT>*oIE0nL~+xkfsRZPCLu zA@x>pkD;_(qIc>KJ@$*@H=e}7wgZ5&HvY4ukj5`54_H@Qp&^)%yV_7~U{OB0Las`$ zDI7zK8h(-xUAm)PobNJ{vbNupnjb7*YkMk@2KowjrtR=Em$(~0gKuv`&`*(bNN1<>~p1=`>nye~`x~oxeu#>2) z*iR=>ez9DSJ1P?dWZg<%DsGqxvtBSif`M^Y@)#D4Xb~Y~rn1$*sUMbH7!g9Z2oQym znrNU?;*GCwO!!+X9p|$ueoL1Pp^ie6)tGh#QdK1oR=fxU7+*te8G}r?=6!zyQ|PTQHHf~V(S!f?cirRt zQMwqUAwgVF&NWZBFnP(p1idH;Ww;<2v~Dh99mm)#*h@1rEiT5&DywQC3bBrlrgN7N zpii6ScPc;W*YP7Rj+0@uH&7AaEoM{@{z>n?Rh9p%i)+PXkxIzFG#5^Ptv><)40eXfT^1^%%*RwjvY3{@cb~RG8>+68w++8HB@i&X6=JccG2HIM6H0)ol^hfayGE1 z8);Q4KN9%g+BguN0@)AX1uaP_x34#!V<4Xrj?Tg%|KVfP=HRcp@>^V zznlKUdIjhjLtVtyyIDR>D?An6+2Wm|1np@|SRbvY*&ph4v?G+r8wGnw42S8}O!^;* zW(x_yr>wt`T2)bQDnKpn3aBkRL)>#U?Kde8R`#4Bb!zr_I`tXMx%M@%L@|s0M$EV2 z_;7!W<}`i2iDhJ=OP#f4D-4yl>B^k2LR3<6rFp-W*{k{ey2YqR`Ahnr}PA)Ij zs$Be~lHywyEX}$w_6a(2uvg-}(+x+xuZ+Vu4dAbfK^x=WCFK({i%CpM^{5gLqs-BCWWhJ2WtDkBN}~PHG!y))vu{bUwKX%@X@%-(C-` z3$tVS((Z3Ns^ROe?&1#eT!ffj;i^71hYrKca4q~ikE5}TI@kizNNMN+SwIkaJ?g%s zk~>L(y6Xw-h07~Z3!{# z6iUaBheMSFq%K+IljQgIo-3qJ%^uuKL9g?I0rYvPw@^%@<&mC%p-~e)_0=EP5QCt! z9#1Y^*XS|Xz|BGWvXYhN^`|%~3J%q%X_E_V=~LOuSygsk+mDL{73#DL{ZGb^rBrAa zO9jai<0LBD2lUdN8Y}{+JB_|=(CW0DMB!5;Lzv^9aoI70W^4o_?mh*+h9H}*imd)v9>5jPDq`2N> z9JaGWsfs^lxs%eYOD74~W;YJiqfdXyYlWa*^+)5KDN?5No=WWRH)PyUH`Gaving$d9;N)c$Z^X;qFMHM zIu^XcBRRoTFtuo8jo5}rwL*3lB&u$2-`1JloCti8mDfn~FNZ(Il z*VyvrioE7Cv6(Of`y*f3n~NRs4IG=%{sWFj_|@VzHX1l0LloMR%-%2`b1xKH%z=vL z6sykA?;MY)Tzy*_0cY_q;ZE-_wk=}aO!EHVvv?|=%%PF#T&|5;eQ47e4|2jRYZ2lv z+oCyYt;^mevmud2ea2_ax(o=UeH%DfL`fvHZoPDL%Ani7OuJ+x zhbmrRuJ*OFK{?j}cn5{HMOTp*KBpueMke#Lp?6zqx{G z@6|^>YW{j+>GrX9Ew$WgzmIBupz(eSMWnQL!ikuJIqv3fP@loRxvyXU+KHS$AwQ#j z!_}(qZsh+7t_s;Y8(10t8(jSk4f(I_BMa4W3OSl^SA;rY{k{$qc`IS*@>IPW_f?to zpi3D>PRac1S6{FBmmN5|Pm=vKYp6s6KxFgSG`s2aA!kb3rY&!`7pNVQtl|lOO;BJI zp%??S%?j(THyTFz6PtD-_v|@uo z?rm~WzY1=2wh3v16#pngl<0M|Uc3Qr)nzQ5{cADN+gu_`o5Bf&FgZ>N$RBx`#L8h7 z1hX}*kU{BIbB z(3N~40W$uodm4=rGj`3EXV8Y1f4iz$95Sy>T4&wIQKT6vbclC26pVWQ(t0%6YAy7< zg+!S|s={uKpu9y06si~(YSmq&Nx-(OPh&uxEg6zKmjY^f;M*xAckxWniy5*^Dc8v{ z#0;jjBJWwM_rat^Hd}49Lkr%J2{q9MtGGWkHM7`bzc|S^hPa`gtq*E@>FNp&Ks6l{ z*@Sg8Y3&7OCN`E7=9>QKu1|SyV^^9liantN)=+ni(7vP z9=tK~J*)9g1aGm(*><6GL*&}X+nj&Bd6Kzm6RL1PK*5ASKrH_eIQ^eX_AgU^Y7kyn zht6L;OBx*-QN->CKXKUt>Z%-x*U=0lMbzT0sbY;s?)RtKO8ae3qXpWJkKJgTh=!E{D|*VVEvb-p0)wD~?v&B`#YO zg`Br>c^!Fv%EW&;4fUeQ6{ zVm%pyFP9^rETK&GOAD0qn3Y&N-+Kj;EW4Pd-5HAxoj{&U4l}l&z_L36rAw)OHE?aL zooI{#CQQ17*>muxwF0~4QDMSjVGP?8p|E-kxlGhA3~2SQEgd;stn`^WU9^&%D1@Ol zv3SbOYuig#YMxDkL@_|XY* zHhu1TaLQ8kKL>e)#F!OJJ-f0s{6bj+&ntLSfH&nS&e%I}X<`z;vLo!>ul%!)y z9Z?QtnPMM?XgueRga&Cmn7^8A>5ex{J-`vO!;h|O<PU&s2mM zi#B6verWho`atbah#*gJR+;$zTW*>Xdr*i}ugRmrZ&Rl1836)~05QZwJGa;@f4G&z zA^G86NysenBQRy97H4XMwB~7>L8{gWCY?nhj6aYJ29FFaz72Ypt^~`<)R`>M#qW4| z`V9+t;v=bq>zJpLtA995aUEcN<=DC$?`+l%6<+W`T{#i1&lb*09pUt=ujcyt^o&<) zi;XQiV5TMq_whGE1cAMT`aWLB^l#n4uNx5|CN{=HxN)~7jAlbGF6%CE!0;>4s@gK( zksBUf#PP9)M}EEp`pfPC_m7L?u2;_g)_*pkb>x|_I3Ko@iw?Q51IkgjG3V-Urfj`p zSE*m_nbTvOCaW)N$L8k}DYay6!m5DaPD^~5+b4gD3`n=fOFrV{pFR{mg^%6|1B#|_ zLzD#^NLNdE8Q%{EPGhR65599ol+yVmtKFJ|j@&?BkJU+KC#N-Wh7V=Jf2o7cK%5^= zYPF3B z9yC2Ie-nHT-TtgU+!XuNy_hrjIDW90N^vIZ)!}bRRMQvqPIO+W@c(W1-yXO|dcT0} zboQkdH3#zLQ;W^n2E<_g^x(3rZ2Cpy>KMPmOEihm(H1R5HT_E#+%}IW)Ye|1b7{j; zXJAbk(ug_nX+NRS%sh{mKww`C^q$-E<)!>@s1 z%QfE&GmV-}2_8MI5b$BK=m=NRXl4eXSloN@R3!XgvAwd8 zKs9n_lNx7X%B&4K>sB(4CxJDz>|ESwOW>*VHGpN;rzGHwXuX3OXbvpiy2;@ zHVXXiO*G(hi9vts{K3foUhbJy9=s+pZdp(Hwb}QVfEs-MZEbDbzcGHh;dJgAiWYNM z%INnLURx=5X~v&;ye34HE8NGkG`FKBt_4qMZM`FBFXU9i>;O!E3NG&>&q(c zOUz{kVgnt`8F`guOiVgw&V;>ootHU1<+w=edG)FC*XCswlY%2|<6!4^nLAevdBJX@ zoBI*(Vw*YF5tzUJ2Eq?7EZl&E=9N>_lmpT}Varht&_}Ikdk9XW+_a!{Y$=l_%MPZ! zRjaOMoV5v&TcCB?-HzXQkbM9ffq2h6ywi$SvA6-EUDa;~5yeC38 z7+ci|%Ew!8G;+4P9jYkRW4!ByZ9H!x4ntPcU1DlIt=X&COQ=WJXF(H(;IfPm;jAVY zZ>T!NUM?}5gIYX)|CG@%sD##5*0wd)j>+SkH8GTh*3pnm%WIpxknkU0-pQNZJLi$I zqCMpTq*diT{7d?p#}&p$`P*gnKMa(S_oD;m?L!10N}Nj?uN26x8n2R5s0{|rMQC1XnWDW1Rn6Xk#j=; zugKo*c)C$(Z8E#1uNIJR+;+a41o@Vwr$(CZ5!3bOxrf5ZQHKKv~AnArf;vi&c3nMx^d3l zaUv?diu$VJ`>*oNeDcX(isxatfgQ8>bahkAeg{M`e!i=4Av#m+!N}M6f)pFGSA~G) zyV<{7yOiEq<(!rK1h|i4r;Y64G0Xp6&*m%M_$6+kAE{A&Gm2l+iQU3!23o9Q-ubvO z`3#WPDw)O+*M*&CTC50?Df$am+HHj8|2kwOTO|-!-i@jls`R^Na`!Zg-!+@tV4*y} zqz;7Be-Kz&QS%etl?19|PiCQFzM=$Ke(z&52%XtT%wD*{tIA7n#H!I7h{wDh|3kk* zzsBuFVNcwlRVSolV<+186mnkQ)n)|RmS6}0du1o){8Yt3#l1qQ=@Sehv`!=B(Ht}0 zx~!$cW|lc_5{ASz$16AD$#0nPG9kXh!}O1l7vATk;i13H_EA-m5jb^iLe)$_u;$=r zwaMXH`WhQ!ErNSKY^df*$uh!}32Mm9*wj+hGk){l+c8jKnOKOi3`x49nY5P zt*xN9zMHWJyFWkh!-uvFTSoIoT{2=(3?iPjqJR&|lf460-|%FezrIM5s=|&CLAGe` zPu#yK4@#dJ+h;WK{+o#&@1*>|J9DSImGDEpvk4cY1zKAm6|X_S#ma)d3g;~kE?eDX zFFqKO#i#T(HGPeLFrB0^MQTT!vEOWM9paNwPs3IEC4c?f;VL|U#M_i(>GuZ@-;dUk z&@-Z>Nd_&sNDY-LOzP0B`W#m!_}-AAQ6)b@mc4wIb+RAd=X4 z`#QBx0pp0c_NX5d|K`Z0Q}^xZ=pt|^D?9rBn?w)HxI63P0jrxgyJM^+E?8G&2nYhp zB~9Z>BwOYMMZG>lo0~rH$COc_(Pllq63uzqerZ89>pDCu?(Iyc8Oigklc*U%fyD$h zdxe}Y8*ji;c2ItH+^CXl1wmg~BCR8s0Z+_}g=6&<+~%(3-;mb{WS>k5UvIFV0MMVW zhfcJXH%k>;IlCS}+fS&!&R_!P7k4rbSKboW3baSh4)%tVo;|VKPMy&xp*^l?Lp_M~ z@c6sF0_RbaLj{^4!<@qS!*ZPcn>S!NViHcj3ignBfn5qNQ%o`OQ@f#nR9 zPta5q20NoEjcXLU1B1H0Gp-+<@@tknHakG8_V8EUAqJ6^4qac&iEp@CtahhaF(8}S zpE5%vFPsOSqg-W9=|1S6`F}$@G#-vRWr|q|F9iZVck__n2|k^-=4b34KHJFFbNXwb zO2VGQ3P4)bFZT$zZY6tuv-l>?{H7q!<{3 zE2!@jQezeQx5C?U;}#s+ziNz`y4gohY@CCZrV$i;;4ca@(>?NaN;gq^K=-56INd?? zoKi5N6(?l4sOWbbqE|(OLuDH)C`TipmAZA%$aK+)OoWqKBJm{=I}c-I#UIXu5CLINGbI+-7C zgj8$gywKdDY}WllfY<0{8?p9XE`=}ifSCy<<G z6c{qU85(+q@Z&(V3^Xm%@&n77+veua<*)U8Sw_Y#;4F0DR^%qCSp!{fZ0^Ol=kF&2$v8_wbyhjnTmSKiy2 zZIG=)rq$+z)0B>LQ*O$trwfuZJ|ZZxX6qPZ!;*=J?`rpy*UxqY+I_6aPo+UxUPI0e z{bc-{5avnaxJ%!=GmzjxWbIg#CuEFvE4-|%jBduHXRya~Qmo5W!71($9;c}8!zBN% zLW(`~knkLvDg)863N*8*K6<(iqktBd4Vg;98>>U zq5C(4moAZny@8da58jV-tilKUD%jRoF}TnFm|Rm~;%}7y@^JkA@>B`_&!n<{l7jw! zG=cxsI#>TU=tnbgU_VtzDYNIVOdG$ptC{zi@!Y%&zs1PV{zH9GfEx4C<+Tfv(-vaBX=26_if$6f% z%ZfD#Bn~V&f(biXLhVh8;ef$MF)f}W`HW$T;=pdDbB!erK( z1u(ioqNXS$X4TL*-H2IMIiFvM*zM3~=ghkNa6z|@mDa~%656m2sXizloOXm}#vm40 z&R6CZ3vMCJpP6O7{)+Nk`}&Z4v{52RQSu9O5!0Q23CFAe~w0`@{H0a8<7 z`cai%!&z5rm<$Acrld()UJrEr56Ybck==+LTU%S1Gh{95dd&5rS)YE!c4Cj<&cS!c zh)tI^U2YY{ZWgV@s7Q_GQF15r0tkM#c%c-d+-L#o;EYW!e7xn5wDWd-5%;vwx6Kvy@TUw<3SET78jFbM|0EcA)l%es(m3m~iW%Tma;8fC^Aqng%VYx%B?Ea!7FEPfV zMq0Mr8HndRH_v8`D}Tj>gVM9TJ7ZrIcctl=BW!zCT#oNs$jfjTjUP)6;21nL?uYr| zkk27F;pF~1+TS1CUTz23ZxSQH@KFZuW>L99sskz&S)S1C6ljC(rJij%_v1*_lFTkE zUA7kA+G}O(R#bCNc1IX$cs-rk>{ID49TFP+*-b?6&X*v0Jg?NW<{Nzt4GYRBBga0i zp_-^gdT23h@eT$HCMT_QGDf(l)N+0Fy(#BYD#1RU{fnzT`BP-p091z!Rr%duezE}t zrn!}8szx4O&g|ma=r;SrgfH08%ZUxy9~NK+8s3m&B>g`cG6nsTSfX!yOM7F+do*S@%c$S zMi+nHIu;!5TFB*#qhfhC+1JYH9EY`v`tnoQFxfO_C^W(BI%>m(zY8$w`qtdT$F;}| zi&z;NGlrZ59EbjOo&Mq5cibl0!E;~YKjY;^JNP)Ae(D>j(#!wq7n?hFFFOkpmEegH zd(Xkk{)Ix#d(f!d~YGAXozq*EkWmnjw>7@#cMzPpG(WXm1~Jw-y?fNmrgczI}@&rJ8k0kiO4{Ab>gg`|aOz1=-AG zjI*y;rTaB$``^SW7r>X-jNu zn~g~>xK$ro#lD1>QsPgK5kyA7k;~{=3If zBsKhRN1QQRD|)l6(ak2@D||;CPku){N9*310)PJ=aDWV74}uVfS%;k{B5TC8J5*v1 zh8Qtm*3fTo*klJhN15qtT7l2l+B1cNCE!2+;Dj|C%MV#2#pgWEmj_dfmVTR8U2M2$ z7oVZ)O`WJ#R?4otVC1yVOD2{!9azVx|p-}a}H zZqW%42$cYEF#utfO^FFnXs)>N@g9uC(`($DeN3kmtV=U23|^ZHPM}vxvKy(6z%tIq zTdgq{^nv<=bu#!8wDT23?FVfPipDDO4O{qa(tPM~>gA3$ij`En^kk~5vu6;C(m$Gd zY!~eTz>PszESWYsMXW+nG#6ndbw+u)Pi`jvJK8x_^5a)qP)x0yvT^ZYXuW(mf_yzA zv0~;Q+%=MM6)2{Edjfp^*u^KlsNr!qCX!~Ojw{vjbhw&H0qJ6%YY)&W%*U8<7EORx z9-|K%VhsoN9vnyM1;WuHE-@2$4&IUC1$O07{4kl!NAqpAt;Q>d$!;zqwi{4kv*;z_ z_<^>w!yr+fG#ETwDlebI6@`Z9+%%?a6Q14^&U7d_b#}vdC9No}JFKZ$kb|(rcFnE% zlVpMRPlu_$8~-e}GPpCM0y>`PGEtssi`P~Pm>&Jdc8hVj#FcTwn-7+6YB@I}R*0$4 zm+)z^DvVyk(SdrE{5_5_rsHiJgr3gMdyCWL1i|$X6dUwPOgA1Ds=wVT%(Gj9 zT$T=RWg~HAuj9mW4UwJBffB89ZwNmy`i9?cL+&niu74wJk?dr0AZ!73ffb2=Defcg z(YhDjT_^=!=J*d-E&~gmqZIhkeswTD>%&Oc)GO>moFa({=a)?T9TTm#&?$8@%qTL< zJU5GmzM&L(g(KL?016pqs`c~7n?*ZOTF~C8#B=VE@YYjG5m-pMgDF2bQv~kev_G4p zQBX+@QEa9yw#Gec1dgWVxkBF@>7R~dFOpa`LS2dkj;en<$K3BUI`#muq1E??vyA{H zS*NfI8u(!>Tms6B; zaSMA7tM`}n0WkxV@kEBz`))<*HE|Vya>~B8}4hr!7 zI8!-tL?Mx}DRH0>t9*aHBetWW$c?x>YHnS*YGHEbIgtfIOskv~HFM(afz!{8S4}%z z(yTk{`Sh#Y@&H9jJ?z9lUDP=8RsFA zMEpu|G$lAnMKw5MNyEBf$Gu)svKS~hTP*uY0JVC>UGEX0 zg0pW^=r$$eVHQ(vgS{LVTj_>bqF+Bq&xBfjr%_O#O?`FcPKh$j(21NX>CcP-f7{SU zATIWHhlXlrgq5%h_l$5juDdE@zpuF}X{*BW4Ji% zK_6y_6QERd&>^ZQpUIIFXX~&n2cD`%OH2~yNmLj4x|m`FOIdwzW={qas{0^YzZ6Cf z`+LWVwoG#fT}r93TwIPYo07LjsBD}c6R~?Ja0_Mm6nYchr0IzCQBFrle%og%!&A#p z<9HfIPH|ha&lZa#^P%2oYi5k?7MDR2y{ox+ja=YuGOojl8&rmYou!8SBAS0#XYE`G zjTmQ`u^iMmnR&6xRhzTXdb5K@Wp><8l9v+@LJ}T-u2s_k(O;NCL1#TLLg?$^!~Ns* zuQ?P?H=K+_Ax>fD=n<<}n*-YV(`3I}&(9J-3(jZ5F=Q!PRr3k@I(iz{wxlNCwrsA0 z&Ws~R)v4Xa;#r8zGV7b<$hzgR{akU&_RU|WE{mnF32C3`zXfz>R6XO`NtMNVO!jF$ zlvy6~o1WPS#mAIzf`-BTPcT}qqjL}3d{(C5E8X+#1F-#6q7^ucO7>rL%VBtoFMYO!q2TFQ8%D?3;IG-9&bp!L5|VetoLe zM;4-yeFx$5lkQmAnQ3xzg*qNx_iBnhpHEi_q{~9g3M;I$Z=ffgf<3CU`v!#FG6|$& z>7k4PPf7{0PV0SoBWS}oGtfAE(cY~JZhF;f+X`@0&w}ql+!EIy1g}Y3uSrr;ToXy7 z^ap?F%^s=H5bs-jb5qL(i2cn=B~t`bEj7%fIxFyUP3)!%g919&z<>8ifPK;)4})>z zbcko3qDJVZ3P^~cux4cVMo?B=E_4mvH6K_GH%Glj-cvP6f;1McQ|=H~KSMJlz&oa) zQa7S)+=_AEK0v24faoDm77*J#5d3?N_g+D^T=+GX`S@ku{%`c4e}*PSXEReP6M&VQ znX-|+se_$}D?t2fCx;8*U*l7ehRr{xGgoR!_578~qUrRt8H7NU1m|;0MHQ(qZQ?K( zs${Sn*;eG-IaDt`{Cc)NgFouM_8W&C_)0$Wd-AuuUMIg zlNX!McZA@xsQLE&DRh3!H*y|}(Q3B7TNZO?jc*uUpOIqAJTQlgH(Q>f0Q=VV_P2#J=qhC+u z@ld+$)u#O^uua#}STQ^@{3URL?pO;u~~>j=9i`KYu8fADLzadVkmed9P|50`q9CSRj*tDPHSxFvUE%Jg1;~bvFs}*wWnk92 zcVeRUb_(qwSMSg_2BY8i;4`aUPF)Z`fr(xh{L09X#y{vd&10Qh>c-F-ASTq5P;K5? z`xXKU&rxQtd3Gi)LAD5ZC;`{8z(QCm>PgB1%j<|}fYC>fSgV35bm&WoM}2T<%O+7$ zJDea0$GTjM|J;x16CNYl~NV+v(~x7n7)mfk_q? zt)iTnJhyiovf?x``)IoY-C2$Gwb&ZfdCoWwN{mC9A1mpc+ZoR`3LRTGQVcNN!Bn1C zSSK@O^Z{QBZYE6rQ{Wm<@H%O}eKhwN8w z38X{F4&w;^e&0H=b+mmVv5cI0Vz>1DJe*?==nr`u4yiHCfoYEb33P5f;fJ`aO0Nq< zFv?ZN9N=%9Ysy2_I~L-AD6{=tcuF$fA#E@c6yUjgqStwp!5jT_^Nk3cDFpEbnY5qN zKlTQiYfft6O2tPU;a(B?FRnpcu~56LUwF;5?6Tc6&epUn`EvL6r2r{jE`LJ+#_jDN z)4q!`{EshOxSbJkT6{%jYW%fixo9`nO@`yg{FVgz~$Jl)9Iic`bB zz66iX13`B~(NDx7M(wf-iaUruoxnzfz~vZ{MrnyU$l)(i8mm{peuz_yZmZ-68MmLo zFqA#f4ulQiRj+m}^E1X)y!XVm{jyJx&jfr*j`{Gqp_E;T)Y8-a1w5uuJ^>Q<^am7O z2X5MvbbWCHrHwGSoWQie@%Ow)fp7Bhn)tOjUOCUSAXHNNcm1#!JK$-NN5I(2pq%T5 zwa?rCI0t;}*WvrV2&c?=|E+oUpXUI;+02YV#KF$d$OQ2Jodl|1H~+QZ|8n%9X<&cq z*C%e+>Pyzg1%c50fU1?kz<>!L#UnRJmE?2>T~mL^QrslN4D4BJP8m( zyowv)Z-I)s1kryAZjJOJNzLf;Vuw@?bF)?9Sd7!^%S2z33-eAh2zUuS^TGl#ec(o) zMj-e9gjK?ec|klGX8bCLQ?$%1X z9||KRaMU8dC%0{?w?u|`I6HBr#QP*V1q_x|I+0n4um@0=kGC z9zutVAU9SHbmb*T2ntvwkO`vFFk%IZ*ysp>je)mp-VufGnBVEDMS5IKXaU1(+6<`4wZd0 zDl~TEyB0?e)XsDva8Llx=AQU~rxQa$+*bci)irx3wWxhM{z10l$ELbK=l9K%VE38q z&EMTKNo6sj+6huGasEpvt848o$;KyM9*Ch&{cqE>MAhCUn`I`YrNu&)O6`pzjsd`n z>u+!yLbto}T_P-V82y>}&q$$(fN*{v4WM1C#p*z))HAX(H~r#Ha0iwu&&JmlE(w%- zm`kt`NOu-Z=M?~Med3Kd^Iy(Tg9GJIr?6p(Hj*y}ngq8~Hy?9a0qGvoYHemEZqg_j2J&|@ z$kRL7j$1h>=p|La8%*Ct8vLaVmgr8dbm(7*!$3#Ja?#vl9br~Ad_}-F7ZFa&WQe-y zxyEUCtGve*j8}gCAV){-i)Goo_wNW({fJE6avK=|OTtQgEQdbVCQfb^{(W*EXpyRX zc85nnZsV{$14+ans(dIa3CGkAwY1LmZA6+pce-oHgCxy6R&rV_ZwK*7#QqPVJ0yiq zAtTi)3rUyr$G;NRt^C2bA2^Nwgo!e&p?HlOp&QuCT!OKByUa(`&8E)Ec7`Eb{IV_$ z+swD(-T<{vQYV;^fV}^RuRxcR+Km!rLBFX4XrDZqGLd;l1Ee0dZO&WFSCY}TGMTFU zc1>7HdB>I6`JM-mieN5uN6Gk!P0Sdn$(R@)0VN+ z`)p^fO0Pscyfg77N6}nR)43g(?RlZ&5xm*Ps73cNrPX^8EcBd01da0a^O5tY^?F z-Vf#g+!ZN!MB$MS>{?=?afk$6|5`e?Z{?g9Alg}|rNii@v(RYc(DO$ytXt5knW#`5 z0LPrFI7J}{%NJjSTr)0z2))?FZQj+} zEWu~+o%(|0C(d8$`(QY(U4Pjo@q(Q}quW5MTtF9&z?gMooBKQ)L%x29zF%KZ?2RWF z&E7l zOqfTJT4w<-2G2QbT?`|b4f1GzPSQR@kC@YfpHmq%WP5LYn93Y^m>Ak~x`+(MYsXe{ zLS2T8f9UvI_9LYxvQX#F@MWrAjK%egPt2F?jp`|CKks%mx0W3_B(vwD zW54&{750C4FJMM*X>E%BE8kt&Owi-0G^I_j*Q$BPuy zIeUwT@!<$1gONiffl-)ktK|`CMfI?pr;R9*gFdvjkYr-AojrR~mpE!;GfrQ0Rd3&^ zK3oRJo06zhXMOD<2H|8(-sh=d`hK5C+9B+f)U&+LlF9o|*`Pqtmeb1ml^bHST`^<7 zx|@?5XXcG{G(esmbi`pQhr_L{zUn5gY`l|5#_6bH7dEvUsLugki6C_rMuzOYdDD6U9i5xEoY;p6*0ONH<^bm{(~)@u9XjFLG< z9Y_5Oi+2Pxtq5j^G0W5?K4F_)vd&Y|a|e4l!X+XPizk9xJvhk#Y_y=4!3c$ZK(McK zY?*AJF&%WM+UYvAw(b#X(qyfeE3XqFf_5=cUE!ub7p2q6ACvaS6d>O2a4bPii@V73 z9IC;BB_|zo26?|577_TnW2#r;J3(fU3P6ApjNfbqFVRt9+b{Bx_;n6rL*_mZ11~dZ zq}PJZ3v&ih=l(|O*=L(6a+3wx0E*XY2D+ZhVMZkJ`k+^X&P%|^ZzG>AzsG83&-BhN zlAU6Q$Nx%;RUF9DxImzFjvVj-J)3yVVzJgqSh3c%y?9Y?ozZvL^B*#9o51um*_T$b z3W}=Hziyfx&51&k%uO)zP`V@TRO0#ABBC zWZM+qQQOgGX3yV`ueR76r8trxBb-!lRGc6{;%BK1_{Qq2y~8CImjofJjdn2$1Cnf1 z&6ALj0Twd)hax!r!Fhf$=EHDvQL-zI1E!1GQMVpDb&R?bV0b#K-pDf2t*-DWi;cs$ z?sT1KFW-)9h@>A8^Fc!Eu8xS18Sba#Afd_A!9>21n(UhKnGw2(hX|o^z1-LccVkFz zdQtXS;yiOj^fT)oTm@F2k}yMZbk@i*XkwS4bF=H#`mFld&IwfBfGX~A>92&js){Yu zhk#y3W;(WCxvAm34w2ZPs7WZ@d%7Is%Ki zoQZcGEpBpcv&Og;=L%?mqUL09Y?m+~eXma2yN0Fa9}tr^U`U3eQQ%pWZ4S-GzhZoW zEygCEKxzOr2_OH2q|Rm6pxBL`%v7lr)L}&4I^*WZ8;fdQPLc4P^X@fp-T6@^alKS_ zF^tVzuuE=_93FEqfx?+^JRq%dWX_+t>`DWi zDIVXGp%%jn*;s{5x|#J2(t$eN^up#=0G3tukCD{{=gC&{+X0W*VlH6tHrR&dH?KO< z*V!=pVfX7E z3ovpn$+Nv|!=f`>!aofswb`iXe2?9~p+#^*>_fWvs&}tGKGkl5aE|Fx3wx-yeuu4V zZCIR5G@91uYv^V7QO;=__zlfDTLR-lm#ls_>tcFXslN zKU`>7#4d)b(|y|FD`mrN0dWEgoFhLIlccIe=Ne|$HCekRSRLk@o`zR%K>=!VeSq*zK^He&;`GMM zIDI1Ua7okv&H$tuN=pY^Lm=aRdmcVXQwC*aH1zArE=wIyMUwtKqQO09?<4O7hu@Id zIt@GH?w<3E!1J{XhaO(<=AQIyNwQ`~{H4t0y#GexfcRfAW)hlD!bcpy9-W;LH4}A7t{d*11HJ>SxRa9?jxVi4vMQX&gS_U-~tmDtqOqi5!OuZ^lKVH*LbA@)~D zP^7CcNdMw)eD*kH<cCbjfTqCe}0f z_nh@HT{&T%e`#wSRU5qTBg{41tu()8UC@|lm1{MPp;KT0dR_6AR?Dlc7nv&-ry;*v zq!bNJ@~djfw6#^*WKh!8J7c~ZkjG_3;)dROh1(!w#;NO-g7%44SLJSV`>$DG8ZOnE zX;AfdnM>UzJyn>PXtPD2LtS@QCTLD%<||eP7F*qgvY|K_u0^m3!J1SJ$Z1m420fFz zE;O6G$+p&V_$8@`vDnfP&4fIdPO!h7*k~E*URo~q{*$2?0S-lXb~v9kzQp?Rto+0IfiVFL@No`9ce^7r~(v3Sc2H{@tw#wLkkn$t}tpb{u9v z`C?}-;W;1EOx&ZF{Vr`#NII{o4pm=?pz%V%g@NbL$xe60%zR)|uH4DgpyAjZNWX7ALkQ~(Hz4?hB>CNBDmoe9j&paxJU8BC`29eA zifY%#CH{vDdKiP1#p}g;6LLNgI(;Q49U@`h_YV|;-o5$dCvG!tQ_&cv_qYL6 z{N<=T73@RZ;=eyt8GmKBv8Y0C~c#arD*zH zNuu|+*e)vp7t5WZ;tvWI7rtEPs9tdTkt=9b3;Kbw{szi^Y+?25@BURR)*}nMF7x#i z9N)YcWyC0JOBFa+d+HxuZeeIISR$?4a@Ae`8jwI-h;TP;uI#QGX0iYPntnH~6FhFx z+q(mFgl{c7dlWy+}$9lh`5Z6DcQ^Gf?(L?C8eu zP5IzR&-@#oB8{_6IoSe*KdH3ARhDuBJ^b}Cwk7UuZ^A0BO}wOYz6taxSfQ^^`5HMP zeG?6LYu^ZVMp4qA6eQQ9^?_c3vV|r~4c4U&WM`Rktn!An?s(o_VFC<66Ok`@Lc7Kg4lG=j>qy{pk5X=^^HEO4y7UhHe0x2M#s8)aMcRO@1iw{$PWCb8k03nPJzrq}Gy5i64vhw>V`jiIl7YdHVKl znd$VP#LJWvX3qY@9DVlA0}DO>)ETC(PyA9%E9cl5hi;aPet#pT%*l!zvKg`nV#-{! z6Pp`qzdOn%SN-iUxu*HD6E(nX4E3gPe} z3*O_r?q9T;&n!k*ggl{qL4o)oNfnrNGD2xgy6a+Xub8`sqFdtJS~j-6Z{R_Rm`FN% zy9W)aX73NK*aQ_TpEMsB5Wfb~p5u2zuUj&9CmL@LB{b{yb~`2j;-`R+pb~~(J>H<7 zy#iei2G1N-O2${YWoH6m@BcxcEb8How)`^8oMV0MUi%+cTSCSz0OzlkguH|4S101D zG5xRS{GbN3kLs80Md8t-BQq83J1ngv_*q?V0;ORbJh2pEEfy3KoG|a}a%Wuf#2Vm< zF2J2_c2(Q1%8qZa$_g8zNH65Q_Q`3`}UDK1<)gpDojli8g`!`|1^O_GK^CMrD zy9xLA9+PaZix~=#aSW(DFnoA@JpN~JaXo8W=JL6aro48~k0SF~jO!^GEh_KsUliO; zY(XFaIKtf0d9Yc@;{dfEdP4`;72GzO7wOfeo0oq~C$jW@H^8s$|EW1WNOx-xo75&% z$^`^kabRtTGn+XmMWG5M6$k?<@hEbYx3dbEn;VL?^ji2WETRkb_It*UFWDPFH@})D zE8s6NLYhELygkw_7Kv>hUWr@nY=x4dU1U5|Mx|U~>6{kK;&xe@)KSfKl;5iQk(Kc6=(HYCSx!pZA@l%xjDBO z6HQD;D_s79jdbyvNBW-aF_gs^Mb>Pyo-TPtKVnoLZdXsZjnjn6aK8|C8uo>;G1ZIM0SMJuMFrvkf_u_3r_L;*nAue( z^t%AZ#dTA27js`}eY7a@)n^8x8y1K=K-TwNDV-dJqP3!y$#8i)FJKH;(I%JvkABd3 ztJ0;JU%{X*v6-mwnY01Ueylg_E{D{+O0y{3DZ9KGq2Wn?54D9>$W_Oypor&uDYalIJN}RU82Klf&*`8g=wiVkcv$x^Z4P4=A1*m z_ocTjD7#67AP}V%>I>KjLej%U%aRiNvTJ&nP!gFCs5X(m%_Nt&M;c{9gW}6YpFT5(5(2n`PT1tsIf#T@6lvQkYH1 zri3L`PmooavZxP9 zlSKo5FLd*oEU~4FhsstG%-$HS%R`%Lt3fxVrCiNBVe;=D&$D8k@nfHjx)97cwX75B z@vzp#^bNb-kAmDD>MHSOIvPoFZE8*_`0_vJ1qpB(K>Klij?o^eQW&cQ9rCU&L6Vuyx z#Q55R5!3Y?!0JK%n;%W-(u(RR72ut3uw@xntqt0Z8t5qk-OxU8Iv2iapHRBfdCdebGB)fHqDLce1Sp-E* zBtb6^`2;049mr(vh%5pnvx8oVZQY^i`H@aTDeFvNWR*p)6+AkCE`6@-!L4LQ9c>-xYB%n$31e(^rG|3-z zNb|9hSLaK!n)R;^?228wrX=SrSuJM;xpB`m@i)|Y)mf!$8RvB^ma$Flv;hlyfmAY~ zQm#I=bSxK>vOF>ZGPKd7Yw|zn3lQAdmlIYEvEB6fogWcN`kjWJJNJ4=KN;X<1VXf# zUuWu;WJJc3A-$>0aPvTKilA&q_FvfX*>hpEY}SqfJE4!1R;{KhZc2+orhY{L#W z6&XDNJ>6E`glwr*3EMg|1MgMnG<@YR-CSzOQ9+oo=! zkzo6WT)w67Z!nx1&UA)KqBMq5q>6$n0|tUFq?*0un%}Cx8_^wa=oX95!W%)JC>ymRO>^%|edsl3m59<@t3(g&h6qGfV5S$-< z-vw6=-QJM|@(=dUzt{~|_j~I6@6=XJg!Vf&76eq>_PZ=L1gx7rBo3>?)ukvpH!8ei zdIEbVBS;^e8NdD7LVks?>F4bnegDi+S+q!Yt-SIKT9=4N^M&(o@~-v6hEL8Ss5qz0 zWnnM-1sK;6Y{5NWw%1kfRRiw~^!D1-kKq_73T~km=wzvu^WsHAl#K)<-VBSi*iu1_ z3bq>8iSVn8Ymn-i?p)h`m=7}EQT)~tBI3Ti=@=I(vrt-57gZpje_2)rr4ba^buVMp z8j$sUC!)NZIQB41zWj>O8oSIdbQPECD72V=QgcOxsQKY4N=(`W28V!lJ4REK!Ow?P zTPtt}mJWS+O`DGh+L~&N^~_I8ELN zJt|?gWLx^#@XBMMlph3GI|Idw=+J{)~9rpxcixs<&TIj$)}yU`xI8iHgSfM7|t`NBirVH z-Q_%reL1x)!!#K!M7=MChiM|Q@Oi_eUBkn*9?k*0{2$@^QHSPI4mNJ;`paqqzVJ+7 z{1bewQO369mo3|*#4l*NgPk61W<3~pt?E;$4yayX!Dr!5RWKzoVJHF`IT~7a$U|)` zFg4epY>Aqv*5q~S`Q`{He3}yLYAE5%bHT`*AU#NXUCEmt*If}P+77g_w`J$6<}9&) zc4t*&g)~Z)cQ3?Jn!Vt77DKwcuMO7;ZpMQ7WkaI_%tJLbwD70w4QLAx~z2K%+u zjR5@e?{>B5nLS^sEz#giS+&52ym;H0mR2?N7T3t_k_nEboJ0pZw!28LxTUAuVo4~ z*$m9EI}5x?9Xck_lK==?XIw> zG0I5;s_dVu0}_gEmL*JLPB>@hl;uYnbdgx2E~G`5P&_$s|i61&*A{c{v(0*H?l-)pwj z9=t(nZa4-mVACRAk$v#~-C~O0IUQ{KwFMFCYwVcif0D>c{l6BXe@)zzmB-}!1=0Av z4{8fim4?MO1&DVzk%3|2pi0ev4-6bAQm5Pty)w?!I9I?5^u{u8rYMWG=Sf*ywmnU9 z9W~!QeB3~9!@Yr<#-S6T#RFl0;fI@?E2eZP|AYOiQ8%LyJBPgdShhY_FGu5{d zv;rinpGZh1#0B#6oZIk2e~asyAkkA9QkI2OZ9yywy1)4tDi`N-<8WNH*#vRQ!~9L$ zVWK=$HV8I#-UK8`XIz|i&X1XryVqsO(_~X;*UQZtGk5a-`0M^yp^o;O!7GAW<@SR0 z?IjzvHN(#?C}r#?{O^zrKsYlJGQXDpU*uauN~F>Mhp~5x(k$q<1*?*ku5{*?wr$(C zZQHK2ZQHhO+qP|W-oD*E?m4%6^#8Q?evLIEV(qymazs)?t~GwHiejfuWM)9(H$EgBlx8(^0`I;T|^D^R9QA?IzQ*R32oyB78`pMuqAk` z!^L84G-3h-*c1m-{36_mYs{iQH=c)~Mmo!cEPTGxZ5te@jmf)4WayFMKpYMZrF4)GVi=jybW9i(Uo8 zrzRoX=?kY!CbOG%^zW`5g-$OyUDV$Vc5ZRJ+~`!$?;cdjnbMD1OnbZh^>Q+>zGcV50@|zSoqR6lw2Lx5i7A6Se zx2yDN8?PLqsWU`b&2$Na$^%X}TH`Y+Y))E$2qc$?NYEs9iHXY@AYqj7t6P!RP$<{exw$QOx<6W^9K-^NkU0 zPV=gz^YnFD7eO9RsjKx3|ICJq+L}L!^0#P_L3sFakrPIDGK|tu0TvWR9%R2pC)t$x zX#a%1<SjvLT2Ao=r!okyhfywdA<<@-GX6ld;Q=IUNX~3zuL&Fft$p3P%yML%w+DZ9iyqs|xdf@t1d#oEK@oG`2|KonU(R?}<$d4h1#btMUHG;lPs(R5#|}(zY!3`(;)wxQ6H& zAWCU>8j5xl{$h=AG$T#d8nK_U-Txd<8|)G`s(~N%DsO-BnyG&!5%LK>n)2=v_NohX zH2$IoRiFGtcjV{6TB9%_=cd_UFgMTIgLFM}bw#onJb25)E4E4X1@*Qr>wb;$4dKJC(##+;HB+Dae+)D+pH>v^~v&fUkXR-2>v$fX^wbU^nDv9zjy7Zt(W}(KC;=% zt36swARQeuPT(U_xXPm?C-G5vsDxb*=)cf`g3hRQN^$E#yge+PI#WuMZN^@BB`?{D zwWjxF>cZ9s_bIeD z1QUK;RpR#h2f=BI;iQmU-n4?`GYc$}^=;;W^vk-|!|M>i_A2mT;;F|RN8u$@Xyt_{UJSZ@L;$Vcb*iHf{D~8Kl)barUsn9+boDiTk0iVSp+>Bf+(FKJ zqY~SpV#nS}0-;y)34Edp`w#)s`cveWRfq%b<77(iuF4SMC{cg7SQQ@yo-3TF(S>#P zS0((kS(s4GR5n7P*lDsuvescJX?r-(WP5OMBbm8&L{L8d-5wWbd!W9x8jODS!HKyN zWOJT!8OSZ|u-wnn5V5=!gV08w%G>QSa)WI=)Md)%FvR8Szd@XH%|W7bD8GJf{D=ZM z|C7$>|CwO^?~qYPsWis`%IV~%T+VIVq#Zc8gwmM}BFK|zet zT@ZW9tST*nGzNAd-I{i3KTah0XRhQFOaxH|y{z8KAL|G{KMd^%7Tb)W+Z5iCNxuZY zUUCbD{c-knmbBT!^nmJ=%2MLq%o00%^Ay6R@ByWHMzcmrjdD7*B}#*oz+PttN{#5z z3o?L$k+WLGaR}->qjaC+zazGCSHoQN8=K~2EmB08C00-dH`XPC6{Y$aHT$NajDxtS zHT8cSmVAt+6H?Tv+@$&~QTlsU@~+ zpHdzaiB$}T4sG121=BtRekK7!BFDuF7gw7l)wBZxHnMGn$J|KqrpsLR$b;mjY%tGd-UIIA_k4N^wP}n;S1r7fb*IxS!(r=fzMnA zcJ zw5i)dgo7(M#+u?Qrf3Z1B2!`_MPrQGDrX$cEu|nju3ZV*SBh$}I56vV;F*B=?Edh3 zO%@zs!eWF3uz;?gDIfwiTA-6LTKMG@<_%oKtOvY*~)jr&xfvb1PYEg8x$i_*(|>B_wlB(_>dX~$v2QC zUJh6R+T+01STa#vOg+Um+)G~Oj!I5#wBSPnGHB}%b(UVjT%TrCuV?96Bn()(jz-Ah zb-3{@?9oM@w3u0Ik1zl(25?X@srH;KldKR9_UoCctQTkLmXU>0R2l(C&8nZ_;sjtpD!GM?ZN$w zn2_BkOcKTeJ%!a=l1gUV7^y{AFiZQ~x7XPc9}frGp{mCK!+$ae(w8_Jq0Nt>hy?K7 z@sQkvklX?9gFn{ow~SeKQ|`G@0c_AWzZH`;g307}BS7yky-jyS06ar7f0$mS4a*_! zat~T0oahbL)~&@kZAb^FZybQ9Ejn;-3+h-P(tC`3EYaT!XTJFNlV1`+^w&E*!1PH> zj6L3+t_Z8h29M@M%hBm?==FrTzfxiz!emV(^=mOiZZsmdcb`4~71xm+y(D;R66W&2 z!eCCWA;&bZ`=#OAePv*dbK|Udo5IZve+So8*oW zIX&BJX?7sQNJOjK@EujRU|+rFNjuZ>M3Q4v+$aEWG`vQyM;(C8-nMjFY#>k5Q(4+k zmgH*GOZEoe%4$oeGWlsTQ@r&P3Z&t&jYWsQORIGFYTH45Rd6nc%sG}2i=zFai{!qS zCoidC#^2+^^ZO8DFT2^8u7#qhn#|lq8fiy>cxwTHe?k$EeXZ}CZn)CI)iv%!)oAF_ zbJK>RU=ZJ6n)t{h!imr_+HO2g5B9(qX_yFRJ6f`oESY$^TV-iPP>mT##<2a%L0UW! zT1}vAU{S6A2mBbhRKF>!hNMQ5VIEKORF)Ld{2zYPMdSJO2!btNh69+O zPCIXFnFnFZpw8YDmJM{sMYqr0EyOhr1G{VC`Ssmo#@J!DbSMzE+WR4bk8G?dbK=%f zfqZB{r5UuH5htBXk9Bmww4Pa=IpguGAJsU%W@Qln=2Hyy6s4ObbE?OwL0Si6mQr&q z1Dbha4l6_R13mdU1XPAwQ+Tw?JP3PlPH37m;S@+Q9A@xzkL@(T6J>eF->5_xEv=$d zy2Y;fiFBiQRJF6n%n>1%a7)W?dJodG25JgrCY~u%p9bw*u8Ah0b~WuNCYp*RrqVZS z5cEMe*&5|VfoU?nP=esJ@KJh$-2a}5DyC7y`Je>!>hG9P8Vb}zjB7QgcVPsJnL55` zzCr8xgByQ`;yitNvFbENXbyB%rCA}V?!gddpd~Nq^lZc05%-D;#H64FQzb#w(`M9x zx}->%1Q>-9yG+??c?()06r)uR)T%<$n?UB#om9Z<>e%3K!KzpsojN`J#0-b~3L3An!#%R>=lzwUvT;9@dQdq?lX)ZD2 zCzY7CaM^4sazLHzeafJ9rZKGwF{2J!C?;Yf=XAfsuBtNWt(gcjn!EH=cyzDazn1&T zQAAT^o$;_bQ6wa~~=pfx*`U$XaOK`~HBR{tQq zoNvg6wJq5j{V5rtYrQgPshFmioXVAi-$3u2Da+u47WYxIv&w^m*{h=BSJvF()8I63K^|il=$F;Ff47(RAqP_|@4{Ap2eQS%?il>gOF*A| z-L&VS9`H?cu}VX_*iE(hSLkcz`H2su#ozP}lH+nd#h>CiD6YVdmgAnW;Zruyt$Ap> zV+Za-9=wZBpC2HXkDYh%i%)p5x)8#;E{EXX;xPw&h%>U6D@$cw#yB1WYxEEHM__(zuLFcTCWvlc)#-Pe7_`U!Si}NtFG(D3KO=9T%g@m<~{85 z)9(Lq8|66{FS_B>sGlMGZ;?P>ZbcouTNx|%@GL+&C*c>XxRVXjsghMfCWQNaR8~4G z;$@@=IZM=qh!>%n8I|ZuH^j}W2+e8783}$PEtw0nwn748I2^$87%7Ak;qyLm8n^`b zu-i^a4CoZyxNSVr5Q^4QOB&*I+*<&xJGcof;QdASFn#1=@oc@;Q6L|=i{4fHFD`qz z6kTj1qt6$=HGXESaZXlGZA|HOf4~g~oee#ng+^$kjlArJN`x*US|`w5xsulm+(eAM zQi6NxRD$p9c&A+;Gu#du`y*7~BMb-PPW7!rO{SOF+#m`2nU*tJ|I%w`-zd6pIuUI~ z2fejUOY;(y_k>LVVwg^TJ@)GxM;z>P9+j9c;pP`6^m1*JBMoJG3>@4ae92%~Rwl;r zImv8qY{foIyZN(xwKm?Py;k(DENvWV?{@YIr!)VeNY${AF&mvZ*@(u(1$7$ik#k6F z2Ws`ijoExv#i>IWpa+Aq)FUEED4p;X)LcK7nP6BSpKoT67Xt!))j^~|t*4=8TOJ(s z7eiS6&I=6>8ryos^svFbByzC&YR&x&BeBzF{K7#xoe*=0A(q5i5kkvljPX7?aBSab z<8i4i`I_*fpu$MuJ&e%tDw(|fe03|&zmB8?#NfUUjiM90Lm0yL74;?o1CL+6OAI+s zGWIwLS6@JnkLMNLEa_5?!0UUnVur4xUfAQTVTLu>L|B&c^h1xkz#Cim-*XvkZ%@Dv zvF@*Sw-Z4_4FNp7UJbS@jlFG>02t{nlGQXLa%$Yl5-?Mv<;-JAw+_TL3lTO^4PzpP zm&O|jsH4buKJP=y-vI-KZvm$POt0DjhsV*i?`PO)?P_zEgx!{V5gfvno@w?!H~1aP@{Z5l$n(CIQ=JHN~Fi*VSa3&2A0SX5)JGsnZw;p?TfHZrMcAeXZB8@yDe9N{=1d2 zRK{NQR-cqO-^AUXHMN;CYfle4Jd(%EbffUcsS~mz5mKg6f=!OjhOe->)uD0RaMm+w z^v@e&ogx^Yxv~;7Pw1OSN2Z%-an($$zG$KME3(UfrAI9CgW95KsaeQKj9}@8^3fm* zf^tIE@rokj@v7yclo67zNKK#3@$RFcKnAXwNo8V_wvms%+;V{kC2Qs%6iY6`2K;1yN>NLZ-0sIJjN*`aq2^cA91C5bGeR3$GN>>|=T z#s|vWPnT)kIO~zrzqT4FyxbmBdCIM=Q*EfR4ECvjTA_M=oScK(P1jLh7_Gf~Qb&*f z{!AJ^ytv|v4nM_V4-uidxJO=cgQRktrQfwi6>q-*nSS4*zZ>N=Ky};;on8zS*l-(D zIU#ez?Vk1xrs6#RiEhX0k%4Hp4SLWf#2k>|?l-_3fY+%lUg@)e-9y$HP3T8sA5=IY ztG)S2V+ij9M4!`=b5i5Iw%;302S<;2e~}T)x8I=9yy|H8IQ{zWPvxIAaD`*_g+qQ) z*!@ke)UhC!Eq6>uNnY&{!*o$cwcngF=+j*5cEt-yZ4}-Dt*sda(DXBFL8!MwmL6VY zMO@Yetr}!&iLyF!XYR+CiHgog%+@`Vx!cCLMc)Wmw`J%GlQDp^?gjoZ#Btt(@I;Z> zRptz)*>UrPr(sykR{LYKVP&Y`%)-HZr6Md9Sn4i;*!iFUsC#xYvw3K%{y^BiuvSYMgN?y+Uk zRH`T9iFRk=k385KPbuLvNAeh?Ln@s$hlX^EEH$qH1nX;?{d-Hf%L`cM7K&6E?$vz= zJ#%IHEfk?sAT5hHSbee@FfDp5sMO!3AQ`+ss020m&R#*xp!NIu3|?uN3-$UGmPtrT zD7Wlb?0%&YE31euvHnXeVyA*VQ^jd1t(Ob@@&IX#tYR=V!VW<^YL^VyK$d5ldJsv)UFeNUEu% zF0AZ%euQc65&5dvZi8(qZ@)FwP>za4fii7u(lOlFmSH|mZG%yjG1)6ny8gt`41-F2 zBzn5$MACYyt|T@65T$abXfRJ{pGtk7Vh`u|s`ws<_xz1#erkV9B>7#OtB}(+QAou@oYOkI>NbJZ zQ#<c`&uhe3r#ifXgU&c5gPTZefS3qmH3tS>+Vn-M4GdTJ05$YrK@5WoNEx<00--F6#LtmBa90frSqutBnlp-=w=&r zMMnF;VJF#BvU5NL(F4xgA1n%2;)bK5|9Un^I%H4IXT+QoZd1f#;F zvV-jC%5%NTJ8eRiA&*N|(*;Ob>{AT>0yo;?;b8L~A*@q(k2&@OT zyR!L}SZ6uS%&MdNvUam@^&9|O)0PCrB7bAZ^wzLht9r$hE*rN1)M6@Tu=p(i%R~fu z(gV|6B$1xHK%YYxTFa4&h5<83WfWWesVJxMtS<~=q9Et>*i@7Bp#RP*CZ<*oeuJ^Brt`s=RNSg2X3 zF`q5hn-tj0IqfN2evPJX-gT^JBMqybQfK0hG6iduj^b{x;v%wi$#A%04AsFEg&Sui zN-dM-XoS+UYBK6{6xG3z*m%(M>B9+PVii*gvK337;}v&k0c&9Dw$lL8xKM2_CV;5& z*c%-jozLz|Y2tX^Sp>*c_}?nR?<3YnW$KAI#tHtq+70I0%o`93!OH{SfzeMs z_MuF;7XJ7Ld#ND8kwoH9_@_~@c9*WUUjscD@;wp=J&T=wP+B7iHy&m!i)0&TNLPVl z**#oLR`+EC@`b7C+u2}B`Z!)S9vJ8etfzlg9xBHg)}_i|oJCdEU$%{r74h0pm9fZ^ zPMqwo#ft!grMDoii%OoX+?C=5N-E_Ri;p)SH2-xFZR^E2Wbnfx(E16%rv6W`a%CfX zhadmZ|EaNzoRg60 zdIS8D8&R);;PF6zX~WL2Y5n|ud=JG7yoiY)Y8*R85D6_{)}zzPaZq>7?F`4pKVp{Hd6l8@>Fcft|%>)RvKC(1d z_VYg3pg~Fzm1>$t!;$py`DxqPDTk*`VI@>dN1kV<`GeYXxocl*va2?AMR*?SPQH=f zHTp}PTkHp&ZsW1!eFddA}Yod>in8{qw~i^GZuq570`Cq2vmjsB6TFe=?o``7D=9~X(#Xqzn;u) zZ5R{+LP<|9a>k}EE@@=>0$-1cT_7+{j|d*I^`C?{-;v%d76xyId(3X1j;`M;19Q0a zBkCi_F(NqqNfha@Hg_cGtaxx$hVzjNjnG0GY*vGbwo!0KIRpM-Ejr;?#T|aY4 zr5y;|{nD-O!`H3*_d7#uHX9Hsmyhn!JsT30$Z zGvRi76Qvr}Qe{ftWL(G-%^YV5lAA~yIEeI;&d)b1Z)f{=-7ps<$L`CsKzuLZ)XCM|=1`j2s@4d%^l~ zbD<#vPGJgN|IlF*TLjqhIwv`MI9Yk^fKo;urvMA7?=$o;3Y%$N8RiDiX$=ms5<6}T z5vrj}IvGh7t1*}@Q5re9r$|(~QJ#3+BL3Gs>W9>9XZhoEnek(8&hnq^Q6X0&1E>Gt z68zr;(f_Br<(GBcICag;UkuW2qy}@*oLZDdA`q&CrG*;-euHp%wK$4Z=lbyWbm`!4 zK^@O{RBXqgA_n&Tlkli0pE&jDP0By%xQTeJ&p&c#m+9B3R&ej{_jjD%k_PQi@Pf#q z07FOG0qm3Lhlo-Qj?~0U@Qw>Q#|c2v?j)UHJYu1A$%*1jVJ-%s|FBML{DjGCx`{r@ zTIoW)^~%ebW8;y&s@Q|G{v{=Ttfmrl{qDt}uf3+6qs`3z{={^4u31W3PZtBITcw|E z0L{I#tp9PW%qm^TAbQ?T8*`Yc!}lIqezGctUi*dRAnzD$NxCV#;*AM<2gqN2sd8}o zylpf_6s2#~h=e)dHYP|ff)*P6>a4U^?yc)K9H8BYCa?|ryd(f0C#Aj8QL&4xX(&5l zJPLR3X`iQ+1K&}!>jZBKjw;v2&y zL~zl}+m+6uT(t_q)(-z97;=Hk~`-bR%bOD$jd?sm{KowDRIY9 ziSb_w6cz6_AKRbbFqsg74EZp!GH9kRlDGg$E8n*+h~JUf5D$1uKso5@1bdWBA0Xl$ zaX%sSzvEV%-cLyA!c}d8SMNJg^5XO+I7l0Hi?VaB-ROi=0_e;Rz*2l>$hSU?*&mq< zPjNpP7HIH9;O2Pvw9+J*Vs3=iprz-I%X!g=^d4l zX&xv-W~$xkCB!~Jq3b$?*?Yq&=b)Z9?Dh`wO%20#t)c8n^O?UQl|z>_DLE((h4oj< za!#O1Y7@Oi=)BHlS=tis$q?}23x?+2Ws3l!aY7#Y@=`w_8R??e{52H1ie0udJYi^o zjF&zLmP1Td?7y#QIWtFwrwB`6Jp2MV(XN6JHGcbt6iKjLq~mDp6)&GB*<5elw=D(9 zK^mr;ppiPi(-)4(PgCIKz52<|AGILcoB2-XjRz;UdC_{;Fx9re_kUx%O+Z$vNBkUK zH$R#q{{Q3vbI`N0wKTH-zm3oo{^#d^p}Il1DlQ^_>CnfgN^Zr_iW1flSy9G=3d0*h z@Zkv<@&k>;_y$1IFhn2p9(2-P!vGChJI-GHSh;94Hi53|FPZ}>Su}<>IA1S!HaTyz zw9$}r9e1tX-Azn9JL%d_vpr{gXZ;V~-mHVtV;pbj9(o9FMbSSb5wjumWtdD4qUy8p zL#Fr!mke`dWZu@@0mv`h}UMR{@?dJy0( zXx!jh7czOZ_$Nb-rUZtMq+%#qxdwEUp+9D!+}iN85*q^!x= zt0274#MXuceg*Jz%vBIjTE@68GrToN$FfAMnMYPxH1r+Fq+i-2uO;*9?~RDds|@hSEY8a}LZzsVuf^6v zII=MIY(twLg@3~^bleAsKi8NK3K5zS4>8c8b8#vNFlepX)^(@|<1kC42;8N32twck zdXzQK`gYxMw-)0^&@T5v469+DFk*)M$=XkWguh-4GC9iMT6Ffjf}D*^de;IOpS3}l&B$_ zB|9lSv!BOVd)O-E+5CusBgSU*X8Z_zu~sP#Aey0_!EOJV;Yc#q+Q*Kos*~4IXDUaf zAhq7;KI=AT_6X2h_vo$nR|>I2^bN9g<0t)B>^(+pGmSx1*zuL{1@9(1lo zapv;pvTR1Kj=fdyh`NZw4_=Sd7QoT2RwG^)Vq3!AAlx_RX8D32#5|>sBlqu&4Yb*l zjNk67Y_2(|-s_V4RLM|f4cq(Ic?TGVGK z8h6dk7uLfp=u*;m2=!ez{R9gVtx73>jEt9OUCD3jwH_Kr(Caz8T2zpVK?KXFFN&iWw== zTto}9M9{_<>`G{wF^DvEq*{LZ;<4fd=UQB+XBXir z!kLqd%vnBd<8wHu8kH!Hn8n-YJpd@F%p1lM#`~%%BAB#mHUDl?FwVUQEu+QFdJ|)k zTmXxs!F_G0P7SW=@eL`e&j{7$SKwxN?sDq`svn}{v~c8~MD?6#sfq2fMClZDyvvaI zF}`=}>kCyoS~wowa@t+2eeKf_Fd0qR@Hg7_SWDN7EHSf>RM^b8UjE0gIiNs}2>zB( zdog-ry@_(Z&Wjw2GEqSK@i<8{gASTj*%zgeHh%4A%*;Up2in1=)j;Oc+s(T%Qh;dQ zJTx_F#}+8zdTpBLG7b!((+jQnuUe06R;M^!&d+8hSzVh?L4sV^oNrL{vIwm-5sBb2 zLSHihtCRt0KkhvHf!|Y|UVr;*el_i{1ffS4vy2=9fJDtn zmCmsh!!;+<^fl)2LrDh7GhP5K*=+zmxr^gnsBZZ+>EhndLHM_^otjrlI$Ajq^zql&wN_+PO9fT8bS9Yj|G~uobrAn)KaoJXwmv@T~05rxD4~59fk~=Qufh6nQK373yPd za=OT#?zPcY|7#5Ee$Ji_wN>{E&^>CPJjXMn$rxqZn(`6GM(1-LwW>{hgn&Bn!?jSL zMU5hmr#?E|Tg-}h1$UXC7#q~Q`u8gGeD7w|nnIy9b)3ENsi{P592}kvZkrfMc6rS6 z7JK2TaW3Y{RlJ_%>&elPSiFuMys^B+?#J|xXz$GVZ7bC%Z$QvtLRg>qC`D?(l0BS? zq|TG)_EYFXde3DmY2O*KnPd7A&sql7G$bYWBcUx-)v57^A-%xpnl&(?5QEC%N}O}} zArZ@eMmc2f8T2xthBbE&$4$c+x{s<3?yGsmki)?ecGPh;Kd6LN>=Z}37Rx7Y$~$0?x* zgIxg4Et0|)`vqLm##>wvHz(6gFEIzoKTVH3f#ok_i~VL;91g-?h`i_qfdj}rvV#;` zjD2js)B0dI;I?u9fZuT6MDHMYn1bj?_ZJs9>139I@9!R$2Q?8DIV=MQN zLm7B3KN>cUGdH2knHCF@hmM+-8=n9Av)YFa5w7x+g6D_yAL`5h-+R6PRi;v@;--sa zg8VJ@lrnh-8aE@TpOe?gEHRb@WFO8lr@>LX&$U9uxw^vybgq{>zIfw{QTfUHo*!Z){RK?j-4RbWn+Ba|nZs zBdhsF$L7*Vq6a(Bu|MHzlr;fH3vm&Qca^11jlLr6+h$!-ie zjljZd$;OxB8wAG<8%kZtrSSW5yY$o6^N8B=r1~iz_5CDlOaGC_v8T{Wa+pV*ziW=l zFUIg9_CW^dIUTm>1I$PW_!#%?k;_5xTlo1L(2MGtVnZD3lKj;@=V(cgKCu_2 z!bAQ<3X9kyR3oX5BdDM$PZS-}eI8X<4>`?Egq$n;r7l5f29}2+6w&dVXMA;%Gn1~! zPWci^x!fLA#G)-T@HO=GcRTx8@Ja(hWHjnHkgU$qf$fh|j7-qhrcTuBM0qi6^y7I+ zm=Sv$La~J9xATUXmX(@nE~tPuPw+6-Uu&;sl7_`znEgoIweI1>5>m1lY-l8|5l@Dl z>6MFgYd99(TS9Dlb9xl$3UX%2=^MIi)@C5t`3W52RMx`eXJGx#X(exuk~T)y&;XZe zq$e;y9CZ8#1yHcCzo#b|#(wcg?Fe&bR_ISUn)hX4+#A(oY8 zQV5cfVyNdaU@y-*!>z~c5mavx(e`esnj~b&0SoBDc`TIeF|gH?^BAoPxNH90iQZai zO1cLDym4HWcy3>6Orj74=w4*G&lc~-e{>Tfd zeM`_n8|idx2~u;VnsyKEkwDjtO_b5#YzYO|!MhRf2-6%+gD>qsiX`l_0MQ;FQkxng zGZ|cl>iBI#h*NuXML=1+oEWbk!2-20g(gucpJn^YF6)8Wuep9`yyE`t;dh6^j@I!g z3#eYm?iPV9xPvR+xfV4ORu6O5H@Dw&ebj^-ciY{K77)&XVYe&$>i7ZLK(MXgpqwAv zKEbiP7q^Ri-h)FD&I%EOv5evtthudbPcOt6yzKqft)7jhtcZ!?$m46v7J*z5iHd{~qA;Y15)I48$5ytL znwVa)@@d6%vm9@uL7t7?ON5nBgiHrQQB&AellXW`;$W3|Z*FA*a2Dv*<(nU&&793b z2Z@%Ihaw;p>g&<#)@p~PQ#PXlrzFSj}U3}k=-mvrjZU3?>6qV(9I*U)g zsmz_m+Zrsln;#K(~!%x~T2$kIcp7UN7u=#RT>I-MZ z_L;@K*HN1g-E(I=fk~MpGh+;x4VipUi_GjN_7)mgn&(SPtZ1e;>>*4qn3~%=F0=q{ zu*F^}M%E)qS=K5jezp4!YEzOR4=d@7bW_1){PQ2st_qs4UDk3{V}GUG{>W z&n3>w;Gy)P8Oxj>kWjy>AJ@yw?Fh*vcv=6J9_nwx;i}C5uY9-@xZz#vFQC~q(L3Ji z+33G)=eE+D-r^bV-)#R13x0icCk=jmcn2Kegy6!#Iy4!-VVNY%bGWnLu|sR_cEIhj zSB&LZ#Ww8;?uH&x|EmcBZoMXAE1hCC{BKpE*7Gb@TQ=*KGyyPL!SIgMfE^f}q#iiz z9@yxcxQolYCQcvCjl`PAuV(Qj3c}#uq|rOL@InOL_&n7y!vy?PN8`g?4$BX@H{(+h zkg)zV;NRyPCpLtm-Bb{YO3+AI$R{oh7^o#m*k%AImN`8x^?(LV{Ka;LBA1I~JYKTC z4==NcvQ~X+vZ0Er0@0gb%p>+Ya+<{6n=*zHwbPw&kuX~fC}*sR-LF_+B#6CxZJa1%AOw>Y}%h^9Mq>*$LN*iBXdl>tZ_!@0jJ?0`-LU>8~DHW zE#Lcm(0s7Jelh(_!8raCjOxFMM*bIYYET`@Q{fPq2S+N6@l#&>%~v+&AFdh3pF841 zJssWJLf#xme1-y4fLVN8x}z_tLJ_c0o+R;@;HE&vzY#NJH z>(Yyi>#g+R$zL(h zuGueH{4O?t!a|EoB=~+gD;0X1_)1<#)r7;rh&>#+>j}EYG8e^IW_ap)tdHKPb_^bfXnpmZ| zz_+Tbb%n7*wss}H;$WQEk>Kx3un#%uW4=@*Mohu8v_X7goEy~*bm0Yo-Qe7n-M@c!b1cs5D+BQ93S zfTHp^S90)Z+GyfE;8553qZR}LH?Cl?n>@Cw(O*?cO76Bhj|FK{y&`3Ie!7&{uuroU z1JW^fp0EsNOa{zCh_Toid=8Et60{5Dl#!qUc9@9~#ttb0f0)cMpXqWeB}ijq=CDuY znkdXQ2wO^JPVw5^FpuIhBZ>X42=5(C30G5WL5cwoPoM+rw=!w__^{!lCHOy*m*u0W zoHSU^-@(M}w+Nc*q!nlL=DRbHR?xz*g7BQUM@5-8A5k5-cu9L2si$PpKIp+vxtV;0 zU17eg)hV<+KiVdJVtBacSN1-0%@@sqTz}7@Gw#tI3F{`-PGrnBT_XLRq1^ymEGjw8 z8|R1@wb$YeV|xUG>Z89omF0^5{^Irx4V@a}x#`ft@qBOUbC%S*HPRfq4Q8Rj251m` z8_4yWg_z7Snv+FzX|YN+M|Qnz*?>Jr(ahG9{yfo@+&QMix7S*8q1){J z1&WxB)kt_ri(u?e(2+(-XS4Lv{uyYC9s0?1W7q3CZdq9LL}ylB9vexrwJeJFG~j(a#mgCn|EVX_~P%64xg( zs@S!x*`A3LQI>M|>;hU)R8W;Q=HyXG@SBePF{=m~DJrTM3#m$Z2J~nyrC@+qjz=`> z+WqYYYm8PvX4W&LP;!s$ zGS`(1g^B_tN?IS=f~U4HA&Qm6Ee^A8m{pLv)WjWXVO|lBLuMpUYVu6&BHoBmrnUrc z4n@kImX3EJo^-V(_tQaM=?CI#WLy=qZOHsW%Attt6AatOlCgzEhoh7 z)6Bf+L|NAlS3{Ufsx&0~M4*>+#w`2LMk{g7DEkydE8&E$Pt22_+9AfE#JQ)}sF#g(cY_GpYO7Ci$vDV*`M<^To3$rpXnIZ9r8vn}3i2%{v(gj?LAK z$Zdh3ipo~A++vrOnj0gyH(Pl7Eu&o-L`pg8B?)?;Cx1kcDj!wzD>oM5VRrlcTRh1_ z(`FE^VGQIll6_3o7W;S2l}Ydw&DRM0(YiubkwSGh^GI`{^73icy2;uWn?U|Izf*cW@Dra=preb%+O>)&g(roOO}uA6snE zdnA~@xGTPtt;_HAl_TYNewtg68&IQ*dt%fuXmSU4N7Fa}TQ3QXb2KdM3Xe$S)+AE3_YI+1c&uexLr+Q%w z)X)|2xe%(~;VUqOXNI}+PSZ17qk`!6)PVN~9R7V}6z~+E$V7ix{;GC;dpMTd^og39 zUe1nubEy6L$w##cWWMeAyJn|NO;-(hz%lnCe$cL4SOazPwfW|d`L%(Y?}z18 z7Ng;7A2dDpHk?zkrDP8f$3gL3d^zVO+Bs6yg~W(NT9#ZzI~r0prJNgN^#h!RpC~B@ zKm0~?UV0PBeaIzLt^HdBtX9a2cOnK9DGC(Ht_PHshH47+Anscxo#uOX2V~`+VrX`? zPn;9f!3#k)Sr+}+*XoyOfExVyVFuECw)5CQ})ckZiKGjr=zO}&48s(YXAAN%ay zYp=DwZ?(S#y?}>pKIikLbWmAl(D4z}23=e+-Z8gMBF?GAS*&@c#S#eTl#*s%k8QrF zTc}Mc2pbfXehAg64&OYFxRi=>>TiXoZOll{;1%?_{-GC`vnlBs6h!QyQBQu;v4+;6 z{JJ1>&yBHbMYHS7X6U2Pe@of_x$I4-@XiF{9O<9iEvV|535@*g7V?k^S^))V96@!e z!n-!$QGfmddzyJ~w)qWTp-TX#FwEz~D0ppX?*rUbz?rOV41 zWAF8a0{7TsX)&8b*y%GtDQg|tAXPM3^TSLA1(P{sW!r1ztQ)ii2h|=(T7u$et(eb%;S_dR?6WWWtE$G*CYd*<|O-;8XZds|%WWoOci zogy308Pn}T-H^VB1oC#p@NX$Lo+GBm1tyP-WVF?|L>V^xJKmlL-DCG;|Y9H3OwJaVg*$i`FDhBCQ$K?zfdFObezxTMd#U(>fuf z3z4c=ux3#l^RjsW!K7_}E_ugO!tLR;5C=5tyM@JdKW-eom!hdeu-0ka5k(&tOOlGl zB%;fo+&q^R(^+k&uB;~KE4+h{kR$v0=vx1#Zl^*sG&7C+_PfzvQAaJB@>@-RoRbaB z`GIpX3)Xbe)wL8<>8uRWmD1MC(!Zrtx{{Z!HU|uTxgpjZ4V2g_cGehBtfk!B8jBIc zS;`VPQKCP4wy)hO5=LE_i)#?zl+{9D{Y^6z^0K` zI^$Nsyp@WmXQ`#(SEcwfs_|9sho7hJ(5TIpOj=bi?}S#-SJGlsIo;`PMrqPO2NJQ0 zUm~?e>zE+suC7~`NEo_zkA8d3QiE){1hzuc&;d-pwqnx|=t4_sw~d#{*GK5nH=fa+ znq@@V@*w8g=8l)CGN;tytUs{EnrGmzvm5g*1R~7+bL9Y8Z)R}aU|0zkG`*-#h0?0 zO>&j%c$@$qXhQesQHPg;Qt|WK*CY@n5sf)tX*}JxO>Ur8aUyoTWJG-14VF%KQM(Uc zvLxt4Z;)2o*m-y$cbxnbi`9#MES?G%1yq1}Bi|b&uE}1g@Oyk<{m)-IB(6zcGVuNQ z_daxr|0KP&;rHa9?pQs1E&3<`!$iHONbE?uyvIlgGD8Nq{8KGfME~ayCm{a7i_b6i zfEOna%8eCkHElG2X*G>Dpv7@qVQ4#!*nob&S{qf##AVz1Ff{|c=d}KRX^t6<5z#*D z{j5I8?4Lw~FD4FlOh$G_-VXLm&i~;G{jBve{|{H_f0*LUn4HaA-JI=Ry&TOxh4EGz zW+tD&*8hB-UaD@Rh#`#0-{fc8#i%5#jMf4xA}gB_QKTZ26fLB~GLVQW!0>y4v31+L zD{WWtW_4*8lN|0AgYuoyq`RrhGOSEYij9}e(&Rz!gQ4K_N_R4-_nIfX+eT1Q0?*K9;>3Rs+4 ztIZo;sm@SpU7216%2H>WTJaaJrls)i8{+XY? zFq#)xjVMD9uW^bla=Le>M;<+{VNG{5{sd{Y7ap74E*=Y&t+m_S24>oikL_yrPzlT` z#Yux&RQpp+L20A*wCWCM#?m|sjD5wW(0!zU`w`0Klnlcf4vZ?dymZ=$jHUkbsQJ5w zVi*;Ng_8Vh7^znwzWI`jI9t>XE31S89jhAISsu^x}?&tnv>LH2&yXYJ8T|w zpmJviwnj{Oa-?}k)XPPhGNdz>)HoQ%`Ze%)#?$>IA%80Um;l0KeDpvek|#Z!bB%HD#HOAZg{kI88dmHpLM|Cj5(o&-%GG-_{r*4U_+SqE`XmWy}`utlCE|OBiy>2pmrCa z@r_-yhRk#=Mt)M9b~g4nbKWFnw8O`O)rzo9fqR1O>`tyLfw<&|9}5j^8ZtPflwCEE zTF82dwRBIq0o9lVTKs~8%jruqc@s39Dz%h}`@KY`=^b}snznd{=m~dCHhd)roT}U< zMbS}X^29RfY&c3`^CvC6?#`8>sTj|)$H90Xwg}wOUDHw7i1_;N!{L%elsV}t%;D$C z8FJ;|3|EIcZCb>On6`Lkp0yn{h^Josdz7@~F@FGK%QP$tr9=p>pNkChD~!f9qR)1u zUaKny4CS2hU%9{j$5ozMawxj&Yw(tq75h9ZPDxJ$>>2QkNu@td%a@*Pa(y45#|;WG z%G0pgx_0^nd?wTzkpenGY)pr=zXWl`C?7!Et1NXtBLpiV?9Za#)-L#LhFsA@wO|Wx z_XW2)wn6>|>(Kaiq4bhzU0Q6WFwT+5s5Iz4K6oAX98ItEv2MzOHVwM&3I6N6q2mgW zx&3r~Hh;Q4|3}VeM`trfX9p8A7Z(R-rcc1z|9kPD7wZ-+1W!G6w0EjHJ9owWW+9z| z($q#cD^cRq#ij}-gHGfIlb*JK~_IUt+G>~w(naSnt7|1)Y}scS7UaQu-} zk(3xJl%^||v6$XgjCYc=`HFQDAR=6f)sjOiVqf3B9sw5`-KteG9f`}|V5vPA8E%}^ zTCzwa3u;w0=M6XM{4wQT!`W3cdfE6aN#FS^a%?7)Sm5vlE%45$XplmVvId}Rz-Kgy z(ZIRbY7*5LXC;_r3SzDua)rireznCWptrZeO{w}B3a{)|HOLMS27!9NO!e||GlESH z9_h7M?tX@|YD5lLPy=XI@3##`TRJD#$Uzism`kmmLSjO1wYNxsdu#JBxpkQ+W#Eeo>>Wc`wgF3heBD~fqSbUeNs!DCfW$hBUMYLN}rpR+(k{p{QfYmhPeuF)`lx_f)R`!rN= zV;#m3+)l2w*D1w!=2<@J%40$*I4e|(VgqG(0UIOvbf^Ru>ShADswB-ge2z6M1|exm zXF_PfE;NPejf=QeQU8^IzkM0>TY~j+NoS9lG(prZ&eAVk1cP&GVE5e|=++A8OwSss z_ih?o@vmPM_wqn8Zyq{JHEW}>^)Rw8{R2}kx(85Ro1@p2uP$D6y6sTi1=Gz(n6KuzF%1`pOM2?dtWdb*(H$pG_^BdBZfE zfLPB{zhXX)2s;H}uHxqSMx7lMgT**d8p|3%uIN3f7@Kp)c6Vhof8rd*Uz8xoUJ&E@ zqtY?W6-ER`wOQ*9Kg?z%nKtdnW}35D7x$;CXC26X;fpnN_)kqwK>?w0hcq|1sSuOJ z&5=PQc;dkbpnXUAk~6&t3G$&!Y9cc@hQRN+Vo?4W*XT@3!}!)c{5hCJmdPIbyS>IU zr2VZmXsISBdF*LWz30@ada(S)D$fFimq~C<gtEx$B`Wk4 zvzHc~?;jf_WC7Nb^cPGxr10$@6ro$sH0VT1o~W<%yWYcr|iyRG76xiGQT1K4p&c-c!pglDzbcwc$fvo*>9q04HCqm4H4&jWXYs6(i?=myy2~o@Nb?vdz$+~(zJ0CQq`Gj^Lf#_gMVuV>qh(* zFO9+>R4D%xsa5RIyctkVqFcG;#R}HlNNS6eY8nmR2M^}Ig3}E09tXGQeqgVTCZ~Oc6!x(v z|8wDTnl>(%TaU~RTmRQFqyk9}53N2cMs?Jxr`M!b_a z1$mMwtyY$XFmxaSCvvJKJ_ZLFYSiAmE$f0wsOZuox^p_ys1Lora zOWtGUQuOC!v4k)Op)?TOip3d=fBz>;Z-S=$6j<>Oq@+^;BgWRd#wQljtu*`f08OXJ zk|uW&+Qpco!PI7r4_DQCd!JpXTv~3O$@vNs=mFQFS@9~i4==8czF%I36IcnrD^QQF z8fl*-O4anSiYvnB8JhBdi0TWX`>J49>=}rATy<6461}2KSU6u@zO@>w+|6w|{vs&I zW*Wc;H=h}eZ`tFK%4%St5qAwW!hhc^43=PdPa5jAlG0bSps_~(Zoa;r$M0{XlTFMKXm zMf>`qvJ4k40ssSUDN+L`4JAhUn}XXhFgJb6aIDbgoZD%}*ay0r=+CG&(yJ<9@OmN?u?>(?M-!}$F-VRt{Qh>4naarmK8*rN(0mL^+?)S=)VnH;-KlZfe8J7au?_+)cQPdEd zy~v^AsJb12AIDR+Vq?2+9y7YW)ohtF6$g$;3?ESwBcF|{ZRn~HIw&ggjhl-0#n*Ja zUdF$s$yZ_*KJYsV6A#h1f-31mF$5EHFD zx5@_mIEY{z$a-J~YdpWj@edpBo#$xwE1DsWU|Vl0(j7tEj?61e zoS^h8P8?3@zphci5)ORr@)2(=b#Z#zK5e&B6;ba!UEHOO|=?;b9S@YIk(JPOon z&YyBzi#l;6GBCZ;oS#Itb1_od2Nn@S%jpjy9}++txP46;La)-pJyfVXzW1fMpdT=# zxrZz7J3N#`1r?ud^@x%UZZ${l{yAJUlfyX<^q~2mug_&vgwy+M*&DBiy zHxTH!bGAyDa)i=9Yt;lVH!>^cPb{BtQ2YM3))7=5@W2wvmoL7o|3gsn|L3OqKSgAv zItZSa%UA+j>ofN5*T>Rl6rI#gjsB^yY_e5{1-?md)6tvAMdOJc6OB)Kg`| zm8;ZJqc?OKtZQ5GX=)jkt7`o=U#iqtuCbJqMRUC@zmm_1e2x4Bwb$n>N= zgTQI$R~j6EFo4D)D9$dLiLz|$^I8s%(~+}KVinAby+FXljKJ^g*&Q0l$!wAh677dD z156eM4-9rcg)eL<@n9{oW~z01wLrNhfr2bjTqA|mEoP$_`3V)D+LBvnk(Zf~M~3if z{TwZ|R=qRIi&^xyhR8}-^M)Yr%E%P%sG~G2UxcbMLWPlAC?2HSG)lZVvht>l;N>$e zPCcN_LOOI6_LX23=iBI7JU;^SjFgth5pxF%^B~irEG4e-g>W!*uFpS@5>p=7krxRU zo+vuS7NC*iC?5Y<`+@gDd-?LD=6yj!h&n$iiHMiTbm%)X&9i~i!RzhTTO-ZA=-V-r z`?^J=Rh!aBh{!HdrL4u};SyGf5A?_`(&uwhlAZgy!<{EimW5tFn6hLSW-Tne1tK^L ziKqg2TTJ@|gzgc5^kmn*4Pw5QKzkor`9)c7`Y_lKp@sVDT5+BSTYl*b7jBqY@j2?? zeK6$2O*>4)u!4J{3{1p*0R|jm+LfiTmZ+0c!q!N_e2}<$ii#a*`iN`UL=}Vl&gD3qW zKsaMqBBo0`^~tdv)J}C;HUgF@x&MlsF=&SC+*2&pe%_jg(b5+r8rx3y+XiE^)ajJ` zY?!4RLk0q{?^^aQfVgv1`H%)~9uJ=aQdp`^r3{4-G(S`ckR$j}%Q{O9ph7s%XX&{^ zWtk~qoHyg7tb{+7Af2Q;2cU?@{tqqKN(Husk8ucf>xI`r+qJ&ik%y-t=qL$Yt|kews1y$4^D zv#WDqlQHpUk{x-`(1Y_`+Lk#do;Tx8EPVnjq_L6%-qnpLvk;9e6&xj4g| zhIO)U{E!I+>D)>@;|j(5C+?&lW1$IQ+WR*=$Ack#T)!$2x74gJIJVS?KhM%2M?sRS zIE!it<1A`i{~vk;69&p}{qmmfltE2yU-eQFg>=l`(LFqsf`jYnm>M#IXYIEL&wLRjPwg2y+|coiI3vOVwWwhq*oJBFSn}5*NH(+(P7cK&v+3Y(nbqw@#Yn&|AL2@_B0>H(UxuOs@T#l zf1qcO;#-{-nEpkI*ikw5f+@-jDkVFW-2KD;7lvn#NcliwSCReRf@P7^;(c<@;l`uJ z+x7SapNkE*j^{Dl9pw#!Sn;Z^0EeZJu_y&bSwM7G1U+@{Ni%PAUnf|iU4-? z2%kS^u(yR)&%)-;G0)^@=tV3vN?bxc>+B?+d#C~bcckcAHSS;{H6z>53xGb!UA&8w zn4i#uXAR;WkL;fa7mVA7dEWb^Ft!%sfE@k&wfu=@K~>9ujf6HqDmY?*9ciN?hJcoi zNDi;Kt z5QsscK-BA-1?*;Vr=-(CW=`(NF}`#Ir%&=?nsgyq9u;!`y8FJj?g;jwjzW$#+lryj zN|9+HtcnjW<&y==?>ThctJpJD`=NSZbsD&+g3HpmBhpRRBkKMHo{BJ+REDQM^fTar z&m}{rCnTM%#Wc6wCYh}peeVleX@f>Vx%yMzVyRg}nP+{qu;7FiRTNb{4r}R(ezDRsFv z^#<8VGqpP|Tc$DtYMIICLZ^(z9icLppa49$)zT-(}IM@E7 zN?)r4F(rCdj?b>^D6aLmnqrB(Egbv67F2Sp7hs=NuMY2}GHY*bbSjpG>s-yrX6`_S z@(awv0H3RLVrpMpY%)lJyNPqKMqfw1z`H36iygUi>L4aDFw7=%27yn+SYA>)=dTDK zc?S52X6&;X_+9iI2z9g{E+E^fCZ&F{#9dgj=~5wK&2tjgc@Gh~7plsveV5=CuZqZP zG6CTkr{7D)V~WZ7=bYm=UAocDK0Gs!<>g#`XW94Bn^F7UBa1A&mkX~>T zP^obe#+X519UVtbyp@)?Wd;DsopJ<1ZYX^#Y%Yos1m9vycrkhl^?XQej3a{PS6Ra1 zCxN*N2mO=dr)sPX;oW@8uXY8lpK+sZ;Z7g)AcmhVu@`y=8^(H@u@_{b0W@O6j{t5> zy}1wtCw;SBj_`M$1Mkm&oxD1ngkGXInt^3KdJ3Ebnhs1xj8%}+Z?>OKL+&DuOpp>g zK1m1YV)u@Dd&@h|n3!A|X##NXVrJ6L)V8`rRB@|8yQzY$^?FtDO&t37UvaZuY{g!MP)rH>sp+AYRjw4%>?>B2VFj&>paE;GB!izV(ZY~islgnVfuAfrUV&lCIV-S|e?&hT5b5%gqNGht<(B3? z`?V#XBg|Pr=^0PcMS(uGNn@Zxc?MXiG)n_NJEbi$0&G!II3N6-6JkA^f^bx$=}5Vp zQ;t0e)E~%HaS4Y%vQ=|Lt~_Y^9Do=7+#Os>=&#_7ZjL=E?I7en8~dq(76G16bGvbz zWQIAU*$2qIt^z^8ydL}l2ZS}QP zpdT8a7g(D>!Qr1OPOUhfcU^Ozt^R~8ehx?*oI|Oe^K&cAvB5m?kIQPRR+@E#pS4-% zEdIc2dAbq-p2a{;t-?24ENQIZSRd^N*XYf)R?prhi`33%pufKRip}6Lq_UOmb_>s} z>rz|f2fIY@1iT^~#nRpVDL(hy)S|W+#B+EM43L`L7j`sSg%YRB#wY&Lc9B$8_q7jU zYb1f45W1Iai~C#1xBcB{=64U&@N4!PjbUAeXX|0j9g{#i*w#!#-r(PqYe9w)aPDMl zR>AtP?%J>(9&`%$hX81}(k=|-LQVz0*H^b@=CqolzE&cSv{ z>@FT>5!pILoV8J$QsyR~6rO*948Agq5ougI z`HNV(P%u~Yz+J`XkDAAROFA4u|7JgiX-<%wvU4{=pp}xzMnyV zm!bP+^l~ID2pmBwcQkc6e@qtw`A-FZRiR}|{WicFvw)@sG;Vm{+;3xerrs~6bO&dP zHjs1vnUrEP!I0!?%%!fQ$r`gC>>%@m$`9l^&|?7@S+ycaGCt$zj@b_+5yUsBUupo- z>ML!rH{-}rsx9I!O}OtGOQ+}Thm>h-EbQHR<9#nV)`p*U$LD^9l0G>BoSuks=C1mc z3o3ADO?;fxMz6#?j8UIFKM>4_c8`v7-p6>InBBAu)ek(SLYybSoWNB9OGlHPv1B2YBrg)WmO#YC}n~R9}_W1pnDvy%b1N7k(ysEg7%U-6eaXnWTL} zr8oSOtv%8zG{VvjC&OHg>(X(`6ck_h7rQzb_D`T+q81ng=YGqf9AF&4PB#1l zYg1#+M&bEHVuS6jnDIOkVjJIYA^Vn0{gEnsS8XO;=BD$@6a}}j za~}EaCZ&DGb4Ao#if6u~V;JOXSe!bn#M<8{l#rH=ImgSuJGQOz5ki#zZn@osnbVp_ z{PIPc;D5+Ta{1pj^?zU!isqCK=FSjChW%ehBI%{azkU0PF4Tyc zgNjZ;kFMm&<|PeH$>xL&+3ZH6-fT^$Q@yr;jbmG?QOT#;pw?yCvSPo&xWc$GvclNf znocV-=fws$gesD9E&FSG_Oq12=e=pm>pg@j{tHN+l*`$0(NNQoBtxlQd#6e>^ij$} zT-LA&O%Bx=CzPykm@K%q!ZQv*p`E=Q>#hLSB?Np0-=n*O7N<7+Am!7{3zBmcGm+p^ zMpHb63+dyl(n3QLN3UJb|M}+umnAsDOKF>NKbxtWdzYCn#b>0T25($<(7i zvcLTeCZ%9d;YdWuqPX&lN2MokUiEyHhb5D_x7@+~%Rhcl6*H3f z**_L?+~h8*SQ=UZbto`YZ$Q@buF1tmT?;XxxurF{6{Z;9zR?Rp?f2FvtcNXU#n@Y@rD{|w#?YTo5%f74 zW=-2jrHFKwI0MaXhM#4z7=!Z(bKrlq=6iBY&1M`e&|;j8Pjlz1ZTk}q+vI2u5`eqlsggD)&ZDQM@QY5s?F%$ox5gei+NIgRWmkiktRw_Xh(@t0GCigp{6#}hX6U}#StMCf z=-d*ej(%A^S~l!eDq?^b1$WHK>p#282S!NLrTL$RQvG;{N9#$U%nLhP^(AG?KQ^09 zO;ly|n+S4G7f@B!Vj29B9)_K?fA)}52$N_Im)9)Rq9#dkCeN*6BAvta-j*rxkyibQ zJkQYmV?;yu;1m{GFfj9A6AQwX8qyE}ZA97m0-Gi++8ZD=t=nbxM4xY@odlr> zG#1drP|#Dv z)yIt;HJ->5GxFKd*$UG)jwOI&3pb3QXY1CEPB;06pnnN^~KIVutenn>P*w!p=b_lua!I2F%x0{n4>)% z`vxI`sVC;Cjy(>9!7 z|MR)O<{p%9!fs@FFqMl9u0Y#0-P;#IQ~UHf4BGJHvt@K9|C*a*weBp;BJcT5l5Ai9 zsvEYq&o8@9XwYW4DH(o=9%Q^ooPkp;YTrer7O`Rn8r+6**E=-eywk z(|it5IvQaE6Zdm8K2wDWPVGlN(+oT@!3SUwj%sanM4l;9>e(ZNbcLQXgayDZhkCaD zW#bnNY{Pp0o=7yrY-K*)>P*H=COkw-5DMmn{}@mn^?k#f-o#s6@%n{T(~}Xb9Bm0NSW{zZHUy2}MMNeJG{ulZ2~>>ORn$rmRsJak+=-(0kcoXtR85jq zpqqx)npG_^U8mA)cnz1k;z0V_}VtB*f(Ze1S8pM2**Amw-js*XBr9 zs=cktcyE(;x{%Ob)1HA@?1cLos0fQqNhQ*J>R0^gk-%LGx<@cf7dbft1P zT0|FBvNMDgV_X9wKCKG$Sah^WZpsfOhx9R}(UYc5rhHwLa7tlCc*g4G>It`mk@ica>+Q+Ci_-Z9OT5hY}BHH~T9a*sq$xS52n9gYEKYNS4Csh&H z64l~wBH}TpBC@_O@@rnux7u>EBzF&!)@no7fo%3qx=Vg_WFXxSw+m@HYfnzytA z)Wy2=mb3zL+E<4*#*i-B0wt2>NtlR>yQtEt?lURd!_u0OtHAK`6@YD;FGw$xd?SICTexx` zko|cMrg`RHYQd8jPBOvK?E0*UZri?-(SA@ zuj2x-j~yi^GfaP~9H(Y|Hv-<`{`(GX**fl3Ise9fw3Qxt{eD%tbZM;U!Og|4p3IDVB3B%RaT1jY z$?$Q)nCf00w%hWZ944yU9=157mGjNZewDk-fXI9<+DeUL`m0AtwD)d8>D`kRMdhb} z@#7@Q0pvNFVYBar!6Yz9*`!^z9b@%Y8%V&$49(U6+oY7RZaEBSE=12}#!;RYMZfhO? zMb5H>PgjM?vO?CF7s;z0vbG+@w;s|tAN=n6eFZ#YSg^1Gc{2#zI`s5b`08|<7_qzX-*`K$tk6)m#!Tv)axrv5ZPSFP!B z%XoexDE`3u(8kgFW-W@{`shQwQjiSW7xTa+-UuB9XOYG7fpON47`Z5FLpfb8d0dq- zttrOMxvA`aJzkIp5rubJF+Bsm=m#T%7ozR)kJP(`I=MHUjaB(Kg$=S!D3bc=XJ1fp|O#?bU)BFPdY_5tT#YQjdtrbVp06S8Qhs+ zp2gn9PH;o;SA>g&#P#P3gx)X=3*2w&pdf_#fy8T5Ee|6a5zA5Ynj!46%i}a^veN;L z;m=^X0Re7n@TU{++*SykqhRZvuV!orz-@a4aPT*YcDq!0}m?H~u_DM29{JWtM* z{jUhY?ki?>P_i&Kt`SF&9W|?Vu2E1s5sq}V2YHLZ)_9j=1#Dso#c=irwO@%I$y-or zu8q#RuxU5zgBC?cjDREXXg|Ldz5xlt|v8m+7H`6+)jT4c*&IUM_C9Xm5<9? zqkW^WgPXn`9nwt&23f#bu z_LIVrLUVMb^2@N7p7ItGv|g)VP=@q7%dbw;;ik5UL5nB_4F`MOQ+t&yPKvRYv*}z{G!>M_gZ!iA&kXzL_~eRRozoelFeqaJOR8es$61!_OBtbkcvb?A&6VX7J_ajwZ%#Q^obF`VK^#3zOqQiAJhORSIPp?{xh zN^FBuX3r^+(23>_P)mhdq>*}{mWjd)3HMFt)7tJ4?uF_gAwmZe`n(T}(QDE-+eVGXx`lDuPf>K&SAMi6!levPC zU1j1P7q4b_9*G~ZN+sIJepJoyT|$CozokTf!U$_dIafM+kPYVnAK7VvgW}J{WMulO zDw17|A#nP%Oc^#Y@K2`Ko$r}s5 zWP4l>1uf=wO6Osbh8gmbZs6eOA<>$ATg|P9q>E-=Zau0O7S2IT^{*PJC;B3@#JiaC z#sT>G{jX{2pA&?5wM`&}l)7RS+_E?}Z3JOG#(gaw|4*_(V3;C)gnmh05gv;}N20)p zQRsuw%<27M?vfR9aY^1!yxWX%zDzT2+(0aykWX>gF*c#LETs?k~Ipjzt1SvNm$AJR2e$sSE~Kg7vq{1vz*BU)wk zsNa?4+UC@r>x%TvesGj31;z21WGm`BN&0k?i`71^yf2QtuZkRiAXXbA05r4>sSra* zx5XpCJ+ntNDP}3f67Ibqou(upG3bN$%(5Q|dwM|eH#BjWVFE8X7D1XG9c2(W-OeSv zt<8|8#)jpH{YL7EiZHf_sdJe=FHV_%e1qx>%~ly3U&V73ADR)fbA3Uq^oJ zzQTKu)qS!i2K%=SATPM?z&hJ%>Z=^E_I8;2vw10!a>rSWeW%E>R~~HZ*J0U9SLEcK z40?HGe?%GfO%Cg45ge44s6GMuGbiU8f?K(|c(LFE!j;%ZLXE|@nQkbi&YY>RX#GbF zC4BB>-Kf}~k+9oWg|OZ~;@>`cZUiYGKTx#xPD@|)Fl3*obQ%R0lWYAtzQ4wR))$xN zi~CqChs~0RJSyH{()rD$oo5U0@&`+UCBhd15UhkTM9=Qm49O3#V=zzSgg>n(?bW5l z|EwicRW@*EqKlCRHY^U(JrU_jVJ^1STwOjf{2RH8;hX6Pt@S5g=A}W<_xwJ z$T=Nf2^G#u_mnMyo$oCD;XS~kTS3l&7#r|2KyN3+2U1w`w6muST0ToA!B!DPQnR`< zYXD6epkmZM``3FtZ|tCFlKOH*U~3k_^-~;bwN)=6Y%_{v+0OGNn4t09Hv8x`i2Lah zxV^m*kv*Ho7H}McfmZ4^C_HyO5XJ?s=tFNP>OFdpK0)W~1C6tEllSNEOYu}q2|*;e zm(WsF+uW-o2BOvw-#@~s9J0g};7U8G!|@6Q6vGxe7CU~;k?UUVPH$3Sot2B!j3Io* z-SMFTthz|f2^GSlr9j$pdXTJ27%64A6Zp5zlfz}qC!pdtW-it!sLC1Xf-7}Vb zN%XrbXNr=h6{PA(<*gSc!6y_XIjEv=MEx^ZL!&3*XKY2icX0C4kqb&_<^-}e6Le8ar; zZf>NWXRMSYG)UP09GvFOzH3c&PYQ2cBiKkU5-L_HC^aak6%V?J zBm1gzg=D9zOyXO4960GLr1|UM>xKcQWgUAnh^v`UJZQ8;sJcg}EBc#0)poN@kC3-= zfv8G>n?Zq`azR%qR0lY_rDr4%*Fv3ho^=|ZBTmRhSD3CLF$+>9#LS2kj?hivcpzuG zipp@78=~b?S&s8$yfV&mAv=;oJ5ZBmqCkh`(h12%|6>lX)Oa?z<^;1NU%u3sQ}(UK zBAfQ!#l8~cntf-iU=qhFNM35ZFf$zk)W5QjtOO}X<+C07&oW`T z1P#w+I!}j{8Z#yv&L)rbwyRBlPhQJD1j)*dT&5Zt^k%v8*V-6d}WJdJ9NBPAAT-+)_PH@M1fAJby)us(5@SGa}F8Y-q0t!&Z zxQ)L_CCCrMChxs-eUQF-1O_eMB(rs;tN)sJ`w8|&xBrKK=WnCkiw)he!XNcgddu+v z_Dz|>rqG1u1fSgz62cNrAPQFL2s{z&j7~x9_X1pL@fS(3iQk$B9?q_j}Z)dgTo1cx%0>gK>@ze94Z36iS?Z(sSX|D z(vYYEq+04>&o9F56d zWG#HTa=P+0GdX1$>8dPq>8P_Ymp?&WUT;C>02Q|$QGb{Go*-OdJ|um!dT1t!zFsd@ z)@7#VvRwkwU-aF+B#1w(X-rOfrD%izo)-x5c26tvEo)Si{iX=T|95bMRydf9Q6;2+ zg-Ywgm%b_Kg1EBb2Rv@i2JV^H4`0o(l+(v`xBnDE#@SR*1nSNmF9wBJottHlsY9<0 zrPzKYIzPI>1Q;WTOY=j}cF}99BnZ!Vod!G8(}e%Abtf9zl*1Ff{D>1wSf$M0_2o<- zI*Qc~zDF$#c&FloUHOu)OConpnwWA}3vXt5S~*dk(>H5q!|bWYy7EAh)AK^_%lg*O z?HipOoqscg?YGSB%lQTsLqzey67x&qA%p$}c0FLd2rD^Yxrk*%G5MSTI~x$;5Z-43 z2HjjIokw8vid7NoJ~-(bjeH3_z@88yC@ieHF*Y!o%H`^%coX6w+);W0&y#BPF?5nFC|PkDHQF?5yNlvlKN(%C>i^j7+cdPGyf$9fU3=KKb#f1p_xBLAS8OZ%ha7ceVqrvPU5=zqX0bsbnQSm^L|Q8{wc#EQ$%5?U z{nFfQzn-4C!dAOtSe|S1oB4s1)p9=(l_p8GTg?w+=~r2o-;bbo&UPfqLHz*U7lq>@1OvsNrOZHjAex+wX-G(K6^2 zJbUs6k6hc_kXh`_X>p!O)Qr}l<8TInXOd{U;$&G4BNl0zas_*g1mH||golGFEQl|n z9oeI8*EyWm3HS%fGh*gnL91y__ay$r2F=4|e%6)u{3H=zhEVc`;ij_)vxGMhbHJ8?!Z8k0Ok!kL<8VP4X)g;mzx2 z*`eH6u*3hvPvU_F;ON?NXrIN#FUr95>lt93}}toXb8I8=>j;?}Dskkltq@ z5Lgn{VTPSH_ElDPOSg4K_e(c`N84y`-Qo{UV%g*k+QS#;2^e=|_v0%lDR$kF{$aA+ zv7Q&Ws%*aubmM8B!Ov8Xd6>jjEn!6#Ja+JRZ_jGR)pT_+oh2({gE_+ zmv;rt|JoAnuA>Li@z}!;dx|+!$2JTq>brrW@X<+I8=9EJLhSH>3Getcq^`Ug*lcSG zqFnFZ=(yn)DdJ)?Q&AlstsHd7(f~iBQ^UUiTrth=IE(eVXR`5*=-E&`I%MMxt^76p zA_Lz>Ja~t{yYyt=j?wqat*%~Kl5@j3x&iaT`kPu`2#UiUzIi|`yi*_=M@!FdA5JC% z+09b8ZKx(07VH(FgXkw~Ku&^D!cFJ~xG#O1>a`~ST2NEa6uHCbPqrN|f7cKymWTcK z1Zqh=AAcrX{>%LqO&L?$EM5Nv#sv{xbY8{V+&FZ`iWbfFOZOEhHPn$6dcA|1 zF1aOa5eB;^^|%Oj)ncBh$wk~RPgjS(`snHpXhJC^`{#M{%A%#H{U4e4NGb_4 z&l}kP!Jfp0Z7unm^W@8`6}2%yJG2H_j(g371w6gMLCY&3$#@7IQOcu z#JT0~%C;2exMm0oThEe$fW;_PRdb;-mgQ<6GW9SA_i9PE4mVObMTVxJ5qXQ_)ndD! zl_Bka$-BMMOJib6 z-Er6&R8kia_j7rPoAvChyPao3ZwFoPe!Q!_7~cSW^p;np?nfy_y8S{X++rCFeU_k~ za=k=pbFp8h+1)F!>|VhBnGJ+c-QL@4q_J`ST-SaTp&zohb~6|K5EP=cH)2y^&i20> zY8y+Cd#s`Rg8z!%0~5>7+mEpyDkT5-?GwH~eNzlb%v+2o^9+~55EgnN5EUb^DCk}X zf&YLQlqlpDr3fd@4UgBBO9sF7f+RE_AcTrS+=m(OU&7PU7@RNU#;E|8g-evKns`ED zTyqQ@MnfU7P!9FvuNQfQO1xGC^^}h{J187hrCe1)FV;{)HcLHL!3=8HP(n6~Pdpos zCtpD}TPcw*86Xt(^Do7rlRCY~^l*gDBCXl6op1!XaB6BoCUSOpzEprW`YlLe3wmmI+kXN3vYcF z9cd|094i1HZ!c!1B#*Uf^}fP7bJ0>JIR+7Ae=Aix8kc5LhdX~sc1|r8I#elF+;5+~ zb>u5esR(<2iFQ6WDX_02dwy4^J9?L9Vj^dvw`e#p?7{4>EH1nN1U4W_%l_&{6*weK z&;H#-KFbE93K5zR{rhX<+MnjPHqHtE%)VbGSd&LCEPoDY6XD90A2yha8(tLNrC*+m zkYIVkk!fz34kwUe43KTr@{oQ?b38l~8awo|ryJ}Zcd>p>$a6IW@rkRjcPJah6yK(O zNfqp(>Wdgm?&vSvQT_o??-#ngejcyixA3?;;V&hyxPo32{TQ1dW2DB%66lt+oNS)( zQ~5p|mVPO6Dj=e}=4>H#4lM~^D*=qq*#Q~o{pw)ZA(-muQxt0KGlRK2SnlvefqtI4 zrurgiOdruT>FM=!lKv9EADHtXnBL}@-EeDDOETaJdEMe}QB}xv)$p}$R>gyyQ(Q+4 zXm`;1YMYvj8=FjQtjFn+W?*cWoMDA5SaCz-Dzj4_4@@b96-o`e#n4+?$qF7-3I*iv6BLH z)~8CSb@;0}RhCAgp%SWG3pX`(6R^5jA%HX#pjShO98Bgf2*(}r9Oys=NQE#om6rlJ zhIS6C$N6V^_fZ0oOv)FVPwT3`4^RACI00~4B!f(#o)4XC&-=wN3Xi9Q(GBZr-34Mx z{kh8Cjr9;Tr0sd!eVC0)?iru`8Y2YsoQL8#wK!ztLLQu6asM|j{;p3tFU>LczzsS3 zwggm8@;y&HFYz&`R@1bE+e}bS!c7fiPMjSlyAS_VOiLN9L`#5@Zt^`7_c=Q|?^(LH zbe|#32gnyj$Z>${t$I*9n-AsH9+d!2#ofQD$HyE`f>l4b@5Y}&8@B%)mhf-DOdk{jcE1e5zP+4)^1kxExJ_ML{0PV- z?b_MiE>|0l)32XX?9W?{*B`IGDKtPUA-uog;o!tiPD?VAr_VH)C@sagvqsYHJyhr? zPQ=T3;~9r5e1OjIL2CI0;4pz7RBczi*@}zeB~oD#{ngYW)92D;Mz`32ponnCAX%MK6TmJY9&Z6sib(|{d7^(P!fk5eaCF1v zK6-~qbWyhe&7L*tIk3l#kWXbZ=WxnCwe6%L9|`w1gFB!ThBEB)i#pkmTwOC%W-=>Z z9I-}UZc)imj8D78lq3*{4Q&7)oK>HXFM|{2SAP6d>Np@vi08-NM?)aK3wNTfpgnQU z#IzXNQKYWo$cy-9;&^gzXdqgK&ZJJM!&!^Wn7!rLCEqX+)-E`W85Ajk#1Pel4cLAv z*d7Xa0vk!LGxd%=f%eWL1{Yzm!`K2>2%XLF&H~yFm6SMJG$J4%$7xW1!eJQZ4b%^T zrzoH$YSmO?4tw4aiNGB@nw|XKpzfXO3d|t+l1G%%LRw#88O2O7kTg?;iU{*p&awNa z{~w^;QOfOBAZ|u$CFU+7{voW%^32f!Go5ycBWIpsb@!;~RazC7cmyOlgR?mYk)Exa zHjwNz3&Lwu;u1DtGRW}Lam?vB%`$FEa&Yq*7@L{;zvP1KeJnbn3D-*MH?2EC+4df zeAZf~ii{2_hh5YatfNZJ;_141$ux7o;Z^1TC_<18+SvlG&fv6Jb^wd#x#2WuDz} zx39>{hn7`yP*0e**eft7F?^!yH2P zFg}#YH01f^*{8ZkjLsrwOn!0o6#o{V5-%ZEbl*?c=IQBaS@n0{NrvjNO8@bZgX41} ztps|d+@T8p+IjB6(dCJgla+%Llt(1B-jWWK7evYm`UtL{smgeA^_=qb@%agoO$~9E ziW5iejFO-5qO|uk!-B?6@X9c#FZ{gKN&sE4)vYOI^;#c@!xiSd%{*0|-BF4j^|4^q zfB97Frdyv)coOqg1dLn}6V%S^g0l^g$Oo9tgn^>+!&vA;U~Zw>2HI-{wfdm<7(52+ zS7^5a_-}QfBA)f7I&hmqVfR{I;A{|ahqCqvTQX`2I-b?~7#d9_iJs*oHBp}xKCsTg zEr1q42(u_pqP564kPK>H$b@d`A)Op`CP5BavkiKG;Wq5vk9NiwwRe~k(4>=w9g*Xv zYa^R`gflg-GfBryDPw65R&p618p$g9PqUAthZ{-%R+d;gAc*i7Zdez-ry6|7l$Dwd zr+4<4x3Zt*l4YWOnQ>AnSdD&rb+;OYA~y0y=2bPdj>9(Qfa<0ZRO8t$b_ zxo=6E+>7X93?brjH>;gJ)UAl|y_+!fR0oLWgxo{8#q^gZ-4}kc+uJN!so<5POMlx) zYkj-_nLCnI)${|XLyZXrDQbO6)%YWx z)X~v-g7<+bxs35EuGn7Cd5Cvtyy+I;61rVtdhXe;*y6#VkF~P7`NZ-V`y+j5&8#AujwhGn2Gc8a+i?8**T0Ht zRAA7(uRpmD2Ot0d-T$5g{n)Wu+u7O}+c-)8NNQI9$bFLJC1v{gpu81%@($Z~kf=DQ z)Z)rA2nxi6@ibs|WHQN=8LEmBAlNVKoGo(H8X(x9HH0`cZ>diKSpyKJ7=eV0Pg

^P=BApIHLQYBCZd2 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%0DVufENbm%cf~T>7RS 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 ZUmD67piDwRc?SO2faJ-069i(E{|6BJ-pK#} literal 0 HcmV?d00001 diff --git a/lib/randomcutforest-parkservices-4.1.0.jar b/lib/randomcutforest-parkservices-4.1.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..5810ff4203c95697ec0abf70c586b5e6a3df4ed1 GIT binary patch literal 108053 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%0DdEC4umQ5r_4HNfojSV2C1>3=m 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

MeMVHdroMEYOIo)%%h5x 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%0A4TH(id;)3=EF#qO-@3BimYM1E+7nD z_FiXA9Czjq=*Yp;)^evm4PWQf6ZE#L)I}I8$1`%dGq|X}rwPblI;}{bb!nj)gW@xM zWyj3DJ@xP-Tu;Px%6;8SUP0t>|CN5B&Sj4)B}RmiaKGtqdCz+o6U)5=Y8FGM_bM~D zVR}7_j1c_|B=Vb|qDYvuE2`<(H?`rlF}jcnjFMJpl_*h<8xK_Us|6(ZnuRM33V=>< z`ZuKPb84r?+PNP+{RD4lgXi3vdA5sKK;~2N(>Pe;3C1V`G7hR-EOH%q4U$Icuh$#A z;=jEC-tc%I1BusUbM=ucuGb+Yne4;D?7aL+( z_EX4U2&`r_&18UwZ1e;%*%O{}JA=};8h95;6BE-Lk*h-3GQR{9Z^I_ICik#eAai>H zAbEmV-EpSHF|*j?mw9!{3PRH!=g{Chiu?-gDBAAn?9fBTnR`|~`rr)NisOlW6@THv z^vTN`^v*m_eDV3`7gia818Qtc003Q7>d&u9?boWwTdf+>nmfohJPYez+ zf|>LKY(N+w3nl>~goXhrkN^oUeopHm0d5>hmr!q7AD&mWusj25)3>(NY>p5V^rLf8 zu(Z4|sjRjxUs^`Bwmef!&T-ty95-SL0=XN#x(hVg_IBkw^7_HE(Vdv3 z85mTzjt!Mm3Fxdwp^m2-Xr1rZ%Ds*UWf^bgo{F`NHaEb-TZ=}idjfB*@BvF$ds+(@ z0#xB^T8*~UH7DO@C(`<3rzH5oPfxWE$Q;64r6-TR1EpVCxoaCvwQgMRh!K1s3sGG( zEJt-42iDRePXpHJrI|s{SYdEAWr(DLM#nbL$!;PppP$T#uL5~X(8@DJ>o;!g|j8cX)kzQF3>>ykHyPB@I zg3#n)GVD@w=66-rCDvLCD+Hox#k??)iEy*IQ`-2rUhazF@l^=LaG+v_OtPln<=_ID z3^lbAFx{pFvDZoS)r!ZmUt0>fy2qh`?2CYm3>>6S7i&(-6OC=rU~F8^}}IMp_Bzt1t0AQ z$GU{}xO|0$(lnCz^X(^98{#JR_q1tN6hTw!7gco`I&zHlSXHU(fGiBCAjm5Lc&bXd zKu(< z@}|X5DeS?c9Y9{W9fC$RGkAs56^o=-CCdV->WB8)gfwBow z2Q&&*l|6_QSj)X4A0%f1)x-d;Fd(2q}R~zx^uwuYKat3OK&VG}7qDI&~;l!oP8iXPY z3ci$O-H!=zGghP6KS*xeG9n^rMYCF1-(>k#>%~jBvZ*r&TV;nyNj5A=jWBU;M)C8p zau060y2H7$PVi|Kv?uQ^p}q0QTJpP#6W?d&8q`m%sa5(QSKAdY-0Zo0YVlU##O3L1 zX4}LKs8pp@N9INs>rQSIv}r%A%@ekB?tNABNp)I4sN|xJM;ksR;>uD&Uoi@4IE1=^ zQOZlq_mIkX+G8qE-hTTn%sINVhCF?(+sq(QyFgXHLAPJb(y;h zcv=XLpohKWmYGtk3dyW(%3iEi(H^dA|wiTd+&wf{2IlA8+`-aFS1 zwcLwj@{;gB+noflokd%zn3IKaIZX+}JGWd&`Ed1r9^LjR1Y+;kA!fnq5G|v>o$Xy` z=1>{11C~IGGz`S8y8eLLglUyWxf>}rCHwI;$E~#M!r`YE?gmLunuFqh3v=59hwR6G zQ3}WDBZH8zM4Nw8+I{P5mkq^*GQcKznCa zeXGIU`e8T;_w<4RK+Kq9a3n9{?1M15?g(Xxyo7j8N*%FhQoT2nftYm!wTHl6 z$nYzc9_3f2EqKan;Ag^e=Bj3A$GFER52?^u$#gvM-BGA-FtPIQR;HLD0Vtf^yx1gd z8*s7xa>mb?z}{KXHgUq%WuB3_C5H+0EF`YT%E(@+=%0fAx(#a_>qCBdKSJ&-L4APb z98_aYZ|%?Jl*7rfR{F1YDKT?Ycb~3937!4Pdz4Gc+GIBnQ4?BkBlxJU$)B0qE##e! zP>TKN2Xzr5M8i-6CczNFIeT;cda15)z6)jG;e1Wb$@up=mNA%fcRYWHfa(OKu06KJ z)uAjYmg)7?QrRU;A~+7UN*NWbpgx1YCq{)b$LG|jg)Fhj&{!;0PO_ssv%iNvJG(ZQ zD@^CL=HPzNf^LVnu1*SHOrK)M)b+HfgnSZ3bp%0J&RyCubTmdzLeiE6dvCi@BHn~} zYZIOIKwmjJ-}VzM^s6gO0+|G2UZx;Yj=;mIg$|y|<`{|rG)9>Oxf6q)UIG|{?a(3S zPx6Q=JLf?zpd_u8%rD_F`N367Sl>B|&DVH*Bt4HR;XqNP$u0{{$WZl`HByL#@%Upx znGpSAiHvP{hs$8o%7x4F<77HaKt2!-8Yc#u?;m5{RQe&`I*}I3z>_cf_(u8iYUr%E z1y@UEVL+WDqzGfOwl}_RJ^C$6xHC9$O-%_^jY$?aFgQxyXnB?kf1@0?beH8&;wVpQ zq|iqupLGu$B@!An%b%H(xwV;;FzTe0HB*>Uu?|CuLOG6QC>e*$Hw<$z%m&du?c54< z(hjcEl0y1sP$kKuA`VcedH-9GhGrghN8%IzO2CHP2GfR2d&9;KSwxMjq@8BmIK8|b zVUCz$qIbh$4vMHAF|Chq|M0#W_J+Y_m3&=GuP!J13M-krvolpH$D^4;k#DAh>h zp9ojxR_r)Y1{lloFoT*gY!45^51UH0yOJ>uhhxV2kGl61Z5e>_s!%~`>;{1WmiD;q zq;FZPMiL9mc4mMwEPLs0B% znXK2OJgjX;*%png)aroCE3_C+8CI>jZo=2mu!+8J^U}a6?X7WzOfWB9pDr_}l(=k0 zY#7~BWAH<{B^QDjkfb(m{VuBwp-BT=d%2t_eFZH@QAV5Mz9m$b@4v%vu_tJo5--M+ zMf(a0P|!p=xYWpVWf>c=@q%#`w$&tmVOrw?Ttpjr>EZIrn=|#UiW=pe1oSKY%I1F7Olli#g(S^^2>mdj>8UT+9leY2j`*k^q6<5Vojh9E{Rf=LNN@f z2o+j%WxC=|2r1ER#T;?I!82AJYTG$_NQC8RFA(grV&LGfunXvHn6N#0_KH>tA`fnR zZ4MPw`Z#L_6CR4lcMKTmFJas%o^6N;h{lsQDnEwuk(3^c#V;_yc{}V0SYkR0GDq$# zA|%ji#UNtNyjL)ZaF{olw<%`UK{Ya}!qBwSov7J)egU#UhP2C&BM0 zxH&36*nF>a!Azm3SuqHtF%mN4cDFyL^NT?ARTNVly_RraRLi^_VWoHIKx9T0BS|k7 zNB$UmSO&*x>dSS{jWxZ7lZRN%xV?EZ5NjvGn<)^?5pNuo4S71Bln$?gzDkm1A zqsM}!SV5l)VzwHGz+;}tL>00wrai4dy|g0A6p3pQdx0iB)s`Yv^m8R%ID;1nizLVR z6qJ4((S#n+5k%OCLb%e+OlfPZZ=BN0Wo_9olFH7I1nafY`Vo`NSd@W<_z*9WhW!*$ zP@%h{@q3@3rLWCOIWYdB1I@{?bm3_YO%Y=HAldbZWPd&5RGy)LF_)A&M4r#Ygpvn* z4_Uu_u~tS?EPjdhqNmO$wI+TizHx2;1(EUSvrw}wfGf~s zMt+b5rgQsEUW{suBRPVeMNoUG2^(|o=Q@$hXGGdQ6YTx7;8uLUt$#x_?A?%DD@qBF zX$Z&pTOfSk{`j01DrwlUl*#U?Y8j_9u5iBle<3OjnEBvsv`%6Z@MmW~EzzlTZ0gWjsVF@0$2# z0}HDD+}cM!!4m6D9*NWfqDhpbeuHutgYnPuq|AqL2H5$2C;-{In`ydctM@Hlz85&P zt{(_;%%AocJdV$DFGHSWM&YJ07+*;(VfeceG?8>fJ}QU3m_NJI+-jJ&GozlOXR&fW zA=9LB_`-7w9!twdVH5JqdEnY^AkyFzv2QG1rGbln#A2TD3OUi#ZL2@PG`nZF7TrUP zk+$&4OESF@bpGU!-c(Jv;UT`Zezb(7+H0Xc`dQ+lFvis$W)e0)SrHpN#@2dV5_0 z78j&FvG3JvS#?vUk^IzP-}J^2s3bMdHwQq6*~m)H>m1r%M0?WjCK7D-f@MWm4jkBq zGJOji8w}JjA|C(}jig^Np#FOWoVeTYPiIM8!b8;togsy^fj4CV$hHn9)m^-3ZP79~1pFGG32FQ@5= zRZs4*Lmh_926{|CnXcZ(*ZxVOG-T!aNa;t1u~2HHA-myWyFFN=v<{32fV27GKJ0Rt z5My0gPHfu%pg><*#9edM!xi|8r%}W+HjiYz!o-GCE2OaRuu);2N(2sYM7OX+8R=2S z7J^Ng2;Pq@VUN*ZbH5h|*Hfa7GZctAFb}a%D$RpNX@u=xx+Q-0r18%%MP-r7UZQm^ z5uh_j)07g_5VAst*dSC#w5ucTj)ney#!wxPK-uel%6s>Z6_?RS)l^B1H zOPT;|Pojn`4h0_XgqRQ@y^>x3ROer1kAV3J3)xp4bk4?#_A9GkSWgO)f+JIbr)c=`nZ57^tgsSDL6ipPY~$53 zMhPxh$)S<-Q=%tc(fK1kPzj~Iv{HzE1`~=>OW%ETl1Urc8Vr0a(t@@8 zRS~*T%W$=As=l{n0w8Q$-{>w5M-D(40bH40nl35~Xc8g7!#?{%mA*&Ztq>S2pT=?9 zO$x>x^?jNv=?4`KCvV}l9k*AOCN^HU%u5j>+S}g3$MqU%ir2EZ%P4@C|D*AhH3RKV z0UHUx;qg#+*>PLW&cu05Jotq;3dsuzm4{*SJ2PJ&t?7tvLc2_5}kc1)rgN z8U^kL(*)ce`VJw-$B4P#YjORKYCG9>5V8EwYt8~qJ>_t(f`b_|;8F+kIHSLV$qW}$ zd^X%Jr8AP#|d?1@1S8x%T2N60lLDC z%ABFQCGsn!(EiA&80G5&s#r?t$1i7kVZQH!Yyr6kJdRy!vR^rIdn}oY_E_t_Tk;j^|2YgccN^p&`4GM>;XmXW)oCDDK zxnrCp-RK#&k1`EZa)L4aUbdE8*55J+6UkfLHc-R^zH0G-Zb~uA!pe`N3m5L#$0}W% zJQ#AZI|RmXr;y~hf9+>wqo;qjg%GIc67S%#T(r@B$^is+Y@RaV8TG1aHC?5C<&%A+ zHL46MTj~YHJfT-O4|uf{3Q1@l9Cttu#bB~N&H51SU>~z~`cG`D?&+5lYh~l?E2dOM ze6Cmn7p<_WcN+3xmm~YB4GutUvAW&27fBy98P`KoT7{u^DATKQV7epXxTA#FgQRFu zI@2B=UCw|@pKq>0SLM!hFBmgG$D@j=o3X#@zwrScDIQ1UH+2{(@y+-`l;Vtt%yhE{ zZitxXAY&tUq;kaQp)NRSz-OSXIamj5W?xrH%`~4RQk^&AgN9DZvUrH}ZOBj1OCd3a z?-^^UA1V%wt>_A^!0txqh!^GzpMaU_q@~(iXLaEdwX@w7M`pFl2G*_^qdA6kGt(Io zl|~K4ixf>oz0=Gee(ebJpI1e*F*mw^)y04~??Efsv@V9WmELMw=1c+eqeElKDf9q3 z_(vR%(>Ne+?U-mrDR&81)+21V)TIM)IdJL3YQmuQ2-^1sIgs&6Vcno2_drtm9Mz%d z4{$ibf1$7(615Yq+W}&}3uc8M>2uNz{CY;TDkh#GtLl@gM`|@decXL{rqqgfv&Zy_ zHo4bl#T!I@23g*%tw+lp;d-ZDK9JA~w>9WC8ch!ht0NC+4lWB`txXO7%7gH`Ab1u_Du89i7u4aHR^^bOU7`ifw(>q?2hsZYHb zu`_rTD8nRx$}3!msmPYJ2cHJTE3^Jg_)C}kv5%hO`6Df0#-}Y6P90}I1~EC55}{ZK zPvQ{*l#!Vx73Y}(T;y6D={E>HDQ@ZkiC2`S>Z>;>b9hcPE;kxV`GXP z(V~u=x2g)Go+QJb3^F5>v?Wh^4d%i3ywb_n-q#t@D9k6LVadG&{j|?gJgNrlfO3Vu zxg{k{Qy+vdKMqqL4l;;W)11r+Md1t+Pat%RHno244tqH&xL`y~DsF2fVX~tek4%fC z+dJ+RNSX@Oxbr2AMCLLl@dOs@f?DJ2apUr*2;x=cO>_bu8jvgJ!E4%4d!e)nV88aq z0zw!fv>1lX^lJZ%dQJB&k73s#+xG7*K#N$eEhh~}+Yw0+t5ow-niiC(L=i81@m1Zuq9!ztz2*3NC~cjZq2wcU27Te9_|+9i9~qLe+8-s z-?%mK#6DXFo!x@s)kln0qA8~!))Za!~jpMt?u zthrOiY#}3lZN&5_-@F&@uUw`JuR*IUJk2oZJB0}?W!z7*!y>?YT$DR7=Qc6DIZjGJ zCWPuzSP-b+!2#i4;)iR%fOlmDl@&RXXvPB97g#B{bo8?NOc zt*_W1$Z})hPUm;S66hh{6K+}-{-!gW2p0PiBH>W~L6c3KU=)-ZvN-^uc<&W0HvCF; zaLK~A-~-V~z8fQCM*;4NH!w-*gcIPnR>`=>m7f-rCd17WJ3k9_>*`&qJL(&D#CM~6 zuLatWB;~i==X>zI;DN0t@RRj*yHu#?I8mPI%v&+T=pe-?6@_vTV>*dUJTZtgQ{vUh zoT<2;QtuZz>7CQK3{0uSktB291K)o-U;qG?3_jJrT*?2~zW7xDG9t+NFSsFK-+yev^zo0w%ZE`y00%}U)*xXsmW_bx!;V#xtq>keRsmPEA-DWg7k&Cci| z!ROOfXe%Mw@SJ3DR(u}T=vOw?Jn)}V3 zsu{SXU0f1JlR_zc`iNm}UX140pB_sYwoy>%#1U1>a{bU-g-b~&mJdSdJacY{NgH$c zds#Fp926JiAcKj(auhIEOp-Z-?ktZ4#==G4Eua+>I0+Y{$d3^nZhh|YVD1^eyM4=+ zBoB?6G+aU1l32}f(PK$7x?pU7mzvuCn|+5VA;>=`j<^W}C%wS8;v7K1({mu-Gx>rRblv^^}7vNK-URWt>}5wStcFm3R8~ zh2qmVhkcd)Ro7aMtIPQ)j?|bz=EHtlQrgokW1!o91Kme$_ovU{dquhFZwXC?WkLLdmEzHir z*3Q_$$=ujcdvY2Ih!6o_w^NR>?nrnT0__ov7E~ZqM{l07NHz6_{Pg5(^W!Zf9f?u( z*3NSflbe(ghhw0eM*w?vLm_?ca<`#3Kw$*xCl+vr$33#)t-F17$dvM_iHHx%J{~Tr z{n6$!J{9L8I+KLfbz6VSq6268g{8Y#_et&ILH3fG@dD8JU+{I#n3uPbi; z-CrW`CmjF3jAQ3uY-DceWb0sP>)`MovCICgT1RgN_qVkseycJ4v4{h`GzVxSs4oKpOBx?d zCEhkjjfuMy2nN-t*uDGot$nKju&6mB1KH(?a6r~FQXPO&ts*BSJodLdIhyJZ%$}A) z=mEgBk!tq)CKdqO+t>p@E_*lYabR}c0XiNE=6fK$q!BnEPIQ)q!~i^L;qkESl$hO# zz`CVh72U%h_mXNKqMme0EY3_uB!D734!GGQVs;kj;ordVfe;iAA3jAr2jC0xr!G3% zgn()8pfY-~%yf}d7r=uYpM|1a0_yjG{e(C*bT60QCe=bjAg2U@2Sl7pOF*@KaS1*b zu$Vm7GXn17d=E_K;V6NA6xd@Swqk&!GUV2abAE0?X-O)gnSsv{GhFNs?gobYXITb@ zFce4Ryr-%o@T9(`0;kWrfOW?cV7(QO;)dD3NOc3Y_9#ZVi!VSJtzPkF0MkXC0x)f$ zIOY-yBZ;v>lw zkTX4~%tO!j*aI5<3curBbhw=VOAERg}{~s6bLZ<|6kS3 zh#2;R%{w3liqNMifE^@C7-2%lTX=1T&Ql<~j(y-7;eZ2u)OP@47(2k571)MnU@!qfZ!rdjEui5_1^_$0 BfrbD8 literal 0 HcmV?d00001 diff --git a/lib/randomcutforest-serialization-4.1.0.jar b/lib/randomcutforest-serialization-4.1.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..6d3ceaccd6a3670d3f2ff4aa1694f7a1ab293921 GIT binary patch literal 20885 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 literal 0 HcmV?d00001 diff --git a/src/main/java/org/opensearch/ad/AnomalyDetectorRunner.java b/src/main/java/org/opensearch/ad/AnomalyDetectorRunner.java index fa16ed2c7..a68645396 100644 --- a/src/main/java/org/opensearch/ad/AnomalyDetectorRunner.java +++ b/src/main/java/org/opensearch/ad/AnomalyDetectorRunner.java @@ -102,8 +102,7 @@ public void executeDetector( startTime.toEpochMilli(), endTime.toEpochMilli(), ActionListener.wrap(features -> { - List entityResults = modelManager - .getPreviewResults(features, detector.getShingleSize(), detector.getTimeDecay()); + List entityResults = modelManager.getPreviewResults(features, detector); List sampledEntityResults = sample( parsePreviewResult(detector, features, entityResults, entity), maxPreviewResults @@ -116,8 +115,7 @@ public void executeDetector( } else { featureManager.getPreviewFeatures(detector, startTime.toEpochMilli(), endTime.toEpochMilli(), ActionListener.wrap(features -> { try { - List results = modelManager - .getPreviewResults(features, detector.getShingleSize(), detector.getTimeDecay()); + List results = modelManager.getPreviewResults(features, detector); listener.onResponse(sample(parsePreviewResult(detector, features, results, null), maxPreviewResults)); } catch (Exception e) { onFailure(e, listener, detector.getId()); diff --git a/src/main/java/org/opensearch/ad/ml/ADColdStart.java b/src/main/java/org/opensearch/ad/ml/ADColdStart.java index d2db383f8..b4f329efa 100644 --- a/src/main/java/org/opensearch/ad/ml/ADColdStart.java +++ b/src/main/java/org/opensearch/ad/ml/ADColdStart.java @@ -13,6 +13,7 @@ import java.time.Clock; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import org.apache.logging.log4j.LogManager; @@ -171,7 +172,7 @@ protected List trainModelFromDataSegments( double[] firstPoint = pointSamples.get(0).getValueList(); if (firstPoint == null || firstPoint.length == 0) { - logger.info("Return early since data points must not be empty."); + logger.info("Return early since the first data point must not be empty."); return null; } @@ -216,6 +217,31 @@ protected List trainModelFromDataSegments( } AnomalyDetector detector = (AnomalyDetector) config; + applyRule(rcfBuilder, detector); + + // use build instead of new TRCF(Builder) because build method did extra validation and initialization + ThresholdedRandomCutForest trcf = rcfBuilder.build(); + + List imputed = new ArrayList<>(); + for (int i = 0; i < pointSamples.size(); i++) { + Sample dataSample = pointSamples.get(i); + double[] dataValue = dataSample.getValueList(); + // We don't keep missing values during cold start as the actual data may not be reconstructed during the early stage. + trcf.process(dataValue, dataSample.getDataEndTime().getEpochSecond()); + imputed.add(new Sample(dataValue, dataSample.getDataStartTime(), dataSample.getDataEndTime())); + } + + entityState.setModel(trcf); + + entityState.setLastUsedTime(clock.instant()); + + // save to checkpoint + checkpointWriteWorker.write(entityState, true, RequestPriority.MEDIUM); + + return pointSamples; + } + + public static void applyRule(ThresholdedRandomCutForest.Builder rcfBuilder, AnomalyDetector detector) { ThresholdArrays thresholdArrays = IgnoreSimilarExtractor.processDetectorRules(detector); if (thresholdArrays != null) { @@ -235,23 +261,5 @@ protected List trainModelFromDataSegments( rcfBuilder.ignoreNearExpectedFromBelowByRatio(thresholdArrays.ignoreSimilarFromBelowByRatio); } } - - // use build instead of new TRCF(Builder) because build method did extra validation and initialization - ThresholdedRandomCutForest trcf = rcfBuilder.build(); - - for (int i = 0; i < pointSamples.size(); i++) { - Sample dataSample = pointSamples.get(i); - double[] dataValue = dataSample.getValueList(); - trcf.process(dataValue, dataSample.getDataEndTime().getEpochSecond()); - } - - entityState.setModel(trcf); - - entityState.setLastUsedTime(clock.instant()); - - // save to checkpoint - checkpointWriteWorker.write(entityState, true, RequestPriority.MEDIUM); - - return pointSamples; } } diff --git a/src/main/java/org/opensearch/ad/ml/ADInferencer.java b/src/main/java/org/opensearch/ad/ml/ADInferencer.java new file mode 100644 index 000000000..26e6c032f --- /dev/null +++ b/src/main/java/org/opensearch/ad/ml/ADInferencer.java @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.ml; + +import static org.opensearch.timeseries.TimeSeriesAnalyticsPlugin.AD_THREAD_POOL_NAME; + +import org.opensearch.ad.caching.ADCacheProvider; +import org.opensearch.ad.caching.ADPriorityCache; +import org.opensearch.ad.indices.ADIndex; +import org.opensearch.ad.indices.ADIndexManagement; +import org.opensearch.ad.model.AnomalyResult; +import org.opensearch.ad.ratelimit.ADCheckpointWriteWorker; +import org.opensearch.ad.ratelimit.ADColdStartWorker; +import org.opensearch.ad.ratelimit.ADSaveResultStrategy; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.timeseries.ml.Inferencer; +import org.opensearch.timeseries.stats.StatNames; +import org.opensearch.timeseries.stats.Stats; + +import com.amazon.randomcutforest.parkservices.ThresholdedRandomCutForest; + +public class ADInferencer extends + Inferencer { + + public ADInferencer( + ADModelManager modelManager, + Stats stats, + ADCheckpointDao checkpointDao, + ADColdStartWorker coldStartWorker, + ADSaveResultStrategy resultWriteWorker, + ADCacheProvider cache, + ThreadPool threadPool + ) { + super( + modelManager, + stats, + StatNames.AD_MODEL_CORRUTPION_COUNT.getName(), + checkpointDao, + coldStartWorker, + resultWriteWorker, + cache, + threadPool, + AD_THREAD_POOL_NAME + ); + } + +} diff --git a/src/main/java/org/opensearch/ad/ml/ADModelManager.java b/src/main/java/org/opensearch/ad/ml/ADModelManager.java index aa553a7bf..354b02557 100644 --- a/src/main/java/org/opensearch/ad/ml/ADModelManager.java +++ b/src/main/java/org/opensearch/ad/ml/ADModelManager.java @@ -33,7 +33,9 @@ import org.opensearch.ad.constant.ADCommonMessages; import org.opensearch.ad.indices.ADIndex; import org.opensearch.ad.indices.ADIndexManagement; +import org.opensearch.ad.model.AnomalyDetector; import org.opensearch.ad.model.AnomalyResult; +import org.opensearch.ad.model.ImputedFeatureResult; import org.opensearch.ad.ratelimit.ADCheckpointWriteWorker; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Setting; @@ -47,13 +49,17 @@ import org.opensearch.timeseries.feature.FeatureManager; import org.opensearch.timeseries.feature.Features; import org.opensearch.timeseries.ml.MemoryAwareConcurrentHashmap; +import org.opensearch.timeseries.ml.ModelColdStart; import org.opensearch.timeseries.ml.ModelManager; import org.opensearch.timeseries.ml.ModelState; import org.opensearch.timeseries.ml.SingleStreamModelIdMapper; +import org.opensearch.timeseries.model.Config; import org.opensearch.timeseries.settings.TimeSeriesSettings; import org.opensearch.timeseries.util.DateUtils; +import org.opensearch.timeseries.util.ModelUtil; import com.amazon.randomcutforest.RandomCutForest; +import com.amazon.randomcutforest.config.ForestMode; import com.amazon.randomcutforest.config.Precision; import com.amazon.randomcutforest.config.TransformMethod; import com.amazon.randomcutforest.parkservices.AnomalyDescriptor; @@ -137,7 +143,11 @@ public ADModelManager( this.initialAcceptFraction = rcfNumMinSamples * 1.0d / rcfNumSamplesInTree; } + @Deprecated /** + * used in RCFResultTransportAction to handle request from old node request. + * In the new logic, we switch to SingleStreamResultAction. + * * Returns to listener the RCF anomaly result using the specified model. * * @param detectorId ID of the detector @@ -194,7 +204,9 @@ private void getTRcfResult( result.getExpectedValuesList(), result.getLikelihoodOfValues(), result.getThreshold(), - result.getNumberOfTrees() + result.getNumberOfTrees(), + point, + null ) ); } catch (Exception e) { @@ -513,11 +525,10 @@ private void maintenanceForIterator( * Returns computed anomaly results for preview data points. * * @param features features of preview data points - * @param shingleSize model shingle size - * @return rcfTimeDecay rcf time decay + * @param detector Anomaly detector * @throws IllegalArgumentException when preview data points are not valid */ - public List getPreviewResults(Features features, int shingleSize, double rcfTimeDecay) { + public List getPreviewResults(Features features, AnomalyDetector detector) { double[][] dataPoints = features.getUnprocessedFeatures(); if (dataPoints.length < minPreviewSize) { throw new IllegalArgumentException("Insufficient data for preview results. Minimum required: " + minPreviewSize); @@ -528,11 +539,15 @@ public List getPreviewResults(Features features, int shingle String.format(Locale.ROOT, "time range size %d does not match data points size %d", timeRanges.size(), dataPoints.length) ); } + + int shingleSize = detector.getShingleSize(); + double rcfTimeDecay = detector.getTimeDecay(); + // Train RCF models and collect non-zero scores int baseDimension = dataPoints[0].length; // speed is important in preview. We don't want cx to wait too long. // thus use the default value of boundingBoxCacheFraction = 1 - ThresholdedRandomCutForest trcf = ThresholdedRandomCutForest + ThresholdedRandomCutForest.Builder trcfBuilder = ThresholdedRandomCutForest .builder() .randomSeed(0L) .dimensions(baseDimension * shingleSize) @@ -550,27 +565,32 @@ public List getPreviewResults(Features features, int shingle .transformMethod(TransformMethod.NORMALIZE) .alertOnce(true) .autoAdjust(true) - .internalShinglingEnabled(true) - .build(); + .internalShinglingEnabled(true); + + if (shingleSize > 1) { + trcfBuilder.forestMode(ForestMode.STREAMING_IMPUTE); + trcfBuilder = ModelColdStart.applyImputationMethod(detector, trcfBuilder); + } else { + // imputation with shingle size 1 is not meaningful + trcfBuilder.forestMode(ForestMode.STANDARD); + } + + ADColdStart.applyRule(trcfBuilder, detector); + + ThresholdedRandomCutForest trcf = trcfBuilder.build(); return IntStream.range(0, dataPoints.length).mapToObj(i -> { + // we don't have missing values in preview data. We have already filtered them out. double[] point = dataPoints[i]; // Get the data end epoch milliseconds corresponding to this index and convert it to seconds long timestampSecs = timeRanges.get(i).getValue() / 1000; AnomalyDescriptor descriptor = trcf.process(point, timestampSecs); // Use the timestamp here - return new ThresholdingResult( - descriptor.getAnomalyGrade(), - descriptor.getDataConfidence(), - descriptor.getRCFScore(), - descriptor.getTotalUpdates(), - descriptor.getRelativeIndex(), - normalizeAttribution(trcf.getForest(), descriptor.getRelevantAttribution()), - descriptor.getPastValues(), - descriptor.getExpectedValuesList(), - descriptor.getLikelihoodOfValues(), - descriptor.getThreshold(), - rcfNumTrees - ); + + if (descriptor != null) { + return toResult(trcf.getForest(), descriptor, point, false, detector); + } + + return null; }).collect(Collectors.toList()); } @@ -623,7 +643,15 @@ protected ThresholdingResult createEmptyResult() { } @Override - protected ThresholdingResult toResult(RandomCutForest rcf, AnomalyDescriptor anomalyDescriptor) { + protected ThresholdingResult toResult( + RandomCutForest rcf, + AnomalyDescriptor anomalyDescriptor, + double[] point, + boolean isImputed, + Config config + ) { + ImputedFeatureResult result = ModelUtil.calculateImputedFeatures(anomalyDescriptor, point, isImputed, config); + return new ThresholdingResult( anomalyDescriptor.getAnomalyGrade(), anomalyDescriptor.getDataConfidence(), @@ -635,7 +663,9 @@ protected ThresholdingResult toResult(RandomCutForest rcf, AnomalyDescriptor ano anomalyDescriptor.getExpectedValuesList(), anomalyDescriptor.getLikelihoodOfValues(), anomalyDescriptor.getThreshold(), - rcfNumTrees + rcfNumTrees, + result.getActual(), + result.getIsFeatureImputed() ); } } diff --git a/src/main/java/org/opensearch/ad/ml/ThresholdingResult.java b/src/main/java/org/opensearch/ad/ml/ThresholdingResult.java index a2da03f51..f4b7c1fb0 100644 --- a/src/main/java/org/opensearch/ad/ml/ThresholdingResult.java +++ b/src/main/java/org/opensearch/ad/ml/ThresholdingResult.java @@ -136,6 +136,11 @@ public class ThresholdingResult extends IntermediateResult { protected final double confidence; + // actual or imputed data + private double[] currentData; + + private boolean[] featureImputed; + /** * Constructor for default empty value or backward compatibility. * In terms of bwc, when an old node sends request for threshold results, @@ -148,7 +153,7 @@ public class ThresholdingResult extends IntermediateResult { * saving or not. */ public ThresholdingResult(double grade, double confidence, double rcfScore) { - this(grade, confidence, rcfScore, 0, 0, null, null, null, null, 0, 0); + this(grade, confidence, rcfScore, 0, 0, null, null, null, null, 0, 0, null, null); } public ThresholdingResult( @@ -162,7 +167,9 @@ public ThresholdingResult( double[][] expectedValuesList, double[] likelihoodOfValues, double threshold, - int forestSize + int forestSize, + double[] currentData, + boolean[] featureImputed ) { super(totalUpdates, rcfScore); this.confidence = confidence; @@ -175,6 +182,9 @@ public ThresholdingResult( this.likelihoodOfValues = likelihoodOfValues; this.threshold = threshold; this.forestSize = forestSize; + this.currentData = currentData; + this.featureImputed = featureImputed; + } /** @@ -223,12 +233,22 @@ public int getForestSize() { return forestSize; } + public double[] getCurrentData() { + return currentData; + } + + public boolean isFeatureImputed(int i) { + return featureImputed[i]; + } + @Override public boolean equals(Object o) { - if (!super.equals(o)) + if (!super.equals(o)) { return false; - if (getClass() != o.getClass()) + } + if (getClass() != o.getClass()) { return false; + } ThresholdingResult that = (ThresholdingResult) o; return Double.doubleToLongBits(confidence) == Double.doubleToLongBits(that.confidence) && Double.doubleToLongBits(this.grade) == Double.doubleToLongBits(that.grade) @@ -238,7 +258,9 @@ public boolean equals(Object o) { && Arrays.deepEquals(expectedValuesList, that.expectedValuesList) && Arrays.equals(likelihoodOfValues, that.likelihoodOfValues) && Double.doubleToLongBits(threshold) == Double.doubleToLongBits(that.threshold) - && forestSize == that.forestSize; + && forestSize == that.forestSize + && Arrays.equals(currentData, that.currentData) + && Arrays.equals(featureImputed, that.featureImputed); } @Override @@ -254,7 +276,9 @@ public int hashCode() { Arrays.deepHashCode(expectedValuesList), Arrays.hashCode(likelihoodOfValues), threshold, - forestSize + forestSize, + Arrays.hashCode(currentData), + Arrays.hashCode(featureImputed) ); } @@ -271,6 +295,8 @@ public String toString() { .append("likelihoodOfValues", Arrays.toString(likelihoodOfValues)) .append("threshold", threshold) .append("forestSize", forestSize) + .append("currentData", Arrays.toString(currentData)) + .append("featureImputed", Arrays.toString(featureImputed)) .toString(); } @@ -330,7 +356,9 @@ public List toIndexableResults( pastValues, expectedValuesList, likelihoodOfValues, - threshold + threshold, + currentData, + featureImputed ) ); } diff --git a/src/main/java/org/opensearch/ad/model/AnomalyResult.java b/src/main/java/org/opensearch/ad/model/AnomalyResult.java index bdfe4eb3c..868317bab 100644 --- a/src/main/java/org/opensearch/ad/model/AnomalyResult.java +++ b/src/main/java/org/opensearch/ad/model/AnomalyResult.java @@ -40,6 +40,7 @@ import org.opensearch.timeseries.model.Entity; import org.opensearch.timeseries.model.FeatureData; import org.opensearch.timeseries.model.IndexableResult; +import org.opensearch.timeseries.util.DataUtil; import org.opensearch.timeseries.util.ParseUtils; import com.google.common.base.Objects; @@ -66,6 +67,7 @@ public class AnomalyResult extends IndexableResult { public static final String THRESHOLD_FIELD = "threshold"; // unused currently. added since odfe 1.4 public static final String IS_ANOMALY_FIELD = "is_anomaly"; + public static final String FEATURE_IMPUTED = "feature_imputed"; private final Double anomalyScore; private final Double anomalyGrade; @@ -193,6 +195,9 @@ So if we detect anomaly late, we get the baseDimension values from the past (cur */ private final String modelId; + // whether a feature value is imputed or not + private List featureImputed; + // used when indexing exception or error or an empty result public AnomalyResult( String detectorId, @@ -228,6 +233,7 @@ public AnomalyResult( null, null, null, + null, null ); } @@ -252,7 +258,8 @@ public AnomalyResult( List relevantAttribution, List pastValues, List expectedValuesList, - Double threshold + Double threshold, + List featureImputed ) { super( configId, @@ -276,6 +283,7 @@ public AnomalyResult( this.pastValues = pastValues; this.expectedValuesList = expectedValuesList; this.threshold = threshold; + this.featureImputed = featureImputed; } /** @@ -302,6 +310,8 @@ public AnomalyResult( * @param expectedValuesList Expected values * @param likelihoodOfValues Likelihood of the expected values * @param threshold Current threshold + * @param currentData imputed data if any + * @param featureImputed whether feature is imputed or not * @return the converted AnomalyResult instance */ public static AnomalyResult fromRawTRCFResult( @@ -326,14 +336,17 @@ public static AnomalyResult fromRawTRCFResult( double[] pastValues, double[][] expectedValuesList, double[] likelihoodOfValues, - Double threshold + Double threshold, + double[] currentData, + boolean[] featureImputed ) { List convertedRelevantAttribution = null; List convertedPastValuesList = null; List convertedExpectedValues = null; + int featureSize = featureData == null ? 0 : featureData.size(); + if (grade > 0) { - int featureSize = featureData.size(); if (relevantAttribution != null) { if (relevantAttribution.length == featureSize) { convertedRelevantAttribution = new ArrayList<>(featureSize); @@ -352,11 +365,21 @@ public static AnomalyResult fromRawTRCFResult( } } - if (pastValues != null) { + // it is possible pastValues is not null but relativeIndex is null. It would happen when the imputation ends in a continuous + // area. + if (pastValues != null && relativeIndex != null && relativeIndex < 0) { if (pastValues.length == featureSize) { convertedPastValuesList = new ArrayList<>(featureSize); for (int j = 0; j < featureSize; j++) { - convertedPastValuesList.add(new DataByFeatureId(featureData.get(j).getFeatureId(), pastValues[j])); + // When impute missing values, the first imputation will generate NaN value, but OS's double type won't accept NaN + // value. + // So we will break out of the loop and not save a past value. + if (Double.isNaN(pastValues[j]) || Double.isInfinite(pastValues[j])) { + convertedPastValuesList = null; + break; + } else { + convertedPastValuesList.add(new DataByFeatureId(featureData.get(j).getFeatureId(), pastValues[j])); + } } } else { LOG @@ -404,6 +427,20 @@ public static AnomalyResult fromRawTRCFResult( } } + List featureImputedList = new ArrayList<>(); + if (featureImputed != null) { + for (int i = 0; i < featureImputed.length; i++) { + FeatureData featureItem = featureData.get(i); + // round to 3rd decimal places + if (featureImputed[i]) { + featureItem.setData(DataUtil.roundDouble(currentData[i], 3)); + } else { + featureItem.setData(DataUtil.roundDouble(featureItem.getData(), 3)); + } + featureImputedList.add(new FeatureImputed(featureItem.getFeatureId(), featureImputed[i])); + } + } + return new AnomalyResult( detectorId, taskId, @@ -420,13 +457,14 @@ public static AnomalyResult fromRawTRCFResult( user, schemaVersion, modelId, - (relativeIndex == null || dataStartTime == null) + (relativeIndex == null || dataStartTime == null || relativeIndex >= 0) ? null : Instant.ofEpochMilli(dataStartTime.toEpochMilli() + relativeIndex * intervalMillis), convertedRelevantAttribution, convertedPastValuesList, convertedExpectedValues, - threshold + threshold, + featureImputedList ); } @@ -470,6 +508,16 @@ public AnomalyResult(StreamInput input) throws IOException { } this.threshold = input.readOptionalDouble(); + + int inputLength = input.readVInt(); + if (inputLength > 0) { + this.featureImputed = new ArrayList<>(); + for (int i = 0; i < inputLength; i++) { + featureImputed.add(new FeatureImputed(input)); + } + } else { + this.featureImputed = null; + } } @Override @@ -545,6 +593,9 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws if (threshold != null && !threshold.isNaN()) { xContentBuilder.field(THRESHOLD_FIELD, threshold); } + if (featureImputed != null && featureImputed.size() > 0) { + xContentBuilder.array(FEATURE_IMPUTED, featureImputed.toArray()); + } return xContentBuilder.endObject(); } @@ -569,6 +620,7 @@ public static AnomalyResult parse(XContentParser parser) throws IOException { List pastValues = new ArrayList<>(); List expectedValues = new ArrayList<>(); Double threshold = null; + List featureImputed = null; ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.currentToken(), parser); while (parser.nextToken() != XContentParser.Token.END_OBJECT) { @@ -648,6 +700,13 @@ public static AnomalyResult parse(XContentParser parser) throws IOException { case THRESHOLD_FIELD: threshold = parser.doubleValue(); break; + case FEATURE_IMPUTED: + featureImputed = new ArrayList<>(); + ensureExpectedToken(XContentParser.Token.START_ARRAY, parser.currentToken(), parser); + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + featureImputed.add(FeatureImputed.parse(parser)); + } + break; default: parser.skipChildren(); break; @@ -674,17 +733,20 @@ public static AnomalyResult parse(XContentParser parser) throws IOException { relavantAttribution, pastValues, expectedValues, - threshold + threshold, + featureImputed ); } @Generated @Override public boolean equals(Object o) { - if (!super.equals(o)) + if (!super.equals(o)) { return false; - if (getClass() != o.getClass()) + } + if (getClass() != o.getClass()) { return false; + } AnomalyResult that = (AnomalyResult) o; return Objects.equal(modelId, that.modelId) && Objects.equal(confidence, that.confidence) @@ -694,7 +756,8 @@ public boolean equals(Object o) { && Objects.equal(relevantAttribution, that.relevantAttribution) && Objects.equal(pastValues, that.pastValues) && Objects.equal(expectedValuesList, that.expectedValuesList) - && Objects.equal(threshold, that.threshold); + && Objects.equal(threshold, that.threshold) + && Objects.equal(featureImputed, that.featureImputed); } @Generated @@ -712,7 +775,8 @@ public int hashCode() { relevantAttribution, pastValues, expectedValuesList, - threshold + threshold, + featureImputed ); return result; } @@ -732,6 +796,7 @@ public String toString() { .append("pastValues", pastValues) .append("expectedValuesList", StringUtils.join(expectedValuesList, "|")) .append("threshold", threshold) + .append("featureImputed", featureImputed) .toString(); } @@ -775,6 +840,10 @@ public String getModelId() { return modelId; } + public List getFeatureImputed() { + return featureImputed; + } + /** * Anomaly result index consists of overwhelmingly (99.5%) zero-grade non-error documents. * This function exclude the majority case. @@ -825,6 +894,15 @@ public void writeTo(StreamOutput out) throws IOException { } out.writeOptionalDouble(threshold); + + if (featureImputed != null) { + out.writeVInt(featureImputed.size()); + for (FeatureImputed imputed : featureImputed) { + imputed.writeTo(out); + } + } else { + out.writeVInt(0); + } } public static AnomalyResult getDummyResult() { diff --git a/src/main/java/org/opensearch/ad/model/FeatureImputed.java b/src/main/java/org/opensearch/ad/model/FeatureImputed.java new file mode 100644 index 000000000..c6c7aeada --- /dev/null +++ b/src/main/java/org/opensearch/ad/model/FeatureImputed.java @@ -0,0 +1,105 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.model; + +import static org.opensearch.core.xcontent.XContentParserUtils.ensureExpectedToken; + +import java.io.IOException; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +import com.google.common.base.Objects; + +/** + * Feature imputed and its Id + * + */ +public class FeatureImputed implements ToXContentObject, Writeable { + + public static final String FEATURE_ID_FIELD = "feature_id"; + public static final String IMPUTED_FIELD = "imputed"; + + protected String featureId; + protected Boolean imputed; + + public FeatureImputed(String featureId, Boolean imputed) { + this.featureId = featureId; + this.imputed = imputed; + } + + public FeatureImputed(StreamInput input) throws IOException { + this.featureId = input.readString(); + this.imputed = input.readBoolean(); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + XContentBuilder xContentBuilder = builder.startObject().field(FEATURE_ID_FIELD, featureId).field(IMPUTED_FIELD, imputed); + return xContentBuilder.endObject(); + } + + public static FeatureImputed parse(XContentParser parser) throws IOException { + String featureId = null; + Boolean imputed = null; + + ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.currentToken(), parser); + while (parser.nextToken() != XContentParser.Token.END_OBJECT) { + String fieldName = parser.currentName(); + parser.nextToken(); + + switch (fieldName) { + case FEATURE_ID_FIELD: + featureId = parser.text(); + break; + case IMPUTED_FIELD: + imputed = parser.booleanValue(); + break; + default: + // the unknown field and it's children should be ignored + parser.skipChildren(); + break; + } + } + return new FeatureImputed(featureId, imputed); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FeatureImputed that = (FeatureImputed) o; + return Objects.equal(getFeatureId(), that.getFeatureId()) && Objects.equal(isImputed(), that.isImputed()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getFeatureId(), isImputed()); + } + + public String getFeatureId() { + return featureId; + } + + public Boolean isImputed() { + return imputed; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(featureId); + out.writeBoolean(imputed); + } + +} diff --git a/src/main/java/org/opensearch/ad/model/ImputedFeatureResult.java b/src/main/java/org/opensearch/ad/model/ImputedFeatureResult.java new file mode 100644 index 000000000..5913be4e5 --- /dev/null +++ b/src/main/java/org/opensearch/ad/model/ImputedFeatureResult.java @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.model; + +public class ImputedFeatureResult { + boolean[] isFeatureImputed; + double[] actual; + + public ImputedFeatureResult(boolean[] isFeatureImputed, double[] actual) { + this.isFeatureImputed = isFeatureImputed; + this.actual = actual; + } + + public boolean[] getIsFeatureImputed() { + return isFeatureImputed; + } + + public double[] getActual() { + return actual; + } +} diff --git a/src/main/java/org/opensearch/ad/ratelimit/ADCheckpointReadWorker.java b/src/main/java/org/opensearch/ad/ratelimit/ADCheckpointReadWorker.java index 40ea61ae6..7a0fe75c8 100644 --- a/src/main/java/org/opensearch/ad/ratelimit/ADCheckpointReadWorker.java +++ b/src/main/java/org/opensearch/ad/ratelimit/ADCheckpointReadWorker.java @@ -24,10 +24,10 @@ import org.opensearch.ad.indices.ADIndexManagement; import org.opensearch.ad.ml.ADCheckpointDao; import org.opensearch.ad.ml.ADColdStart; +import org.opensearch.ad.ml.ADInferencer; import org.opensearch.ad.ml.ADModelManager; import org.opensearch.ad.ml.ThresholdingResult; import org.opensearch.ad.model.AnomalyResult; -import org.opensearch.ad.stats.ADStats; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.inject.Provider; import org.opensearch.common.settings.Setting; @@ -38,7 +38,6 @@ import org.opensearch.timeseries.TimeSeriesAnalyticsPlugin; import org.opensearch.timeseries.breaker.CircuitBreakerService; import org.opensearch.timeseries.ratelimit.CheckpointReadWorker; -import org.opensearch.timeseries.stats.StatNames; import com.amazon.randomcutforest.parkservices.ThresholdedRandomCutForest; @@ -55,7 +54,7 @@ * */ public class ADCheckpointReadWorker extends - CheckpointReadWorker { + CheckpointReadWorker { public static final String WORKER_NAME = "ad-checkpoint-read"; public ADCheckpointReadWorker( @@ -77,12 +76,10 @@ public ADCheckpointReadWorker( ADCheckpointDao checkpointDao, ADColdStartWorker entityColdStartQueue, NodeStateManager stateManager, - ADIndexManagement indexUtil, Provider cacheProvider, Duration stateTtl, ADCheckpointWriteWorker checkpointWriteQueue, - ADStats adStats, - ADSaveResultStrategy resultWriteWorker + ADInferencer inferencer ) { super( WORKER_NAME, @@ -105,17 +102,14 @@ public ADCheckpointReadWorker( checkpointDao, entityColdStartQueue, stateManager, - indexUtil, cacheProvider, stateTtl, checkpointWriteQueue, - adStats, AD_CHECKPOINT_READ_QUEUE_CONCURRENCY, AD_CHECKPOINT_READ_QUEUE_BATCH_SIZE, ADCommonName.CHECKPOINT_INDEX_NAME, - StatNames.AD_MODEL_CORRUTPION_COUNT, AnalysisType.AD, - resultWriteWorker + inferencer ); } } diff --git a/src/main/java/org/opensearch/ad/ratelimit/ADColdEntityWorker.java b/src/main/java/org/opensearch/ad/ratelimit/ADColdEntityWorker.java index 0abd7527d..aa92704fe 100644 --- a/src/main/java/org/opensearch/ad/ratelimit/ADColdEntityWorker.java +++ b/src/main/java/org/opensearch/ad/ratelimit/ADColdEntityWorker.java @@ -23,6 +23,7 @@ import org.opensearch.ad.indices.ADIndexManagement; import org.opensearch.ad.ml.ADCheckpointDao; import org.opensearch.ad.ml.ADColdStart; +import org.opensearch.ad.ml.ADInferencer; import org.opensearch.ad.ml.ADModelManager; import org.opensearch.ad.ml.ThresholdingResult; import org.opensearch.ad.model.AnomalyResult; @@ -54,7 +55,7 @@ * */ public class ADColdEntityWorker extends - ColdEntityWorker { + ColdEntityWorker { public static final String WORKER_NAME = "ad-cold-entity"; public ADColdEntityWorker( diff --git a/src/main/java/org/opensearch/ad/ratelimit/ADColdStartWorker.java b/src/main/java/org/opensearch/ad/ratelimit/ADColdStartWorker.java index 09cd82a4a..7f88df4c1 100644 --- a/src/main/java/org/opensearch/ad/ratelimit/ADColdStartWorker.java +++ b/src/main/java/org/opensearch/ad/ratelimit/ADColdStartWorker.java @@ -118,7 +118,7 @@ protected ModelState createEmptyState(FeatureRequest null, modelId, configId, - ModelManager.ModelType.RCFCASTER.getName(), + ModelManager.ModelType.TRCF.getName(), clock, 0, request.getEntity(), @@ -131,6 +131,9 @@ protected AnomalyResult createIndexableResult(Config config, String taskId, Stri return new AnomalyResult( config.getId(), taskId, + Double.NaN, + Double.NaN, + Double.NaN, ParseUtils.getFeatureData(entry.getValueList(), config), entry.getDataStartTime(), entry.getDataEndTime(), @@ -140,7 +143,13 @@ protected AnomalyResult createIndexableResult(Config config, String taskId, Stri entity, config.getUser(), config.getSchemaVersion(), - modelId + modelId, + null, + null, + null, + null, + null, + null ); } } diff --git a/src/main/java/org/opensearch/ad/ratelimit/ADSaveResultStrategy.java b/src/main/java/org/opensearch/ad/ratelimit/ADSaveResultStrategy.java index d82eab34a..cac437523 100644 --- a/src/main/java/org/opensearch/ad/ratelimit/ADSaveResultStrategy.java +++ b/src/main/java/org/opensearch/ad/ratelimit/ADSaveResultStrategy.java @@ -71,7 +71,6 @@ public void saveResult( taskId, null ); - for (AnomalyResult r : indexableResults) { saveResult(r, config); } diff --git a/src/main/java/org/opensearch/ad/rest/handler/AbstractAnomalyDetectorActionHandler.java b/src/main/java/org/opensearch/ad/rest/handler/AbstractAnomalyDetectorActionHandler.java index ef2b7d754..1219107c4 100644 --- a/src/main/java/org/opensearch/ad/rest/handler/AbstractAnomalyDetectorActionHandler.java +++ b/src/main/java/org/opensearch/ad/rest/handler/AbstractAnomalyDetectorActionHandler.java @@ -92,6 +92,11 @@ public abstract class AbstractAnomalyDetectorActionHandler REQUEST_TIMEOUT = Setting .positiveTimeSetting( "opendistro.anomaly_detection.request_timeout", - TimeValue.timeValueSeconds(10), + TimeValue.timeValueSeconds(60), Setting.Property.NodeScope, Setting.Property.Dynamic, Setting.Property.Deprecated diff --git a/src/main/java/org/opensearch/ad/task/ADBatchTaskCache.java b/src/main/java/org/opensearch/ad/task/ADBatchTaskCache.java index acccb0dd2..4623ad4e3 100644 --- a/src/main/java/org/opensearch/ad/task/ADBatchTaskCache.java +++ b/src/main/java/org/opensearch/ad/task/ADBatchTaskCache.java @@ -15,6 +15,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import org.opensearch.ad.ml.ADColdStart; import org.opensearch.ad.model.ADTask; import org.opensearch.ad.model.AnomalyDetector; import org.opensearch.timeseries.ml.ModelColdStart; @@ -85,6 +86,8 @@ protected ADBatchTaskCache(ADTask adTask) { rcfBuilder.forestMode(ForestMode.STANDARD); } + ADColdStart.applyRule(rcfBuilder, detector); + rcfModel = rcfBuilder.build(); this.thresholdModelTrained = false; } diff --git a/src/main/java/org/opensearch/ad/task/ADBatchTaskRunner.java b/src/main/java/org/opensearch/ad/task/ADBatchTaskRunner.java index 6ff20287c..20c87d1b6 100644 --- a/src/main/java/org/opensearch/ad/task/ADBatchTaskRunner.java +++ b/src/main/java/org/opensearch/ad/task/ADBatchTaskRunner.java @@ -25,6 +25,7 @@ import java.time.Clock; import java.time.Instant; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -43,6 +44,7 @@ import org.opensearch.ad.indices.ADIndex; import org.opensearch.ad.indices.ADIndexManagement; import org.opensearch.ad.ml.ADModelManager; +import org.opensearch.ad.ml.ThresholdingResult; import org.opensearch.ad.model.ADTask; import org.opensearch.ad.model.ADTaskType; import org.opensearch.ad.model.AnomalyDetector; @@ -87,6 +89,7 @@ import org.opensearch.timeseries.feature.FeatureManager; import org.opensearch.timeseries.feature.SearchFeatureDao; import org.opensearch.timeseries.function.ExecutorFunction; +import org.opensearch.timeseries.ml.Sample; import org.opensearch.timeseries.model.DateRange; import org.opensearch.timeseries.model.Entity; import org.opensearch.timeseries.model.FeatureData; @@ -105,7 +108,6 @@ import org.opensearch.transport.TransportService; import com.amazon.randomcutforest.RandomCutForest; -import com.amazon.randomcutforest.parkservices.AnomalyDescriptor; import com.amazon.randomcutforest.parkservices.ThresholdedRandomCutForest; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -1087,62 +1089,61 @@ private void detectAnomaly( List anomalyResults = new ArrayList<>(); long intervalEndTime = pieceStartTime; + AnomalyDetector detector = adTask.getDetector(); for (int i = 0; i < pieceSize && intervalEndTime < dataEndTime; i++) { Optional dataPoint = dataPoints.containsKey(intervalEndTime) ? dataPoints.get(intervalEndTime) : Optional.empty(); intervalEndTime = intervalEndTime + interval; + Instant pieceDataStartTime = Instant.ofEpochMilli(intervalEndTime - interval); + Instant pieceDataEndTime = Instant.ofEpochMilli(intervalEndTime); - if (dataPoint.isEmpty()) { + if (dataPoint.isEmpty() && detector.getImputationOption() == null) { AnomalyResult anomalyResult = new AnomalyResult( adTask.getConfigId(), adTask.getConfigLevelTaskId(), null, - Instant.ofEpochMilli(intervalEndTime - interval), - Instant.ofEpochMilli(intervalEndTime), + pieceDataStartTime, + pieceDataEndTime, executeStartTime, Instant.now(), "No data in current detection window", Optional.ofNullable(adTask.getEntity()), - adTask.getDetector().getUser(), + detector.getUser(), anomalyDetectionIndices.getSchemaVersion(ADIndex.RESULT), adTask.getEntityModelId() ); anomalyResults.add(anomalyResult); } else { - List featureData = ParseUtils.getFeatureData(dataPoint.get(), adTask.getDetector()); - // 0 is placeholder for timestamp. In the future, we will add - // data time stamp there. - AnomalyDescriptor descriptor = trcf.process(dataPoint.get(), intervalEndTime); - double score = descriptor.getRCFScore(); - if (!adTaskCacheManager.isThresholdModelTrained(taskId) && score > 0) { + // dataPoint is empty or or dataPoint may have partial results (missing feature value is Double.NaN) or imputation option is + // not null + double[] toScore = null; + if (dataPoint.isEmpty()) { + toScore = new double[detector.getEnabledFeatureIds().size()]; + Arrays.fill(toScore, Double.NaN); + } else { + toScore = dataPoint.get(); + } + ThresholdingResult thresholdingResult = modelManager + .score(new Sample(toScore, pieceDataStartTime, pieceDataEndTime), detector, trcf); + if (!adTaskCacheManager.isThresholdModelTrained(taskId) && thresholdingResult.getRcfScore() > 0) { adTaskCacheManager.setThresholdModelTrained(taskId, true); } + List featureData = ParseUtils.getFeatureData(toScore, adTask.getDetector()); - AnomalyResult anomalyResult = AnomalyResult - .fromRawTRCFResult( - adTask.getConfigId(), - adTask.getDetector().getIntervalInMilliseconds(), - adTask.getConfigLevelTaskId(), - score, - descriptor.getAnomalyGrade(), - descriptor.getDataConfidence(), - featureData, - Instant.ofEpochMilli(intervalEndTime - interval), - Instant.ofEpochMilli(intervalEndTime), + List indexableResults = thresholdingResult + .toIndexableResults( + detector, + pieceDataStartTime, + pieceDataEndTime, executeStartTime, Instant.now(), - null, + featureData, Optional.ofNullable(adTask.getEntity()), - adTask.getDetector().getUser(), anomalyDetectionIndices.getSchemaVersion(ADIndex.RESULT), adTask.getEntityModelId(), - modelManager.normalizeAttribution(trcf.getForest(), descriptor.getRelevantAttribution()), - descriptor.getRelativeIndex(), - descriptor.getPastValues(), - descriptor.getExpectedValuesList(), - descriptor.getLikelihoodOfValues(), - descriptor.getThreshold() + adTask.getConfigLevelTaskId(), + null ); - anomalyResults.add(anomalyResult); + anomalyResults.addAll(indexableResults); } } @@ -1300,7 +1301,14 @@ private void runNextPiece( ); }, TimeValue.timeValueSeconds(pieceIntervalSeconds), AD_BATCH_TASK_THREAD_POOL_NAME); } else { - logger.info("AD task finished for detector {}, task id: {}", detectorId, taskId); + logger + .info( + "AD task finished for detector {}, task id: {}, pieceStartTime: {}, dataEndTime: {}", + detectorId, + taskId, + pieceStartTime, + dataEndTime + ); adTaskCacheManager.remove(taskId, detectorId, detectorTaskId); adTaskManager .updateTask( diff --git a/src/main/java/org/opensearch/ad/transport/ADHCImputeAction.java b/src/main/java/org/opensearch/ad/transport/ADHCImputeAction.java new file mode 100644 index 000000000..da1537ca5 --- /dev/null +++ b/src/main/java/org/opensearch/ad/transport/ADHCImputeAction.java @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import org.opensearch.action.ActionType; +import org.opensearch.ad.constant.ADCommonValue; + +public class ADHCImputeAction extends ActionType { + // Internal Action which is not used for public facing RestAPIs. + public static final String NAME = ADCommonValue.INTERNAL_ACTION_PREFIX + "impute/hc"; + public static final ADHCImputeAction INSTANCE = new ADHCImputeAction(); + + private ADHCImputeAction() { + super(NAME, ADHCImputeNodesResponse::new); + } +} diff --git a/src/main/java/org/opensearch/ad/transport/ADHCImputeNodeRequest.java b/src/main/java/org/opensearch/ad/transport/ADHCImputeNodeRequest.java new file mode 100644 index 000000000..2e85d0800 --- /dev/null +++ b/src/main/java/org/opensearch/ad/transport/ADHCImputeNodeRequest.java @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import java.io.IOException; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.transport.TransportRequest; + +public class ADHCImputeNodeRequest extends TransportRequest { + private final ADHCImputeRequest request; + + public ADHCImputeNodeRequest(StreamInput in) throws IOException { + super(in); + this.request = new ADHCImputeRequest(in); + } + + public ADHCImputeNodeRequest(ADHCImputeRequest request) { + this.request = request; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + request.writeTo(out); + } + + public ADHCImputeRequest getRequest() { + return request; + } +} diff --git a/src/main/java/org/opensearch/ad/transport/ADHCImputeNodeResponse.java b/src/main/java/org/opensearch/ad/transport/ADHCImputeNodeResponse.java new file mode 100644 index 000000000..ef62ab491 --- /dev/null +++ b/src/main/java/org/opensearch/ad/transport/ADHCImputeNodeResponse.java @@ -0,0 +1,49 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodeResponse; +import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +public class ADHCImputeNodeResponse extends BaseNodeResponse { + + Exception previousException; + + public ADHCImputeNodeResponse(DiscoveryNode node, Exception previousException) { + super(node); + this.previousException = previousException; + } + + public ADHCImputeNodeResponse(StreamInput in) throws IOException { + super(in); + if (in.readBoolean()) { + this.previousException = in.readException(); + } + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + if (previousException != null) { + out.writeBoolean(true); + out.writeException(previousException); + } else { + out.writeBoolean(false); + } + } + + public static ADHCImputeNodeResponse readNodeResponse(StreamInput in) throws IOException { + return new ADHCImputeNodeResponse(in); + } + + public Exception getPreviousException() { + return previousException; + } +} diff --git a/src/main/java/org/opensearch/ad/transport/ADHCImputeNodesResponse.java b/src/main/java/org/opensearch/ad/transport/ADHCImputeNodesResponse.java new file mode 100644 index 000000000..87874d13d --- /dev/null +++ b/src/main/java/org/opensearch/ad/transport/ADHCImputeNodesResponse.java @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import java.io.IOException; +import java.util.List; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.nodes.BaseNodesResponse; +import org.opensearch.cluster.ClusterName; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +public class ADHCImputeNodesResponse extends BaseNodesResponse { + public ADHCImputeNodesResponse(StreamInput in) throws IOException { + super(new ClusterName(in), in.readList(ADHCImputeNodeResponse::readNodeResponse), in.readList(FailedNodeException::new)); + } + + public ADHCImputeNodesResponse(ClusterName clusterName, List nodes, List failures) { + super(clusterName, nodes, failures); + } + + @Override + protected List readNodesFrom(StreamInput in) throws IOException { + return in.readList(ADHCImputeNodeResponse::readNodeResponse); + } + + @Override + protected void writeNodesTo(StreamOutput out, List nodes) throws IOException { + out.writeList(nodes); + } + +} diff --git a/src/main/java/org/opensearch/ad/transport/ADHCImputeRequest.java b/src/main/java/org/opensearch/ad/transport/ADHCImputeRequest.java new file mode 100644 index 000000000..9a082b538 --- /dev/null +++ b/src/main/java/org/opensearch/ad/transport/ADHCImputeRequest.java @@ -0,0 +1,61 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodesRequest; +import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +public class ADHCImputeRequest extends BaseNodesRequest { + private final String configId; + private final String taskId; + private final long dataStartMillis; + private final long dataEndMillis; + + public ADHCImputeRequest(String configId, String taskId, long startMillis, long endMillis, DiscoveryNode... nodes) { + super(nodes); + this.configId = configId; + this.taskId = taskId; + this.dataStartMillis = startMillis; + this.dataEndMillis = endMillis; + } + + public ADHCImputeRequest(StreamInput in) throws IOException { + super(in); + this.configId = in.readString(); + this.taskId = in.readOptionalString(); + this.dataStartMillis = in.readLong(); + this.dataEndMillis = in.readLong(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(configId); + out.writeOptionalString(taskId); + out.writeLong(dataStartMillis); + out.writeLong(dataEndMillis); + } + + public String getConfigId() { + return configId; + } + + public String getTaskId() { + return taskId; + } + + public long getDataStartMillis() { + return dataStartMillis; + } + + public long getDataEndMillis() { + return dataEndMillis; + } +} diff --git a/src/main/java/org/opensearch/ad/transport/ADHCImputeTransportAction.java b/src/main/java/org/opensearch/ad/transport/ADHCImputeTransportAction.java new file mode 100644 index 000000000..6f0e442bf --- /dev/null +++ b/src/main/java/org/opensearch/ad/transport/ADHCImputeTransportAction.java @@ -0,0 +1,132 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.transport; + +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.nodes.TransportNodesAction; +import org.opensearch.ad.caching.ADCacheProvider; +import org.opensearch.ad.ml.ADInferencer; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.timeseries.AnalysisType; +import org.opensearch.timeseries.NodeStateManager; +import org.opensearch.timeseries.TimeSeriesAnalyticsPlugin; +import org.opensearch.timeseries.ml.ModelState; +import org.opensearch.timeseries.ml.Sample; +import org.opensearch.timeseries.model.Config; +import org.opensearch.timeseries.model.IntervalTimeConfiguration; +import org.opensearch.timeseries.util.ActionListenerExecutor; +import org.opensearch.transport.TransportService; + +import com.amazon.randomcutforest.parkservices.ThresholdedRandomCutForest; + +public class ADHCImputeTransportAction extends + TransportNodesAction { + private static final Logger LOG = LogManager.getLogger(ADHCImputeTransportAction.class); + + private ADCacheProvider cache; + private NodeStateManager nodeStateManager; + private ADInferencer adInferencer; + + @Inject + public ADHCImputeTransportAction( + ThreadPool threadPool, + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters, + ADCacheProvider priorityCache, + NodeStateManager nodeStateManager, + ADInferencer adInferencer + ) { + super( + ADHCImputeAction.NAME, + threadPool, + clusterService, + transportService, + actionFilters, + ADHCImputeRequest::new, + ADHCImputeNodeRequest::new, + TimeSeriesAnalyticsPlugin.AD_THREAD_POOL_NAME, + ADHCImputeNodeResponse.class + ); + this.cache = priorityCache; + this.nodeStateManager = nodeStateManager; + this.adInferencer = adInferencer; + } + + @Override + protected ADHCImputeNodeRequest newNodeRequest(ADHCImputeRequest request) { + return new ADHCImputeNodeRequest(request); + } + + @Override + protected ADHCImputeNodeResponse newNodeResponse(StreamInput response) throws IOException { + return new ADHCImputeNodeResponse(response); + } + + @Override + protected ADHCImputeNodesResponse newResponse( + ADHCImputeRequest request, + List responses, + List failures + ) { + return new ADHCImputeNodesResponse(clusterService.getClusterName(), responses, failures); + } + + @Override + protected ADHCImputeNodeResponse nodeOperation(ADHCImputeNodeRequest nodeRequest) { + String configId = nodeRequest.getRequest().getConfigId(); + nodeStateManager.getConfig(configId, AnalysisType.AD, ActionListenerExecutor.wrap(configOptional -> { + if (configOptional.isEmpty()) { + LOG.warn(String.format(Locale.ROOT, "cannot find config %s", configId)); + return; + } + Config config = configOptional.get(); + long windowDelayMillis = ((IntervalTimeConfiguration) config.getWindowDelay()).toDuration().toMillis(); + int featureSize = config.getEnabledFeatureIds().size(); + long dataEndMillis = nodeRequest.getRequest().getDataEndMillis(); + long dataStartMillis = nodeRequest.getRequest().getDataStartMillis(); + long executionEndTime = dataEndMillis + windowDelayMillis; + String taskId = nodeRequest.getRequest().getTaskId(); + for (ModelState modelState : cache.get().getAllModels(configId)) { + // execution end time (when job starts execution in this interval) > last used time => the model state is updated in + // previous intervals + if (executionEndTime > modelState.getLastUsedTime().toEpochMilli()) { + double[] nanArray = new double[featureSize]; + Arrays.fill(nanArray, Double.NaN); + adInferencer + .process( + new Sample(nanArray, Instant.ofEpochMilli(dataStartMillis), Instant.ofEpochMilli(dataEndMillis)), + modelState, + config, + taskId + ); + } + } + }, e -> nodeStateManager.setException(configId, e), threadPool.executor(TimeSeriesAnalyticsPlugin.AD_THREAD_POOL_NAME))); + + Optional previousException = nodeStateManager.fetchExceptionAndClear(configId); + + if (previousException.isPresent()) { + return new ADHCImputeNodeResponse(clusterService.localNode(), previousException.get()); + } else { + return new ADHCImputeNodeResponse(clusterService.localNode(), null); + } + } + +} diff --git a/src/main/java/org/opensearch/ad/transport/ADResultProcessor.java b/src/main/java/org/opensearch/ad/transport/ADResultProcessor.java index b0f01b996..b7564b38e 100644 --- a/src/main/java/org/opensearch/ad/transport/ADResultProcessor.java +++ b/src/main/java/org/opensearch/ad/transport/ADResultProcessor.java @@ -22,10 +22,12 @@ import org.opensearch.ad.task.ADTaskManager; import org.opensearch.client.Client; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.node.DiscoveryNode; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Setting; import org.opensearch.common.settings.Settings; import org.opensearch.common.unit.TimeValue; +import org.opensearch.core.action.ActionListener; import org.opensearch.core.xcontent.NamedXContentRegistry; import org.opensearch.threadpool.ThreadPool; import org.opensearch.timeseries.AnalysisType; @@ -45,7 +47,6 @@ public class ADResultProcessor extends public ADResultProcessor( Setting requestTimeoutSetting, - float intervalRatioForRequests, String entityResultAction, StatNames hcRequestCountStat, Settings settings, @@ -65,7 +66,6 @@ public ADResultProcessor( ) { super( requestTimeoutSetting, - intervalRatioForRequests, entityResultAction, hcRequestCountStat, settings, @@ -102,4 +102,33 @@ protected AnomalyResultResponse createResultResponse( ) { return new AnomalyResultResponse(features, error, rcfTotalUpdates, configInterval, isHC, taskId); } + + @Override + protected void imputeHC(long dataStartTime, long dataEndTime, String configID, String taskId) { + LOG + .info( + "Sending an HC impute request to process data from timestamp {} to {} for config {}", + dataStartTime, + dataEndTime, + configID + ); + + DiscoveryNode[] dataNodes = hashRing.getNodesWithSameLocalVersion(); + + client + .execute( + ADHCImputeAction.INSTANCE, + new ADHCImputeRequest(configID, taskId, dataStartTime, dataEndTime, dataNodes), + ActionListener.wrap(hcImputeResponse -> { + for (final ADHCImputeNodeResponse nodeResponse : hcImputeResponse.getNodes()) { + if (nodeResponse.getPreviousException() != null) { + nodeStateManager.setException(configID, nodeResponse.getPreviousException()); + } + } + }, e -> { + LOG.warn("fail to HC impute", e); + nodeStateManager.setException(configID, e); + }) + ); + } } diff --git a/src/main/java/org/opensearch/ad/transport/ADSingleStreamResultTransportAction.java b/src/main/java/org/opensearch/ad/transport/ADSingleStreamResultTransportAction.java index 983c089b5..6995c5b36 100644 --- a/src/main/java/org/opensearch/ad/transport/ADSingleStreamResultTransportAction.java +++ b/src/main/java/org/opensearch/ad/transport/ADSingleStreamResultTransportAction.java @@ -13,6 +13,7 @@ import org.opensearch.ad.indices.ADIndexManagement; import org.opensearch.ad.ml.ADCheckpointDao; import org.opensearch.ad.ml.ADColdStart; +import org.opensearch.ad.ml.ADInferencer; import org.opensearch.ad.ml.ADModelManager; import org.opensearch.ad.ml.ThresholdingResult; import org.opensearch.ad.model.AnomalyResult; @@ -21,24 +22,22 @@ import org.opensearch.ad.ratelimit.ADCheckpointWriteWorker; import org.opensearch.ad.ratelimit.ADColdStartWorker; import org.opensearch.ad.ratelimit.ADResultWriteRequest; -import org.opensearch.ad.ratelimit.ADResultWriteWorker; import org.opensearch.ad.ratelimit.ADSaveResultStrategy; -import org.opensearch.ad.stats.ADStats; -import org.opensearch.ad.transport.handler.ADIndexMemoryPressureAwareResultHandler; import org.opensearch.common.inject.Inject; +import org.opensearch.threadpool.ThreadPool; import org.opensearch.timeseries.AnalysisType; import org.opensearch.timeseries.NodeStateManager; +import org.opensearch.timeseries.TimeSeriesAnalyticsPlugin; import org.opensearch.timeseries.breaker.CircuitBreakerService; import org.opensearch.timeseries.model.Config; import org.opensearch.timeseries.ratelimit.RequestPriority; -import org.opensearch.timeseries.stats.StatNames; import org.opensearch.timeseries.transport.AbstractSingleStreamResultTransportAction; import org.opensearch.transport.TransportService; import com.amazon.randomcutforest.parkservices.ThresholdedRandomCutForest; public class ADSingleStreamResultTransportAction extends - AbstractSingleStreamResultTransportAction { + AbstractSingleStreamResultTransportAction { @Inject public ADSingleStreamResultTransportAction( @@ -48,11 +47,8 @@ public ADSingleStreamResultTransportAction( ADCacheProvider cache, NodeStateManager stateManager, ADCheckpointReadWorker checkpointReadQueue, - ADModelManager modelManager, - ADIndexManagement indexUtil, - ADResultWriteWorker resultWriteQueue, - ADStats stats, - ADColdStartWorker adColdStartQueue + ADInferencer inferencer, + ThreadPool threadPool ) { super( transportService, @@ -61,15 +57,11 @@ public ADSingleStreamResultTransportAction( cache, stateManager, checkpointReadQueue, - modelManager, - indexUtil, - resultWriteQueue, - stats, - adColdStartQueue, ADSingleStreamResultAction.NAME, - ADIndex.RESULT, AnalysisType.AD, - StatNames.AD_MODEL_CORRUTPION_COUNT.getName() + inferencer, + threadPool, + TimeSeriesAnalyticsPlugin.AD_THREAD_POOL_NAME ); } diff --git a/src/main/java/org/opensearch/ad/transport/AnomalyResultResponse.java b/src/main/java/org/opensearch/ad/transport/AnomalyResultResponse.java index 8708cb92a..072795ef2 100644 --- a/src/main/java/org/opensearch/ad/transport/AnomalyResultResponse.java +++ b/src/main/java/org/opensearch/ad/transport/AnomalyResultResponse.java @@ -352,7 +352,12 @@ public List toIndexableResults( pastValues, expectedValuesList, likelihoodOfValues, - threshold + threshold, + // Starting from version 2.15, this class is used to store job execution errors, not actual results, + // as the single stream has been changed to async mode. The job no longer waits for results before returning. + // Therefore, we set the following two fields to null, as we will not record any imputed fields. + null, + null ) ); } diff --git a/src/main/java/org/opensearch/ad/transport/AnomalyResultTransportAction.java b/src/main/java/org/opensearch/ad/transport/AnomalyResultTransportAction.java index 1ee5b3ab8..d8a726d5d 100644 --- a/src/main/java/org/opensearch/ad/transport/AnomalyResultTransportAction.java +++ b/src/main/java/org/opensearch/ad/transport/AnomalyResultTransportAction.java @@ -44,7 +44,6 @@ import org.opensearch.timeseries.common.exception.TimeSeriesException; import org.opensearch.timeseries.constant.CommonMessages; import org.opensearch.timeseries.feature.FeatureManager; -import org.opensearch.timeseries.settings.TimeSeriesSettings; import org.opensearch.timeseries.stats.StatNames; import org.opensearch.timeseries.transport.ResultProcessor; import org.opensearch.timeseries.util.SecurityClientUtil; @@ -85,7 +84,6 @@ public AnomalyResultTransportAction( super(AnomalyResultAction.NAME, transportService, actionFilters, AnomalyResultRequest::new); this.resultProcessor = new ADResultProcessor( AnomalyDetectorSettings.AD_REQUEST_TIMEOUT, - TimeSeriesSettings.INTERVAL_RATIO_FOR_REQUESTS, EntityADResultAction.NAME, StatNames.AD_HC_EXECUTE_REQUEST_COUNT, settings, diff --git a/src/main/java/org/opensearch/ad/transport/EntityADResultTransportAction.java b/src/main/java/org/opensearch/ad/transport/EntityADResultTransportAction.java index b5d644c21..3712b9f1f 100644 --- a/src/main/java/org/opensearch/ad/transport/EntityADResultTransportAction.java +++ b/src/main/java/org/opensearch/ad/transport/EntityADResultTransportAction.java @@ -24,6 +24,7 @@ import org.opensearch.ad.indices.ADIndexManagement; import org.opensearch.ad.ml.ADCheckpointDao; import org.opensearch.ad.ml.ADColdStart; +import org.opensearch.ad.ml.ADInferencer; import org.opensearch.ad.ml.ADModelManager; import org.opensearch.ad.ml.ThresholdingResult; import org.opensearch.ad.model.AnomalyResult; @@ -32,7 +33,6 @@ import org.opensearch.ad.ratelimit.ADColdEntityWorker; import org.opensearch.ad.ratelimit.ADColdStartWorker; import org.opensearch.ad.ratelimit.ADSaveResultStrategy; -import org.opensearch.ad.stats.ADStats; import org.opensearch.common.inject.Inject; import org.opensearch.core.action.ActionListener; import org.opensearch.tasks.Task; @@ -44,7 +44,6 @@ import org.opensearch.timeseries.common.exception.EndRunException; import org.opensearch.timeseries.common.exception.LimitExceededException; import org.opensearch.timeseries.constant.CommonMessages; -import org.opensearch.timeseries.stats.StatNames; import org.opensearch.timeseries.transport.EntityResultProcessor; import org.opensearch.timeseries.transport.EntityResultRequest; import org.opensearch.timeseries.util.ExceptionUtil; @@ -78,21 +77,17 @@ public class EntityADResultTransportAction extends HandledTransportAction cache; private final NodeStateManager stateManager; private ThreadPool threadPool; - private EntityResultProcessor intervalDataProcessor; + private EntityResultProcessor intervalDataProcessor; private final ADCacheProvider entityCache; - private final ADModelManager manager; - private final ADStats timeSeriesStats; - private final ADColdStartWorker entityColdStartWorker; private final ADCheckpointReadWorker checkpointReadQueue; private final ADColdEntityWorker coldEntityQueue; - private final ADSaveResultStrategy adSaveResultStategy; + private final ADInferencer inferencer; @Inject public EntityADResultTransportAction( ActionFilters actionFilters, TransportService transportService, - ADModelManager manager, CircuitBreakerService adCircuitBreakerService, ADCacheProvider entityCache, NodeStateManager stateManager, @@ -100,9 +95,7 @@ public EntityADResultTransportAction( ADCheckpointReadWorker checkpointReadQueue, ADColdEntityWorker coldEntityQueue, ThreadPool threadPool, - ADColdStartWorker entityColdStartWorker, - ADStats timeSeriesStats, - ADSaveResultStrategy adSaveResultStategy + ADInferencer inferencer ) { super(EntityADResultAction.NAME, transportService, actionFilters, EntityResultRequest::new); this.adCircuitBreakerService = adCircuitBreakerService; @@ -111,13 +104,10 @@ public EntityADResultTransportAction( this.threadPool = threadPool; this.entityCache = entityCache; - this.manager = manager; - this.timeSeriesStats = timeSeriesStats; - this.entityColdStartWorker = entityColdStartWorker; this.checkpointReadQueue = checkpointReadQueue; this.coldEntityQueue = coldEntityQueue; - this.adSaveResultStategy = adSaveResultStategy; this.intervalDataProcessor = null; + this.inferencer = inferencer; } @Override @@ -151,13 +141,11 @@ protected void doExecute(Task task, EntityResultRequest request, ActionListener< this.intervalDataProcessor = new EntityResultProcessor<>( entityCache, - manager, - timeSeriesStats, - entityColdStartWorker, checkpointReadQueue, coldEntityQueue, - adSaveResultStategy, - StatNames.AD_MODEL_CORRUTPION_COUNT + inferencer, + threadPool, + TimeSeriesAnalyticsPlugin.AD_THREAD_POOL_NAME ); stateManager diff --git a/src/main/java/org/opensearch/ad/transport/ForwardADTaskRequest.java b/src/main/java/org/opensearch/ad/transport/ForwardADTaskRequest.java index 417696609..5aa362e1d 100644 --- a/src/main/java/org/opensearch/ad/transport/ForwardADTaskRequest.java +++ b/src/main/java/org/opensearch/ad/transport/ForwardADTaskRequest.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Objects; +import org.apache.commons.lang.builder.ToStringBuilder; import org.opensearch.Version; import org.opensearch.action.ActionRequest; import org.opensearch.action.ActionRequestValidationException; @@ -27,6 +28,7 @@ import org.opensearch.commons.authuser.User; import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.timeseries.annotation.Generated; import org.opensearch.timeseries.common.exception.VersionException; import org.opensearch.timeseries.function.ExecutorFunction; import org.opensearch.timeseries.model.DateRange; @@ -197,10 +199,12 @@ public Integer getAvailableTaskSLots() { @Override public boolean equals(Object o) { - if (this == o) + if (this == o) { return true; - if (o == null || getClass() != o.getClass()) + } + if (o == null || getClass() != o.getClass()) { return false; + } ForwardADTaskRequest request = (ForwardADTaskRequest) o; return Objects.equals(detector, request.detector) && Objects.equals(adTask, request.adTask) @@ -215,4 +219,18 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(detector, adTask, detectionDateRange, staleRunningEntities, user, availableTaskSlots, adTaskAction); } + + @Generated + @Override + public String toString() { + return new ToStringBuilder(this) + .append("detector", detector) + .append("adTask", adTask) + .append("detectionDateRange", detectionDateRange) + .append("staleRunningEntities", staleRunningEntities) + .append("user", user) + .append("availableTaskSlots", availableTaskSlots) + .append("adTaskAction", adTaskAction) + .toString(); + } } diff --git a/src/main/java/org/opensearch/ad/transport/handler/ADIndexMemoryPressureAwareResultHandler.java b/src/main/java/org/opensearch/ad/transport/handler/ADIndexMemoryPressureAwareResultHandler.java index 1ac134768..149c934f2 100644 --- a/src/main/java/org/opensearch/ad/transport/handler/ADIndexMemoryPressureAwareResultHandler.java +++ b/src/main/java/org/opensearch/ad/transport/handler/ADIndexMemoryPressureAwareResultHandler.java @@ -47,6 +47,7 @@ protected void bulk(ADResultBulkRequest currentBulkRequest, ActionListenerwrap(response -> { LOG.debug(CommonMessages.SUCCESS_SAVING_RESULT_MSG); listener.onResponse(response); diff --git a/src/main/java/org/opensearch/forecast/ml/ForecastInferencer.java b/src/main/java/org/opensearch/forecast/ml/ForecastInferencer.java new file mode 100644 index 000000000..793b21995 --- /dev/null +++ b/src/main/java/org/opensearch/forecast/ml/ForecastInferencer.java @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.forecast.ml; + +import static org.opensearch.timeseries.TimeSeriesAnalyticsPlugin.FORECAST_THREAD_POOL_NAME; + +import org.opensearch.forecast.caching.ForecastCacheProvider; +import org.opensearch.forecast.caching.ForecastPriorityCache; +import org.opensearch.forecast.indices.ForecastIndex; +import org.opensearch.forecast.indices.ForecastIndexManagement; +import org.opensearch.forecast.model.ForecastResult; +import org.opensearch.forecast.ratelimit.ForecastCheckpointWriteWorker; +import org.opensearch.forecast.ratelimit.ForecastColdStartWorker; +import org.opensearch.forecast.ratelimit.ForecastSaveResultStrategy; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.timeseries.ml.Inferencer; +import org.opensearch.timeseries.stats.StatNames; +import org.opensearch.timeseries.stats.Stats; + +import com.amazon.randomcutforest.parkservices.RCFCaster; + +public class ForecastInferencer extends + Inferencer { + + public ForecastInferencer( + ForecastModelManager modelManager, + Stats stats, + ForecastCheckpointDao checkpointDao, + ForecastColdStartWorker coldStartWorker, + ForecastSaveResultStrategy resultWriteWorker, + ForecastCacheProvider cache, + ThreadPool threadPool + ) { + super( + modelManager, + stats, + StatNames.FORECAST_MODEL_CORRUTPION_COUNT.getName(), + checkpointDao, + coldStartWorker, + resultWriteWorker, + cache, + threadPool, + FORECAST_THREAD_POOL_NAME + ); + } + +} diff --git a/src/main/java/org/opensearch/forecast/ml/ForecastModelManager.java b/src/main/java/org/opensearch/forecast/ml/ForecastModelManager.java index 438c9bdde..3e15014d5 100644 --- a/src/main/java/org/opensearch/forecast/ml/ForecastModelManager.java +++ b/src/main/java/org/opensearch/forecast/ml/ForecastModelManager.java @@ -21,6 +21,7 @@ import org.opensearch.timeseries.MemoryTracker; import org.opensearch.timeseries.feature.FeatureManager; import org.opensearch.timeseries.ml.ModelManager; +import org.opensearch.timeseries.model.Config; import com.amazon.randomcutforest.RandomCutForest; import com.amazon.randomcutforest.parkservices.AnomalyDescriptor; @@ -49,7 +50,13 @@ protected RCFCasterResult createEmptyResult() { } @Override - protected RCFCasterResult toResult(RandomCutForest forecast, RCFDescriptor castDescriptor) { + protected RCFCasterResult toResult( + RandomCutForest forecast, + RCFDescriptor castDescriptor, + double[] point, + boolean isImputed, + Config config + ) { if (castDescriptor instanceof ForecastDescriptor) { ForecastDescriptor forecastDescriptor = (ForecastDescriptor) castDescriptor; // Use forecastDescriptor in the rest of your method diff --git a/src/main/java/org/opensearch/forecast/ratelimit/ForecastCheckpointReadWorker.java b/src/main/java/org/opensearch/forecast/ratelimit/ForecastCheckpointReadWorker.java index 5bbcb4e1e..652b38ec2 100644 --- a/src/main/java/org/opensearch/forecast/ratelimit/ForecastCheckpointReadWorker.java +++ b/src/main/java/org/opensearch/forecast/ratelimit/ForecastCheckpointReadWorker.java @@ -22,22 +22,21 @@ import org.opensearch.forecast.indices.ForecastIndexManagement; import org.opensearch.forecast.ml.ForecastCheckpointDao; import org.opensearch.forecast.ml.ForecastColdStart; +import org.opensearch.forecast.ml.ForecastInferencer; import org.opensearch.forecast.ml.ForecastModelManager; import org.opensearch.forecast.ml.RCFCasterResult; import org.opensearch.forecast.model.ForecastResult; -import org.opensearch.forecast.stats.ForecastStats; import org.opensearch.threadpool.ThreadPool; import org.opensearch.timeseries.AnalysisType; import org.opensearch.timeseries.NodeStateManager; import org.opensearch.timeseries.TimeSeriesAnalyticsPlugin; import org.opensearch.timeseries.breaker.CircuitBreakerService; import org.opensearch.timeseries.ratelimit.CheckpointReadWorker; -import org.opensearch.timeseries.stats.StatNames; import com.amazon.randomcutforest.parkservices.RCFCaster; public class ForecastCheckpointReadWorker extends - CheckpointReadWorker { + CheckpointReadWorker { public static final String WORKER_NAME = "forecast-checkpoint-read"; public ForecastCheckpointReadWorker( @@ -59,12 +58,10 @@ public ForecastCheckpointReadWorker( ForecastCheckpointDao checkpointDao, ForecastColdStartWorker entityColdStartQueue, NodeStateManager stateManager, - ForecastIndexManagement indexUtil, Provider cacheProvider, Duration stateTtl, ForecastCheckpointWriteWorker checkpointWriteQueue, - ForecastStats forecastStats, - ForecastSaveResultStrategy saveResultStrategy + ForecastInferencer inferencer ) { super( WORKER_NAME, @@ -87,17 +84,14 @@ public ForecastCheckpointReadWorker( checkpointDao, entityColdStartQueue, stateManager, - indexUtil, cacheProvider, stateTtl, checkpointWriteQueue, - forecastStats, FORECAST_CHECKPOINT_READ_QUEUE_CONCURRENCY, FORECAST_CHECKPOINT_READ_QUEUE_BATCH_SIZE, ForecastCommonName.FORECAST_CHECKPOINT_INDEX_NAME, - StatNames.FORECAST_MODEL_CORRUTPION_COUNT, AnalysisType.FORECAST, - saveResultStrategy + inferencer ); } } diff --git a/src/main/java/org/opensearch/forecast/ratelimit/ForecastColdEntityWorker.java b/src/main/java/org/opensearch/forecast/ratelimit/ForecastColdEntityWorker.java index 43831f8df..dcc1dca6f 100644 --- a/src/main/java/org/opensearch/forecast/ratelimit/ForecastColdEntityWorker.java +++ b/src/main/java/org/opensearch/forecast/ratelimit/ForecastColdEntityWorker.java @@ -20,6 +20,7 @@ import org.opensearch.forecast.indices.ForecastIndexManagement; import org.opensearch.forecast.ml.ForecastCheckpointDao; import org.opensearch.forecast.ml.ForecastColdStart; +import org.opensearch.forecast.ml.ForecastInferencer; import org.opensearch.forecast.ml.ForecastModelManager; import org.opensearch.forecast.ml.RCFCasterResult; import org.opensearch.forecast.model.ForecastResult; @@ -48,7 +49,7 @@ * */ public class ForecastColdEntityWorker extends - ColdEntityWorker { + ColdEntityWorker { public static final String WORKER_NAME = "forecast-cold-entity"; public ForecastColdEntityWorker( diff --git a/src/main/java/org/opensearch/forecast/rest/handler/AbstractForecasterActionHandler.java b/src/main/java/org/opensearch/forecast/rest/handler/AbstractForecasterActionHandler.java index 92bbf9325..15e30ef76 100644 --- a/src/main/java/org/opensearch/forecast/rest/handler/AbstractForecasterActionHandler.java +++ b/src/main/java/org/opensearch/forecast/rest/handler/AbstractForecasterActionHandler.java @@ -226,7 +226,7 @@ protected String getNoDocsInUserIndexErrorMsg(String suppliedIndices) { } @Override - protected String getDuplicateConfigErrorMsg(String name) { + public String getDuplicateConfigErrorMsg(String name) { return String.format(Locale.ROOT, DUPLICATE_FORECASTER_MSG, name); } diff --git a/src/main/java/org/opensearch/forecast/transport/EntityForecastResultTransportAction.java b/src/main/java/org/opensearch/forecast/transport/EntityForecastResultTransportAction.java index d638b3bae..9d58ec049 100644 --- a/src/main/java/org/opensearch/forecast/transport/EntityForecastResultTransportAction.java +++ b/src/main/java/org/opensearch/forecast/transport/EntityForecastResultTransportAction.java @@ -25,6 +25,7 @@ import org.opensearch.forecast.indices.ForecastIndexManagement; import org.opensearch.forecast.ml.ForecastCheckpointDao; import org.opensearch.forecast.ml.ForecastColdStart; +import org.opensearch.forecast.ml.ForecastInferencer; import org.opensearch.forecast.ml.ForecastModelManager; import org.opensearch.forecast.ml.RCFCasterResult; import org.opensearch.forecast.model.ForecastResult; @@ -34,7 +35,6 @@ import org.opensearch.forecast.ratelimit.ForecastColdStartWorker; import org.opensearch.forecast.ratelimit.ForecastResultWriteWorker; import org.opensearch.forecast.ratelimit.ForecastSaveResultStrategy; -import org.opensearch.forecast.stats.ForecastStats; import org.opensearch.tasks.Task; import org.opensearch.threadpool.ThreadPool; import org.opensearch.timeseries.NodeStateManager; @@ -44,7 +44,6 @@ import org.opensearch.timeseries.common.exception.EndRunException; import org.opensearch.timeseries.common.exception.LimitExceededException; import org.opensearch.timeseries.constant.CommonMessages; -import org.opensearch.timeseries.stats.StatNames; import org.opensearch.timeseries.transport.EntityResultProcessor; import org.opensearch.timeseries.transport.EntityResultRequest; import org.opensearch.timeseries.util.ExceptionUtil; @@ -78,21 +77,17 @@ public class EntityForecastResultTransportAction extends HandledTransportAction< private CacheProvider cache; private final NodeStateManager stateManager; private ThreadPool threadPool; - private EntityResultProcessor intervalDataProcessor; + private EntityResultProcessor intervalDataProcessor; private final ForecastCacheProvider entityCache; - private final ForecastModelManager manager; - private final ForecastStats timeSeriesStats; - private final ForecastColdStartWorker entityColdStartWorker; private final ForecastCheckpointReadWorker checkpointReadQueue; private final ForecastColdEntityWorker coldEntityQueue; - private final ForecastSaveResultStrategy forecastSaveResultStategy; + private final ForecastInferencer inferencer; @Inject public EntityForecastResultTransportAction( ActionFilters actionFilters, TransportService transportService, - ForecastModelManager manager, CircuitBreakerService adCircuitBreakerService, ForecastCacheProvider entityCache, NodeStateManager stateManager, @@ -101,9 +96,7 @@ public EntityForecastResultTransportAction( ForecastCheckpointReadWorker checkpointReadQueue, ForecastColdEntityWorker coldEntityQueue, ThreadPool threadPool, - ForecastColdStartWorker entityColdStartWorker, - ForecastStats timeSeriesStats, - ForecastSaveResultStrategy forecastSaveResultStategy + ForecastInferencer inferencer ) { super(EntityForecastResultAction.NAME, transportService, actionFilters, EntityResultRequest::new); this.circuitBreakerService = adCircuitBreakerService; @@ -112,12 +105,9 @@ public EntityForecastResultTransportAction( this.threadPool = threadPool; this.intervalDataProcessor = null; this.entityCache = entityCache; - this.manager = manager; - this.timeSeriesStats = timeSeriesStats; - this.entityColdStartWorker = entityColdStartWorker; this.checkpointReadQueue = checkpointReadQueue; this.coldEntityQueue = coldEntityQueue; - this.forecastSaveResultStategy = forecastSaveResultStategy; + this.inferencer = inferencer; } @Override @@ -151,13 +141,11 @@ protected void doExecute(Task task, EntityResultRequest request, ActionListener< intervalDataProcessor = new EntityResultProcessor<>( entityCache, - manager, - timeSeriesStats, - entityColdStartWorker, checkpointReadQueue, coldEntityQueue, - forecastSaveResultStategy, - StatNames.FORECAST_MODEL_CORRUTPION_COUNT + inferencer, + threadPool, + TimeSeriesAnalyticsPlugin.FORECAST_THREAD_POOL_NAME ); stateManager diff --git a/src/main/java/org/opensearch/forecast/transport/ForecastImputeMissingValueAction.java b/src/main/java/org/opensearch/forecast/transport/ForecastImputeMissingValueAction.java new file mode 100644 index 000000000..8e6c59aaa --- /dev/null +++ b/src/main/java/org/opensearch/forecast/transport/ForecastImputeMissingValueAction.java @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.forecast.transport; + +import org.opensearch.action.ActionType; +import org.opensearch.action.support.master.AcknowledgedResponse; +import org.opensearch.forecast.constant.ForecastCommonValue; + +public class ForecastImputeMissingValueAction extends ActionType { + // Internal Action which is not used for public facing RestAPIs. + public static final String NAME = ForecastCommonValue.INTERNAL_ACTION_PREFIX + "impute"; + public static final ForecastImputeMissingValueAction INSTANCE = new ForecastImputeMissingValueAction(); + + public ForecastImputeMissingValueAction() { + super(NAME, AcknowledgedResponse::new); + } +} diff --git a/src/main/java/org/opensearch/forecast/transport/ForecastResultProcessor.java b/src/main/java/org/opensearch/forecast/transport/ForecastResultProcessor.java index 8489bf47f..bc8241e5a 100644 --- a/src/main/java/org/opensearch/forecast/transport/ForecastResultProcessor.java +++ b/src/main/java/org/opensearch/forecast/transport/ForecastResultProcessor.java @@ -46,7 +46,6 @@ public class ForecastResultProcessor extends public ForecastResultProcessor( Setting requestTimeoutSetting, - float intervalRatioForRequests, String entityResultAction, StatNames hcRequestCountStat, Settings settings, @@ -68,7 +67,6 @@ public ForecastResultProcessor( ) { super( requestTimeoutSetting, - intervalRatioForRequests, entityResultAction, hcRequestCountStat, settings, @@ -106,4 +104,8 @@ protected ForecastResultResponse createResultResponse( return new ForecastResultResponse(features, error, rcfTotalUpdates, configInterval, isHC, taskId); } + @Override + protected void imputeHC(long dataStartTime, long dataEndTime, String configID, String taskId) { + // no imputation for forecasting as on the fly imputation and error estimation should not mix + } } diff --git a/src/main/java/org/opensearch/forecast/transport/ForecastResultTransportAction.java b/src/main/java/org/opensearch/forecast/transport/ForecastResultTransportAction.java index 1db61e5d9..ba5dc64ea 100644 --- a/src/main/java/org/opensearch/forecast/transport/ForecastResultTransportAction.java +++ b/src/main/java/org/opensearch/forecast/transport/ForecastResultTransportAction.java @@ -43,7 +43,6 @@ import org.opensearch.timeseries.common.exception.TimeSeriesException; import org.opensearch.timeseries.constant.CommonMessages; import org.opensearch.timeseries.feature.FeatureManager; -import org.opensearch.timeseries.settings.TimeSeriesSettings; import org.opensearch.timeseries.stats.StatNames; import org.opensearch.timeseries.task.TaskCacheManager; import org.opensearch.timeseries.transport.ResultProcessor; @@ -149,7 +148,6 @@ protected void doExecute(Task task, ForecastResultRequest request, ActionListene this.resultProcessor = new ForecastResultProcessor( ForecastSettings.FORECAST_REQUEST_TIMEOUT, - TimeSeriesSettings.INTERVAL_RATIO_FOR_REQUESTS, EntityForecastResultAction.NAME, StatNames.FORECAST_HC_EXECUTE_REQUEST_COUNT, settings, diff --git a/src/main/java/org/opensearch/timeseries/transport/ForecastRunOnceProfileNodeRequest.java b/src/main/java/org/opensearch/forecast/transport/ForecastRunOnceProfileNodeRequest.java similarity index 88% rename from src/main/java/org/opensearch/timeseries/transport/ForecastRunOnceProfileNodeRequest.java rename to src/main/java/org/opensearch/forecast/transport/ForecastRunOnceProfileNodeRequest.java index 4c2895378..5c84d0668 100644 --- a/src/main/java/org/opensearch/timeseries/transport/ForecastRunOnceProfileNodeRequest.java +++ b/src/main/java/org/opensearch/forecast/transport/ForecastRunOnceProfileNodeRequest.java @@ -3,13 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.opensearch.timeseries.transport; +package org.opensearch.forecast.transport; import java.io.IOException; import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.common.io.stream.StreamOutput; -import org.opensearch.forecast.transport.ForecastRunOnceProfileRequest; import org.opensearch.transport.TransportRequest; public class ForecastRunOnceProfileNodeRequest extends TransportRequest { diff --git a/src/main/java/org/opensearch/forecast/transport/ForecastRunOnceProfileTransportAction.java b/src/main/java/org/opensearch/forecast/transport/ForecastRunOnceProfileTransportAction.java index a9fe218a8..156a34ff6 100644 --- a/src/main/java/org/opensearch/forecast/transport/ForecastRunOnceProfileTransportAction.java +++ b/src/main/java/org/opensearch/forecast/transport/ForecastRunOnceProfileTransportAction.java @@ -20,7 +20,6 @@ import org.opensearch.threadpool.ThreadPool; import org.opensearch.timeseries.transport.BooleanNodeResponse; import org.opensearch.timeseries.transport.BooleanResponse; -import org.opensearch.timeseries.transport.ForecastRunOnceProfileNodeRequest; import org.opensearch.transport.TransportService; public class ForecastRunOnceProfileTransportAction extends diff --git a/src/main/java/org/opensearch/forecast/transport/ForecastRunOnceTransportAction.java b/src/main/java/org/opensearch/forecast/transport/ForecastRunOnceTransportAction.java index 49eb2b995..f157c4d6f 100644 --- a/src/main/java/org/opensearch/forecast/transport/ForecastRunOnceTransportAction.java +++ b/src/main/java/org/opensearch/forecast/transport/ForecastRunOnceTransportAction.java @@ -68,7 +68,6 @@ import org.opensearch.timeseries.model.Config; import org.opensearch.timeseries.model.TaskState; import org.opensearch.timeseries.model.TimeSeriesTask; -import org.opensearch.timeseries.settings.TimeSeriesSettings; import org.opensearch.timeseries.stats.StatNames; import org.opensearch.timeseries.task.TaskCacheManager; import org.opensearch.timeseries.transport.ResultProcessor; @@ -303,7 +302,6 @@ private void triggerRunOnce(String forecastID, ForecastResultRequest request, Ac try { resultProcessor = new ForecastResultProcessor( ForecastSettings.FORECAST_REQUEST_TIMEOUT, - TimeSeriesSettings.INTERVAL_RATIO_FOR_REQUESTS, EntityForecastResultAction.NAME, StatNames.FORECAST_HC_EXECUTE_REQUEST_COUNT, settings, diff --git a/src/main/java/org/opensearch/forecast/transport/ForecastSingleStreamResultTransportAction.java b/src/main/java/org/opensearch/forecast/transport/ForecastSingleStreamResultTransportAction.java index 3672a3e43..5a0aee36b 100644 --- a/src/main/java/org/opensearch/forecast/transport/ForecastSingleStreamResultTransportAction.java +++ b/src/main/java/org/opensearch/forecast/transport/ForecastSingleStreamResultTransportAction.java @@ -16,6 +16,7 @@ import org.opensearch.forecast.indices.ForecastIndexManagement; import org.opensearch.forecast.ml.ForecastCheckpointDao; import org.opensearch.forecast.ml.ForecastColdStart; +import org.opensearch.forecast.ml.ForecastInferencer; import org.opensearch.forecast.ml.ForecastModelManager; import org.opensearch.forecast.ml.RCFCasterResult; import org.opensearch.forecast.model.ForecastResult; @@ -24,23 +25,21 @@ import org.opensearch.forecast.ratelimit.ForecastCheckpointWriteWorker; import org.opensearch.forecast.ratelimit.ForecastColdStartWorker; import org.opensearch.forecast.ratelimit.ForecastResultWriteRequest; -import org.opensearch.forecast.ratelimit.ForecastResultWriteWorker; import org.opensearch.forecast.ratelimit.ForecastSaveResultStrategy; -import org.opensearch.forecast.stats.ForecastStats; -import org.opensearch.forecast.transport.handler.ForecastIndexMemoryPressureAwareResultHandler; +import org.opensearch.threadpool.ThreadPool; import org.opensearch.timeseries.AnalysisType; import org.opensearch.timeseries.NodeStateManager; +import org.opensearch.timeseries.TimeSeriesAnalyticsPlugin; import org.opensearch.timeseries.breaker.CircuitBreakerService; import org.opensearch.timeseries.model.Config; import org.opensearch.timeseries.ratelimit.RequestPriority; -import org.opensearch.timeseries.stats.StatNames; import org.opensearch.timeseries.transport.AbstractSingleStreamResultTransportAction; import org.opensearch.transport.TransportService; import com.amazon.randomcutforest.parkservices.RCFCaster; public class ForecastSingleStreamResultTransportAction extends - AbstractSingleStreamResultTransportAction { + AbstractSingleStreamResultTransportAction { private static final Logger LOG = LogManager.getLogger(ForecastSingleStreamResultTransportAction.class); @@ -52,11 +51,8 @@ public ForecastSingleStreamResultTransportAction( ForecastCacheProvider cache, NodeStateManager stateManager, ForecastCheckpointReadWorker checkpointReadQueue, - ForecastModelManager modelManager, - ForecastIndexManagement indexUtil, - ForecastResultWriteWorker resultWriteQueue, - ForecastStats stats, - ForecastColdStartWorker forecastColdStartQueue + ForecastInferencer inferencer, + ThreadPool threadPool ) { super( transportService, @@ -65,15 +61,11 @@ public ForecastSingleStreamResultTransportAction( cache, stateManager, checkpointReadQueue, - modelManager, - indexUtil, - resultWriteQueue, - stats, - forecastColdStartQueue, ForecastSingleStreamResultAction.NAME, - ForecastIndex.RESULT, AnalysisType.FORECAST, - StatNames.FORECAST_MODEL_CORRUTPION_COUNT.getName() + inferencer, + threadPool, + TimeSeriesAnalyticsPlugin.FORECAST_THREAD_POOL_NAME ); } diff --git a/src/main/java/org/opensearch/forecast/transport/handler/ForecastIndexMemoryPressureAwareResultHandler.java b/src/main/java/org/opensearch/forecast/transport/handler/ForecastIndexMemoryPressureAwareResultHandler.java index df5ed807b..95ea64ef0 100644 --- a/src/main/java/org/opensearch/forecast/transport/handler/ForecastIndexMemoryPressureAwareResultHandler.java +++ b/src/main/java/org/opensearch/forecast/transport/handler/ForecastIndexMemoryPressureAwareResultHandler.java @@ -47,6 +47,7 @@ public void bulk(ForecastResultBulkRequest currentBulkRequest, ActionListenerwrap(response -> { LOG.debug(CommonMessages.SUCCESS_SAVING_RESULT_MSG); listener.onResponse(response); diff --git a/src/main/java/org/opensearch/timeseries/ExecuteResultResponseRecorder.java b/src/main/java/org/opensearch/timeseries/ExecuteResultResponseRecorder.java index 102eb2f6d..69a6caaf2 100644 --- a/src/main/java/org/opensearch/timeseries/ExecuteResultResponseRecorder.java +++ b/src/main/java/org/opensearch/timeseries/ExecuteResultResponseRecorder.java @@ -104,7 +104,6 @@ public void indexResult( ) { String configId = config.getId(); try { - if (!response.shouldSave()) { updateRealtimeTask(response, configId); return; @@ -115,7 +114,7 @@ public void indexResult( User user = config.getUser(); if (response.getError() != null) { - log.info("Result action run successfully for {} with error {}", configId, response.getError()); + log.info("Result action run for {} with error {}", configId, response.getError()); } List analysisResults = response diff --git a/src/main/java/org/opensearch/timeseries/TimeSeriesAnalyticsPlugin.java b/src/main/java/org/opensearch/timeseries/TimeSeriesAnalyticsPlugin.java index d935fd6ba..c1ce884a4 100644 --- a/src/main/java/org/opensearch/timeseries/TimeSeriesAnalyticsPlugin.java +++ b/src/main/java/org/opensearch/timeseries/TimeSeriesAnalyticsPlugin.java @@ -13,19 +13,11 @@ import static java.util.Collections.unmodifiableList; import static org.opensearch.ad.constant.ADCommonName.ANOMALY_RESULT_INDEX_ALIAS; -import static org.opensearch.ad.constant.ADCommonName.CHECKPOINT_INDEX_NAME; -import static org.opensearch.ad.constant.ADCommonName.DETECTION_STATE_INDEX; -import static org.opensearch.ad.indices.ADIndexManagement.ALL_AD_RESULTS_INDEX_PATTERN; import static org.opensearch.ad.settings.AnomalyDetectorSettings.AD_COOLDOWN_MINUTES; -import static org.opensearch.forecast.constant.ForecastCommonName.FORECAST_CHECKPOINT_INDEX_NAME; -import static org.opensearch.forecast.constant.ForecastCommonName.FORECAST_STATE_INDEX; -import static org.opensearch.timeseries.constant.CommonName.CONFIG_INDEX; -import static org.opensearch.timeseries.constant.CommonName.JOB_INDEX; import java.security.AccessController; import java.security.PrivilegedAction; import java.time.Clock; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -54,6 +46,7 @@ import org.opensearch.ad.indices.ADIndexManagement; import org.opensearch.ad.ml.ADCheckpointDao; import org.opensearch.ad.ml.ADColdStart; +import org.opensearch.ad.ml.ADInferencer; import org.opensearch.ad.ml.ADModelManager; import org.opensearch.ad.ml.HybridThresholdingModel; import org.opensearch.ad.model.AnomalyDetector; @@ -99,6 +92,8 @@ import org.opensearch.ad.transport.ADCancelTaskTransportAction; import org.opensearch.ad.transport.ADEntityProfileAction; import org.opensearch.ad.transport.ADEntityProfileTransportAction; +import org.opensearch.ad.transport.ADHCImputeAction; +import org.opensearch.ad.transport.ADHCImputeTransportAction; import org.opensearch.ad.transport.ADProfileAction; import org.opensearch.ad.transport.ADProfileTransportAction; import org.opensearch.ad.transport.ADResultBulkAction; @@ -181,6 +176,7 @@ import org.opensearch.forecast.indices.ForecastIndexManagement; import org.opensearch.forecast.ml.ForecastCheckpointDao; import org.opensearch.forecast.ml.ForecastColdStart; +import org.opensearch.forecast.ml.ForecastInferencer; import org.opensearch.forecast.ml.ForecastModelManager; import org.opensearch.forecast.model.ForecastResult; import org.opensearch.forecast.model.Forecaster; @@ -257,7 +253,6 @@ import org.opensearch.forecast.transport.ValidateForecasterTransportAction; import org.opensearch.forecast.transport.handler.ForecastIndexMemoryPressureAwareResultHandler; import org.opensearch.forecast.transport.handler.ForecastSearchHandler; -import org.opensearch.indices.SystemIndexDescriptor; import org.opensearch.jobscheduler.spi.JobSchedulerExtension; import org.opensearch.jobscheduler.spi.ScheduledJobParser; import org.opensearch.jobscheduler.spi.ScheduledJobRunner; @@ -266,7 +261,6 @@ import org.opensearch.plugins.ActionPlugin; import org.opensearch.plugins.Plugin; import org.opensearch.plugins.ScriptPlugin; -import org.opensearch.plugins.SystemIndexPlugin; import org.opensearch.repositories.RepositoriesService; import org.opensearch.rest.RestController; import org.opensearch.rest.RestHandler; @@ -323,7 +317,7 @@ /** * Entry point of time series analytics plugin. */ -public class TimeSeriesAnalyticsPlugin extends Plugin implements ActionPlugin, ScriptPlugin, SystemIndexPlugin, JobSchedulerExtension { +public class TimeSeriesAnalyticsPlugin extends Plugin implements ActionPlugin, ScriptPlugin, JobSchedulerExtension { private static final Logger LOG = LogManager.getLogger(TimeSeriesAnalyticsPlugin.class); @@ -817,7 +811,10 @@ public PooledObject wrap(LinkedBuffer obj) { StatNames.CONFIG_INDEX_STATUS.getName(), new TimeSeriesStat<>(true, new IndexStatusSupplier(indexUtils, CommonName.CONFIG_INDEX)) ) - .put(StatNames.JOB_INDEX_STATUS.getName(), new TimeSeriesStat<>(true, new IndexStatusSupplier(indexUtils, JOB_INDEX))) + .put( + StatNames.JOB_INDEX_STATUS.getName(), + new TimeSeriesStat<>(true, new IndexStatusSupplier(indexUtils, CommonName.JOB_INDEX)) + ) .put( StatNames.MODEL_COUNT.getName(), new TimeSeriesStat<>(false, new ADModelsOnNodeCountSupplier(adModelManager, adCacheProvider)) @@ -826,6 +823,16 @@ public PooledObject wrap(LinkedBuffer obj) { adStats = new ADStats(adStatsMap); + ADInferencer adInferencer = new ADInferencer( + adModelManager, + adStats, + adCheckpoint, + adColdstartQueue, + adSaveResultStrategy, + adCacheProvider, + threadPool + ); + ADCheckpointReadWorker adCheckpointReadQueue = new ADCheckpointReadWorker( heapSizeBytes, TimeSeriesSettings.FEATURE_REQUEST_SIZE_IN_BYTES, @@ -845,12 +852,10 @@ public PooledObject wrap(LinkedBuffer obj) { adCheckpoint, adColdstartQueue, stateManager, - anomalyDetectionIndices, adCacheProvider, TimeSeriesSettings.HOURLY_MAINTENANCE, adCheckpointWriteQueue, - adStats, - adSaveResultStrategy + adInferencer ); ADColdEntityWorker adColdEntityQueue = new ADColdEntityWorker( @@ -1199,12 +1204,25 @@ public PooledObject wrap(LinkedBuffer obj) { StatNames.CONFIG_INDEX_STATUS.getName(), new TimeSeriesStat<>(true, new IndexStatusSupplier(indexUtils, CommonName.CONFIG_INDEX)) ) - .put(StatNames.JOB_INDEX_STATUS.getName(), new TimeSeriesStat<>(true, new IndexStatusSupplier(indexUtils, JOB_INDEX))) + .put( + StatNames.JOB_INDEX_STATUS.getName(), + new TimeSeriesStat<>(true, new IndexStatusSupplier(indexUtils, CommonName.JOB_INDEX)) + ) .put(StatNames.MODEL_COUNT.getName(), new TimeSeriesStat<>(false, new ForecastModelsOnNodeCountSupplier(forecastCacheProvider))) .build(); forecastStats = new ForecastStats(forecastStatsMap); + ForecastInferencer forecastInferencer = new ForecastInferencer( + forecastModelManager, + forecastStats, + forecastCheckpoint, + forecastColdstartQueue, + forecastSaveResultStrategy, + forecastCacheProvider, + threadPool + ); + ForecastCheckpointReadWorker forecastCheckpointReadQueue = new ForecastCheckpointReadWorker( heapSizeBytes, TimeSeriesSettings.FEATURE_REQUEST_SIZE_IN_BYTES, @@ -1224,12 +1242,10 @@ public PooledObject wrap(LinkedBuffer obj) { forecastCheckpoint, forecastColdstartQueue, stateManager, - forecastIndices, forecastCacheProvider, TimeSeriesSettings.HOURLY_MAINTENANCE, forecastCheckpointWriteQueue, - forecastStats, - forecastSaveResultStrategy + forecastInferencer ); ForecastColdEntityWorker forecastColdEntityQueue = new ForecastColdEntityWorker( @@ -1350,6 +1366,7 @@ public PooledObject wrap(LinkedBuffer obj) { adIndexJobActionHandler, adSaveResultStrategy, new ADTaskProfileRunner(hashRing, client), + adInferencer, // forecast components forecastIndices, forecastStats, @@ -1368,12 +1385,13 @@ public PooledObject wrap(LinkedBuffer obj) { forecastIndexJobActionHandler, forecastTaskCacheManager, forecastSaveResultStrategy, - new ForecastTaskProfileRunner() + new ForecastTaskProfileRunner(), + forecastInferencer ); } /** - * createComponents doesn't work for Clock as ES process cannot start + * createComponents doesn't work for Clock as OS process cannot start * complaining it cannot find Clock instances for transport actions constructors. * @return a UTC clock */ @@ -1650,6 +1668,7 @@ public List getNamedXContent() { new ActionHandler<>(SearchTopAnomalyResultAction.INSTANCE, SearchTopAnomalyResultTransportAction.class), new ActionHandler<>(ValidateAnomalyDetectorAction.INSTANCE, ValidateAnomalyDetectorTransportAction.class), new ActionHandler<>(ADSingleStreamResultAction.INSTANCE, ADSingleStreamResultTransportAction.class), + new ActionHandler<>(ADHCImputeAction.INSTANCE, ADHCImputeTransportAction.class), // forecast new ActionHandler<>(IndexForecasterAction.INSTANCE, IndexForecasterTransportAction.class), new ActionHandler<>(ForecastResultAction.INSTANCE, ForecastResultTransportAction.class), @@ -1676,19 +1695,6 @@ public List getNamedXContent() { ); } - @Override - public Collection getSystemIndexDescriptors(Settings settings) { - List systemIndexDescriptors = new ArrayList<>(); - systemIndexDescriptors.add(new SystemIndexDescriptor(CONFIG_INDEX, "Time Series Analytics config index")); - systemIndexDescriptors.add(new SystemIndexDescriptor(ALL_AD_RESULTS_INDEX_PATTERN, "AD result index pattern")); - systemIndexDescriptors.add(new SystemIndexDescriptor(CHECKPOINT_INDEX_NAME, "AD Checkpoints index")); - systemIndexDescriptors.add(new SystemIndexDescriptor(DETECTION_STATE_INDEX, "AD State index")); - systemIndexDescriptors.add(new SystemIndexDescriptor(FORECAST_CHECKPOINT_INDEX_NAME, "Forecast Checkpoints index")); - systemIndexDescriptors.add(new SystemIndexDescriptor(FORECAST_STATE_INDEX, "Forecast state index")); - systemIndexDescriptors.add(new SystemIndexDescriptor(JOB_INDEX, "Time Series Analytics job index")); - return systemIndexDescriptors; - } - @Override public String getJobType() { return TIME_SERIES_JOB_TYPE; @@ -1696,7 +1702,7 @@ public String getJobType() { @Override public String getJobIndex() { - return JOB_INDEX; + return CommonName.JOB_INDEX; } @Override diff --git a/src/main/java/org/opensearch/timeseries/caching/PriorityCache.java b/src/main/java/org/opensearch/timeseries/caching/PriorityCache.java index 6f4f9dcfd..043c197cf 100644 --- a/src/main/java/org/opensearch/timeseries/caching/PriorityCache.java +++ b/src/main/java/org/opensearch/timeseries/caching/PriorityCache.java @@ -761,6 +761,21 @@ public List> getAllModels() { return states; } + /** + * Gets a config's modelStates hosted on a node + * + * @return list of modelStates + */ + @Override + public List> getAllModels(String configId) { + List> states = new ArrayList<>(); + CacheBufferType cacheBuffer = activeEnities.get(configId); + if (cacheBuffer != null) { + states.addAll(cacheBuffer.getAllModelStates()); + } + return states; + } + /** * Gets all of a config's model sizes hosted on a node * diff --git a/src/main/java/org/opensearch/timeseries/caching/TimeSeriesCache.java b/src/main/java/org/opensearch/timeseries/caching/TimeSeriesCache.java index f1a94d588..9d335a719 100644 --- a/src/main/java/org/opensearch/timeseries/caching/TimeSeriesCache.java +++ b/src/main/java/org/opensearch/timeseries/caching/TimeSeriesCache.java @@ -82,6 +82,13 @@ public interface TimeSeriesCache> getAllModels(); + /** + * Gets a config's modelStates hosted on a node + * + * @return list of modelStates + */ + List> getAllModels(String configId); + /** * Get the number of active entities of a config * @param configId Config Id diff --git a/src/main/java/org/opensearch/timeseries/constant/CommonMessages.java b/src/main/java/org/opensearch/timeseries/constant/CommonMessages.java index 330833a7e..8e0a7a537 100644 --- a/src/main/java/org/opensearch/timeseries/constant/CommonMessages.java +++ b/src/main/java/org/opensearch/timeseries/constant/CommonMessages.java @@ -43,7 +43,7 @@ public static String getTooManyCategoricalFieldErr(int limit) { public static String FAIL_TO_FIND_CONFIG_MSG = "Can't find config with id: "; public static final String CAN_NOT_CHANGE_CATEGORY_FIELD = "Can't change category field"; public static final String CAN_NOT_CHANGE_CUSTOM_RESULT_INDEX = "Can't change custom result index"; - public static final String CATEGORICAL_FIELD_TYPE_ERR_MSG = "A categorical field must be of type keyword or ip."; + public static final String CATEGORICAL_FIELD_TYPE_ERR_MSG = "Categorical field %s must be of type keyword or ip."; // Modifying message for FEATURE below may break the parseADValidationException method of ValidateAnomalyDetectorTransportAction public static final String FEATURE_INVALID_MSG_PREFIX = "Feature has an invalid query"; public static final String FEATURE_WITH_EMPTY_DATA_MSG = FEATURE_INVALID_MSG_PREFIX + " returning empty aggregated data: "; @@ -73,6 +73,7 @@ public static String getTooManyCategoricalFieldErr(int limit) { + TimeSeriesSettings.MAX_DESCRIPTION_LENGTH + " characters."; public static final String INDEX_NOT_FOUND = "index does not exist"; + public static final String FAIL_TO_GET_MAPPING_MSG = "Fail to get the index mapping of %s"; // ====================================== // Index message diff --git a/src/main/java/org/opensearch/timeseries/dataprocessor/ImputationMethod.java b/src/main/java/org/opensearch/timeseries/dataprocessor/ImputationMethod.java index 90494862c..564aea2bf 100644 --- a/src/main/java/org/opensearch/timeseries/dataprocessor/ImputationMethod.java +++ b/src/main/java/org/opensearch/timeseries/dataprocessor/ImputationMethod.java @@ -18,8 +18,4 @@ public enum ImputationMethod { * This method replaces missing values with the last known value in the respective input dimension. It's a commonly used method for time series data, where temporal continuity is expected. */ PREVIOUS, - /** - * This method estimates missing values by interpolating linearly between known values in the respective input dimension. This method assumes that the data follows a linear trend. - */ - LINEAR } diff --git a/src/main/java/org/opensearch/timeseries/dataprocessor/ImputationOption.java b/src/main/java/org/opensearch/timeseries/dataprocessor/ImputationOption.java index b163662a4..7147c753c 100644 --- a/src/main/java/org/opensearch/timeseries/dataprocessor/ImputationOption.java +++ b/src/main/java/org/opensearch/timeseries/dataprocessor/ImputationOption.java @@ -8,12 +8,10 @@ import static org.opensearch.core.xcontent.XContentParserUtils.ensureExpectedToken; import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; +import java.util.HashMap; import java.util.Locale; +import java.util.Map; import java.util.Objects; -import java.util.Optional; import org.apache.commons.lang.builder.ToStringBuilder; import org.opensearch.core.common.io.stream.StreamInput; @@ -22,53 +20,49 @@ import org.opensearch.core.xcontent.ToXContent; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.timeseries.model.DataByFeatureId; +import org.opensearch.timeseries.model.Feature; public class ImputationOption implements Writeable, ToXContent { // field name in toXContent public static final String METHOD_FIELD = "method"; public static final String DEFAULT_FILL_FIELD = "defaultFill"; - public static final String INTEGER_SENSITIVE_FIELD = "integerSensitive"; private final ImputationMethod method; - private final Optional defaultFill; - private final boolean integerSentive; + private final Map defaultFill; - public ImputationOption(ImputationMethod method, Optional defaultFill, boolean integerSentive) { + public ImputationOption(ImputationMethod method, Map defaultFill) { this.method = method; this.defaultFill = defaultFill; - this.integerSentive = integerSentive; } public ImputationOption(ImputationMethod method) { - this(method, Optional.empty(), false); + this(method, null); } public ImputationOption(StreamInput in) throws IOException { this.method = in.readEnum(ImputationMethod.class); if (in.readBoolean()) { - this.defaultFill = Optional.of(in.readDoubleArray()); + this.defaultFill = in.readMap(StreamInput::readString, StreamInput::readDouble); } else { - this.defaultFill = Optional.empty(); + this.defaultFill = null; } - this.integerSentive = in.readBoolean(); } @Override public void writeTo(StreamOutput out) throws IOException { out.writeEnum(method); - if (defaultFill.isEmpty()) { + if (defaultFill == null || defaultFill.isEmpty()) { out.writeBoolean(false); } else { out.writeBoolean(true); - out.writeDoubleArray(defaultFill.get()); + out.writeMap(defaultFill, StreamOutput::writeString, StreamOutput::writeDouble); } - out.writeBoolean(integerSentive); } public static ImputationOption parse(XContentParser parser) throws IOException { ImputationMethod method = ImputationMethod.ZERO; - List defaultFill = null; - Boolean integerSensitive = null; + Map defaultFill = null; ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.currentToken(), parser); while (parser.nextToken() != XContentParser.Token.END_OBJECT) { @@ -79,24 +73,40 @@ public static ImputationOption parse(XContentParser parser) throws IOException { method = ImputationMethod.valueOf(parser.text().toUpperCase(Locale.ROOT)); break; case DEFAULT_FILL_FIELD: + defaultFill = new HashMap<>(); ensureExpectedToken(XContentParser.Token.START_ARRAY, parser.currentToken(), parser); - defaultFill = new ArrayList<>(); - while (parser.nextToken() != XContentParser.Token.END_ARRAY) { - defaultFill.add(parser.doubleValue()); + while ((parser.nextToken()) != XContentParser.Token.END_ARRAY) { + + ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.currentToken(), parser); + + String featureName = null; + Double fillValue = null; + while (parser.nextToken() != XContentParser.Token.END_OBJECT) { + String fillFieldName = parser.currentName(); + parser.nextToken(); + + switch (fillFieldName) { + case Feature.FEATURE_NAME_FIELD: + featureName = parser.text(); + break; + case DataByFeatureId.DATA_FIELD: + fillValue = parser.doubleValue(); + break; + default: + // the unknown field and it's children should be ignored + parser.skipChildren(); + break; + } + } + + defaultFill.put(featureName, fillValue); } break; - case INTEGER_SENSITIVE_FIELD: - integerSensitive = parser.booleanValue(); - break; default: break; } } - return new ImputationOption( - method, - Optional.ofNullable(defaultFill).map(list -> list.stream().mapToDouble(Double::doubleValue).toArray()), - integerSensitive - ); + return new ImputationOption(method, defaultFill); } public XContentBuilder toXContent(XContentBuilder builder) throws IOException { @@ -109,10 +119,16 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.field(METHOD_FIELD, method); - if (!defaultFill.isEmpty()) { - builder.array(DEFAULT_FILL_FIELD, defaultFill.get()); + if (defaultFill != null && !defaultFill.isEmpty()) { + builder.startArray(DEFAULT_FILL_FIELD); + for (Map.Entry fill : defaultFill.entrySet()) { + builder.startObject(); + builder.field(Feature.FEATURE_NAME_FIELD, fill.getKey()); + builder.field(DataByFeatureId.DATA_FIELD, fill.getValue()); + builder.endObject(); + } + builder.endArray(); } - builder.field(INTEGER_SENSITIVE_FIELD, integerSentive); return xContentBuilder.endObject(); } @@ -126,34 +142,24 @@ public boolean equals(Object o) { } ImputationOption other = (ImputationOption) o; - return method == other.method - && (defaultFill.isEmpty() ? other.defaultFill.isEmpty() : Arrays.equals(defaultFill.get(), other.defaultFill.get())) - && integerSentive == other.integerSentive; + return method == other.method && Objects.equals(defaultFill, other.defaultFill); } @Override public int hashCode() { - return Objects.hash(method, (defaultFill.isEmpty() ? 0 : Arrays.hashCode(defaultFill.get())), integerSentive); + return Objects.hash(method, defaultFill); } @Override public String toString() { - return new ToStringBuilder(this) - .append("method", method) - .append("defaultFill", (defaultFill.isEmpty() ? null : Arrays.toString(defaultFill.get()))) - .append("integerSentive", integerSentive) - .toString(); + return new ToStringBuilder(this).append("method", method).append("defaultFill", defaultFill).toString(); } public ImputationMethod getMethod() { return method; } - public Optional getDefaultFill() { + public Map getDefaultFill() { return defaultFill; } - - public boolean isIntegerSentive() { - return integerSentive; - } } diff --git a/src/main/java/org/opensearch/timeseries/feature/AbstractRetriever.java b/src/main/java/org/opensearch/timeseries/feature/AbstractRetriever.java index 5f2609ed5..b5b747ae0 100644 --- a/src/main/java/org/opensearch/timeseries/feature/AbstractRetriever.java +++ b/src/main/java/org/opensearch/timeseries/feature/AbstractRetriever.java @@ -88,11 +88,11 @@ would produce an InternalFilter (a subtype of InternalSingleBucketAggregation) w .orElseThrow(() -> new EndRunException("Failed to parse aggregation " + aggregation, true).countedInStats(false)); } - protected Optional parseBucket(MultiBucketsAggregation.Bucket bucket, List featureIds) { - return parseAggregations(Optional.ofNullable(bucket).map(b -> b.getAggregations()), featureIds); + protected Optional parseBucket(MultiBucketsAggregation.Bucket bucket, List featureIds, boolean keepMissingValue) { + return parseAggregations(Optional.ofNullable(bucket).map(b -> b.getAggregations()), featureIds, keepMissingValue); } - protected Optional parseAggregations(Optional aggregations, List featureIds) { + protected Optional parseAggregations(Optional aggregations, List featureIds, boolean keepMissingValue) { return aggregations .map(aggs -> aggs.asMap()) .map( @@ -101,7 +101,16 @@ protected Optional parseAggregations(Optional aggregatio .mapToDouble(id -> Optional.ofNullable(map.get(id)).map(this::parseAggregation).orElse(Double.NaN)) .toArray() ) - .filter(result -> Arrays.stream(result).noneMatch(d -> Double.isNaN(d) || Double.isInfinite(d))); + .flatMap(result -> { + if (keepMissingValue) { + // Convert Double.isInfinite values to Double.NaN + return Optional.of(Arrays.stream(result).map(d -> Double.isInfinite(d) ? Double.NaN : d).toArray()); + } else { + // Return the array only if it contains no Double.NaN or Double.isInfinite + boolean noneNaNOrInfinite = Arrays.stream(result).noneMatch(d -> Double.isNaN(d) || Double.isInfinite(d)); + return noneNaNOrInfinite ? Optional.of(result) : Optional.empty(); + } + }); } protected void updateSourceAfterKey(Map afterKey, SearchSourceBuilder search) { diff --git a/src/main/java/org/opensearch/timeseries/feature/CompositeRetriever.java b/src/main/java/org/opensearch/timeseries/feature/CompositeRetriever.java index f4cae0c0e..767c6d2fe 100644 --- a/src/main/java/org/opensearch/timeseries/feature/CompositeRetriever.java +++ b/src/main/java/org/opensearch/timeseries/feature/CompositeRetriever.java @@ -296,7 +296,7 @@ private Page analyzePage(SearchResponse response) { } */ for (Bucket bucket : composite.getBuckets()) { - Optional featureValues = parseBucket(bucket, config.getEnabledFeatureIds()); + Optional featureValues = parseBucket(bucket, config.getEnabledFeatureIds(), true); // bucket.getKey() returns a map of categorical field like "host" and its value like "server_1" if (featureValues.isPresent() && bucket.getKey() != null) { results.put(Entity.createEntityByReordering(bucket.getKey()), featureValues.get()); diff --git a/src/main/java/org/opensearch/timeseries/feature/FeatureManager.java b/src/main/java/org/opensearch/timeseries/feature/FeatureManager.java index 3de670ffb..5130b3d2b 100644 --- a/src/main/java/org/opensearch/timeseries/feature/FeatureManager.java +++ b/src/main/java/org/opensearch/timeseries/feature/FeatureManager.java @@ -116,7 +116,7 @@ public void getCurrentFeatures( ) { List> missingRanges = Collections.singletonList(new SimpleImmutableEntry<>(startTime, endTime)); try { - searchFeatureDao.getFeatureSamplesForPeriods(config, missingRanges, context, ActionListener.wrap(points -> { + searchFeatureDao.getFeatureSamplesForPeriods(config, missingRanges, context, true, ActionListener.wrap(points -> { // we only have one point if (points.size() == 1) { Optional point = points.get(0); @@ -169,6 +169,7 @@ private void getColdStartSamples( config, sampleRanges, context, + false, new ThreadedActionListener<>( logger, threadPool, @@ -342,7 +343,7 @@ public void getPreviewFeatures(AnomalyDetector detector, long startMilli, long e int stride = sampleRangeResults.getValue(); int shingleSize = detector.getShingleSize(); - getSamplesForRanges(detector, sampleRanges, getFeatureSamplesListener(stride, shingleSize, listener)); + getSamplesForPreview(detector, sampleRanges, getFeatureSamplesListener(stride, shingleSize, listener)); } /** @@ -417,13 +418,13 @@ private ActionListener>> getSamplesRangesListener( * @param listener handle search results map: key is time ranges, value is corresponding search results * @throws IOException if a user gives wrong query input when defining a detector */ - void getSamplesForRanges( + void getSamplesForPreview( AnomalyDetector detector, List> sampleRanges, ActionListener>, double[][]>> listener ) throws IOException { searchFeatureDao - .getFeatureSamplesForPeriods(detector, sampleRanges, AnalysisType.AD, getSamplesRangesListener(sampleRanges, listener)); + .getFeatureSamplesForPeriods(detector, sampleRanges, AnalysisType.AD, false, getSamplesRangesListener(sampleRanges, listener)); } /** diff --git a/src/main/java/org/opensearch/timeseries/feature/SearchFeatureDao.java b/src/main/java/org/opensearch/timeseries/feature/SearchFeatureDao.java index 89ad90926..8c865a337 100644 --- a/src/main/java/org/opensearch/timeseries/feature/SearchFeatureDao.java +++ b/src/main/java/org/opensearch/timeseries/feature/SearchFeatureDao.java @@ -51,7 +51,6 @@ import org.opensearch.search.aggregations.AggregationBuilders; import org.opensearch.search.aggregations.Aggregations; import org.opensearch.search.aggregations.PipelineAggregatorBuilders; -import org.opensearch.search.aggregations.bucket.MultiBucketsAggregation; import org.opensearch.search.aggregations.bucket.composite.CompositeAggregation; import org.opensearch.search.aggregations.bucket.composite.InternalComposite; import org.opensearch.search.aggregations.bucket.composite.TermsValuesSourceBuilder; @@ -150,10 +149,10 @@ public SearchFeatureDao( } /** - * Returns to listener the epoch time of the latset data under the detector. + * Returns to listener the epoch time of the latest data under the detector. * * @param config info about the data - * @param listener onResponse is called with the epoch time of the latset data under the detector + * @param listener onResponse is called with the epoch time of the latest data under the detector */ public void getLatestDataTime(Config config, Optional entity, AnalysisType context, ActionListener> listener) { BoolQueryBuilder internalFilterQuery = QueryBuilders.boolQuery(); @@ -525,7 +524,7 @@ private Optional parseMinDataTime(SearchResponse searchResponse) { public void getFeaturesForPeriod(AnomalyDetector detector, long startTime, long endTime, ActionListener> listener) { SearchRequest searchRequest = createFeatureSearchRequest(detector, startTime, endTime, Optional.empty()); final ActionListener searchResponseListener = ActionListener - .wrap(response -> listener.onResponse(parseResponse(response, detector.getEnabledFeatureIds())), listener::onFailure); + .wrap(response -> listener.onResponse(parseResponse(response, detector.getEnabledFeatureIds(), true)), listener::onFailure); // using the original context in listener as user roles have no permissions for internal operations like fetching a // checkpoint clientUtil @@ -551,7 +550,7 @@ public void getFeaturesForPeriodByBatch( SearchRequest searchRequest = new SearchRequest(detector.getIndices().toArray(new String[0])).source(searchSourceBuilder); final ActionListener searchResponseListener = ActionListener.wrap(response -> { - listener.onResponse(parseBucketAggregationResponse(response, detector.getEnabledFeatureIds())); + listener.onResponse(parseBucketAggregationResponse(response, detector.getEnabledFeatureIds(), true)); }, listener::onFailure); // inject user role while searching. clientUtil @@ -565,31 +564,41 @@ public void getFeaturesForPeriodByBatch( ); } - private Map> parseBucketAggregationResponse(SearchResponse response, List featureIds) { + private Map> parseBucketAggregationResponse( + SearchResponse response, + List featureIds, + boolean keepMissingValue + ) { Map> dataPoints = new HashMap<>(); List aggregations = response.getAggregations().asList(); logger.debug("Feature aggregation result size {}", aggregations.size()); for (Aggregation agg : aggregations) { List buckets = ((InternalComposite) agg).getBuckets(); buckets.forEach(bucket -> { - Optional featureData = parseAggregations(Optional.ofNullable(bucket.getAggregations()), featureIds); + Optional featureData = parseAggregations( + Optional.ofNullable(bucket.getAggregations()), + featureIds, + keepMissingValue + ); dataPoints.put((Long) bucket.getKey().get(CommonName.DATE_HISTOGRAM), featureData); }); } return dataPoints; } - public Optional parseResponse(SearchResponse response, List featureIds) { - return parseAggregations(Optional.ofNullable(response).map(resp -> resp.getAggregations()), featureIds); + public Optional parseResponse(SearchResponse response, List featureIds, boolean keepMissingData) { + return parseAggregations(Optional.ofNullable(response).map(resp -> resp.getAggregations()), featureIds, keepMissingData); } /** - * Gets samples of features for the time ranges. + * Gets features for the time ranges. * - * Sampled features are not true features. They are intended to be approximate results produced at low costs. + * If called by preview API, sampled features are not true features. + * They are intended to be approximate results produced at low costs. * * @param config info about the indices, documents, feature query * @param ranges list of time ranges + * @param keepMissingValues whether to keep missing values or not in the result * @param listener handle approximate features for the time ranges * @throws IOException if a user gives wrong query input when defining a detector */ @@ -597,9 +606,10 @@ public void getFeatureSamplesForPeriods( Config config, List> ranges, AnalysisType context, + boolean keepMissingValues, ActionListener>> listener ) throws IOException { - SearchRequest request = createPreviewSearchRequest(config, ranges); + SearchRequest request = createRangeSearchRequest(config, ranges); final ActionListener searchResponseListener = ActionListener.wrap(response -> { Aggregations aggs = response.getAggregations(); if (aggs == null) { @@ -613,7 +623,7 @@ public void getFeatureSamplesForPeriods( .stream() .filter(InternalDateRange.class::isInstance) .flatMap(agg -> ((InternalDateRange) agg).getBuckets().stream()) - .map(bucket -> parseBucket(bucket, config.getEnabledFeatureIds())) + .map(bucket -> parseBucket(bucket, config.getEnabledFeatureIds(), keepMissingValues)) .collect(Collectors.toList()) ); }, listener::onFailure); @@ -640,9 +650,9 @@ private SearchRequest createFeatureSearchRequest(AnomalyDetector detector, long } } - private SearchRequest createPreviewSearchRequest(Config config, List> ranges) throws IOException { + private SearchRequest createRangeSearchRequest(Config config, List> ranges) throws IOException { try { - SearchSourceBuilder searchSourceBuilder = ParseUtils.generatePreviewQuery(config, ranges, xContent); + SearchSourceBuilder searchSourceBuilder = ParseUtils.generateRangeQuery(config, ranges, xContent); return new SearchRequest(config.getIndices().toArray(new String[0]), searchSourceBuilder); } catch (IOException e) { logger.warn("Failed to create feature search request for " + config.getId() + " for preview", e); @@ -752,7 +762,7 @@ public List> parseColdStartSampleResp(SearchResponse response .filter(bucket -> bucket.getFrom() != null && bucket.getFrom() instanceof ZonedDateTime) .filter(bucket -> bucket.getDocCount() > docCountThreshold) .sorted(Comparator.comparing((Bucket bucket) -> (ZonedDateTime) bucket.getFrom())) - .map(bucket -> parseBucket(bucket, config.getEnabledFeatureIds())) + .map(bucket -> parseBucket(bucket, config.getEnabledFeatureIds(), false)) .collect(Collectors.toList()); } @@ -801,7 +811,7 @@ public List parseColdStartSampleTimestamp(SearchResponse response, boolean .flatMap(agg -> ((InternalDateRange) agg).getBuckets().stream()) .filter(bucket -> bucket.getFrom() != null && bucket.getFrom() instanceof ZonedDateTime) .filter(bucket -> bucket.getDocCount() > docCountThreshold) - .filter(bucket -> parseBucket(bucket, config.getEnabledFeatureIds()).isPresent()) + .filter(bucket -> parseBucket(bucket, config.getEnabledFeatureIds(), false).isPresent()) .sorted(Comparator.comparing((Bucket bucket) -> (ZonedDateTime) bucket.getFrom())) .map(bucket -> ((ZonedDateTime) bucket.getFrom()).toInstant().toEpochMilli()) .collect(Collectors.toList()); @@ -851,11 +861,6 @@ public SearchRequest createColdStartFeatureSearchRequestForSingleFeature( } } - @Override - public Optional parseBucket(MultiBucketsAggregation.Bucket bucket, List featureIds) { - return parseAggregations(Optional.ofNullable(bucket).map(b -> b.getAggregations()), featureIds); - } - /** * Get train samples within a time range. * diff --git a/src/main/java/org/opensearch/timeseries/ml/Inferencer.java b/src/main/java/org/opensearch/timeseries/ml/Inferencer.java new file mode 100644 index 000000000..ff7cdca3a --- /dev/null +++ b/src/main/java/org/opensearch/timeseries/ml/Inferencer.java @@ -0,0 +1,190 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.timeseries.ml; + +import java.util.Collections; +import java.util.Locale; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.core.action.ActionListener; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.timeseries.caching.CacheProvider; +import org.opensearch.timeseries.caching.TimeSeriesCache; +import org.opensearch.timeseries.indices.IndexManagement; +import org.opensearch.timeseries.indices.TimeSeriesIndex; +import org.opensearch.timeseries.model.Config; +import org.opensearch.timeseries.model.IndexableResult; +import org.opensearch.timeseries.ratelimit.CheckpointWriteWorker; +import org.opensearch.timeseries.ratelimit.ColdStartWorker; +import org.opensearch.timeseries.ratelimit.FeatureRequest; +import org.opensearch.timeseries.ratelimit.RequestPriority; +import org.opensearch.timeseries.ratelimit.SaveResultStrategy; +import org.opensearch.timeseries.stats.Stats; +import org.opensearch.timeseries.util.TimeUtil; + +import com.amazon.randomcutforest.parkservices.ThresholdedRandomCutForest; + +public abstract class Inferencer, IndexType extends Enum & TimeSeriesIndex, IndexManagementType extends IndexManagement, CheckpointDaoType extends CheckpointDao, CheckpointWriterType extends CheckpointWriteWorker, ColdStarterType extends ModelColdStart, ModelManagerType extends ModelManager, SaveResultStrategyType extends SaveResultStrategy, CacheType extends TimeSeriesCache, ColdStartWorkerType extends ColdStartWorker> { + private static final Logger LOG = LogManager.getLogger(Inferencer.class); + protected ModelManagerType modelManager; + protected Stats stats; + private String modelCorruptionStat; + protected CheckpointDaoType checkpointDao; + protected ColdStartWorkerType coldStartWorker; + protected SaveResultStrategyType resultWriteWorker; + private CacheProvider cache; + private Map modelLocks = Collections.synchronizedMap(new WeakHashMap<>()); + private ThreadPool threadPool; + private String threadPoolName; + + public Inferencer( + ModelManagerType modelManager, + Stats stats, + String modelCorruptionStat, + CheckpointDaoType checkpointDao, + ColdStartWorkerType coldStartWorker, + SaveResultStrategyType resultWriteWorker, + CacheProvider cache, + ThreadPool threadPool, + String threadPoolName + ) { + this.modelManager = modelManager; + this.stats = stats; + this.modelCorruptionStat = modelCorruptionStat; + this.checkpointDao = checkpointDao; + this.coldStartWorker = coldStartWorker; + this.resultWriteWorker = resultWriteWorker; + this.cache = cache; + this.threadPool = threadPool; + this.threadPoolName = threadPoolName; + // WeakHashMap allows for automatic removal of entries when the key is no longer referenced elsewhere. + // This helps prevent memory leaks as the garbage collector can reclaim memory when modelId is no + // longer in use. + this.modelLocks = Collections.synchronizedMap(new WeakHashMap<>()); + } + + /** + * + * @param sample Sample to process + * @param modelState model state + * @param config Config accessor + * @param taskId task Id for batch analysis + * @return whether process succeeds or not + */ + public boolean process(Sample sample, ModelState modelState, Config config, String taskId) { + long expiryEpoch = TimeUtil.calculateTimeoutMillis(config, sample.getDataEndTime().toEpochMilli()); + return processWithTimeout(sample, modelState, config, taskId, expiryEpoch); + } + + private boolean processWithTimeout(Sample sample, ModelState modelState, Config config, String taskId, long expiryEpoch) { + String modelId = modelState.getModelId(); + ReentrantLock lock = (ReentrantLock) modelLocks.computeIfAbsent(modelId, k -> new ReentrantLock()); + + if (lock.tryLock()) { + try { + tryProcess(sample, modelState, config, taskId); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + return true; + } else { + if (System.currentTimeMillis() >= expiryEpoch) { + LOG.warn("Timeout reached, not retrying."); + } else { + // Schedule a retry in one second + threadPool + .schedule( + () -> processWithTimeout(sample, modelState, config, taskId, expiryEpoch), + new TimeValue(1, TimeUnit.SECONDS), + threadPoolName + ); + } + + return false; + } + } + + private boolean tryProcess(Sample sample, ModelState modelState, Config config, String taskId) { + String modelId = modelState.getModelId(); + try { + RCFResultType result = modelManager.getResult(sample, modelState, modelId, config, taskId); + resultWriteWorker + .saveResult( + result, + config, + sample.getDataStartTime(), + sample.getDataEndTime(), + modelId, + sample.getValueList(), + modelState.getEntity(), + taskId + ); + } catch (IllegalArgumentException e) { + if (e.getMessage() != null && e.getMessage().contains("incorrect ordering of time")) { + // ignore current timestamp. + LOG + .warn( + String + .format( + Locale.ROOT, + "incorrect ordering of time for config %s model %s at data end time %d", + config.getId(), + modelState.getModelId(), + sample.getDataEndTime().toEpochMilli() + ) + ); + } else { + reColdStart(config, modelId, e, sample, taskId); + } + return false; + } catch (Exception e) { + // e.g., null pointer exception when there is a bug in RCF + reColdStart(config, modelId, e, sample, taskId); + } + return true; + } + + private void reColdStart(Config config, String modelId, Exception e, Sample sample, String taskId) { + // fail to score likely due to model corruption. Re-cold start to recover. + LOG.error(new ParameterizedMessage("Likely model corruption for [{}]", modelId), e); + stats.getStat(modelCorruptionStat).increment(); + cache.get().removeModel(config.getId(), modelId); + if (null != modelId) { + checkpointDao + .deleteModelCheckpoint( + modelId, + ActionListener + .wrap( + r -> LOG.debug(new ParameterizedMessage("Succeeded in deleting checkpoint [{}].", modelId)), + ex -> LOG.error(new ParameterizedMessage("Failed to delete checkpoint [{}].", modelId), ex) + ) + ); + } + + coldStartWorker + .put( + new FeatureRequest( + System.currentTimeMillis() + config.getIntervalInMilliseconds(), + config.getId(), + RequestPriority.MEDIUM, + modelId, + sample.getValueList(), + sample.getDataStartTime().toEpochMilli(), + taskId + ) + ); + } +} diff --git a/src/main/java/org/opensearch/timeseries/ml/ModelColdStart.java b/src/main/java/org/opensearch/timeseries/ml/ModelColdStart.java index 3ccd00428..2cb0f0b17 100644 --- a/src/main/java/org/opensearch/timeseries/ml/ModelColdStart.java +++ b/src/main/java/org/opensearch/timeseries/ml/ModelColdStart.java @@ -463,8 +463,10 @@ private void getFeatures( // make sure the following logic making sense via checking lastRoundFirstStartTime > 0 if (lastRounddataSample != null && lastRounddataSample.size() > 0) { concatenatedDataSample = new ArrayList<>(); - concatenatedDataSample.addAll(lastRounddataSample); + // since we move farther in history in current one, last round data should come + // after current round data to keep time in sequence. concatenatedDataSample.addAll(samples); + concatenatedDataSample.addAll(lastRounddataSample); } else { concatenatedDataSample = samples; } @@ -525,12 +527,17 @@ public static > T applyImputatio case ZERO: return builder.imputationMethod(ImputationMethod.ZERO); case FIXED_VALUES: - // we did validate default fill is not empty and size matches enabled feature number in Config's constructor - return builder.imputationMethod(ImputationMethod.FIXED_VALUES).fillValues(imputationOption.getDefaultFill().get()); + // we did validate default fill is not empty, size matches enabled feature number in Config's constructor, + // and feature names matches existing features + List enabledFeatureName = config.getEnabledFeatureNames(); + double[] fillValues = new double[enabledFeatureName.size()]; + Map defaultFillMap = imputationOption.getDefaultFill(); + for (int i = 0; i < enabledFeatureName.size(); i++) { + fillValues[i] = defaultFillMap.get(enabledFeatureName.get(i)); + } + return builder.imputationMethod(ImputationMethod.FIXED_VALUES).fillValues(fillValues); case PREVIOUS: return builder.imputationMethod(ImputationMethod.PREVIOUS); - case LINEAR: - return builder.imputationMethod(ImputationMethod.LINEAR); default: // by default using last known value return builder.imputationMethod(ImputationMethod.PREVIOUS); diff --git a/src/main/java/org/opensearch/timeseries/ml/ModelManager.java b/src/main/java/org/opensearch/timeseries/ml/ModelManager.java index 273a580d9..d2e557be3 100644 --- a/src/main/java/org/opensearch/timeseries/ml/ModelManager.java +++ b/src/main/java/org/opensearch/timeseries/ml/ModelManager.java @@ -28,6 +28,7 @@ import org.opensearch.timeseries.model.Config; import org.opensearch.timeseries.model.IndexableResult; import org.opensearch.timeseries.ratelimit.CheckpointWriteWorker; +import org.opensearch.timeseries.util.DataUtil; import com.amazon.randomcutforest.RandomCutForest; import com.amazon.randomcutforest.parkservices.AnomalyDescriptor; @@ -130,15 +131,12 @@ protected void clearModelForIterator(String detectorId, Map models, I } } - @SuppressWarnings("unchecked") public IntermediateResultType score( Sample sample, String modelId, ModelState modelState, Config config ) { - - IntermediateResultType result = createEmptyResult(); Optional model = modelState.getModel(); try { if (model != null && model.isPresent()) { @@ -147,23 +145,22 @@ public IntermediateResultType score( if (!modelState.getSamples().isEmpty()) { for (Sample unProcessedSample : modelState.getSamples()) { // we are sure that the process method will indeed return an instance of RCFDescriptor. - rcfModel.process(unProcessedSample.getValueList(), unProcessedSample.getDataEndTime().getEpochSecond()); + double[] unProcessedPoint = unProcessedSample.getValueList(); + int[] missingIndices = DataUtil.generateMissingIndicesArray(unProcessedPoint); + rcfModel.process(unProcessedPoint, unProcessedSample.getDataEndTime().getEpochSecond(), missingIndices); } modelState.clearSamples(); } - RCFDescriptor lastResult = (RCFDescriptor) rcfModel - .process(sample.getValueList(), sample.getDataEndTime().getEpochSecond()); - if (lastResult != null) { - result = toResult(rcfModel.getForest(), lastResult); - } + return score(sample, config, rcfModel); } } catch (Exception e) { LOG .error( new ParameterizedMessage( - "Fail to score for [{}]: model Id [{}], feature [{}]", + "Fail to score for [{}] at [{}]: model Id [{}], feature [{}]", modelState.getEntity().isEmpty() ? modelState.getConfigId() : modelState.getEntity().get(), + sample.getDataEndTime().getEpochSecond(), modelId, Arrays.toString(sample.getValueList()) ), @@ -173,13 +170,28 @@ public IntermediateResultType score( } finally { modelState.setLastUsedTime(clock.instant()); } - return result; + return createEmptyResult(); + } + + @SuppressWarnings("unchecked") + public IntermediateResultType score(Sample sample, Config config, RCFModelType rcfModel) { + double[] point = sample.getValueList(); + + int[] missingValues = DataUtil.generateMissingIndicesArray(point); + RCFDescriptor lastResult = (RCFDescriptor) rcfModel.process(point, sample.getDataEndTime().getEpochSecond(), missingValues); + if (lastResult != null) { + return toResult(rcfModel.getForest(), lastResult, point, missingValues != null, config); + } + return createEmptyResult(); } protected abstract IntermediateResultType createEmptyResult(); protected abstract IntermediateResultType toResult( RandomCutForest forecast, - RCFDescriptor castDescriptor + RCFDescriptor castDescriptor, + double[] point, + boolean featureImputed, + Config config ); } diff --git a/src/main/java/org/opensearch/timeseries/ml/Sample.java b/src/main/java/org/opensearch/timeseries/ml/Sample.java index bc1212596..0089206ee 100644 --- a/src/main/java/org/opensearch/timeseries/ml/Sample.java +++ b/src/main/java/org/opensearch/timeseries/ml/Sample.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.Map; +import org.apache.commons.lang.builder.ToStringBuilder; import org.opensearch.core.xcontent.ToXContentObject; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.timeseries.annotation.Generated; @@ -31,7 +32,6 @@ public class Sample implements ToXContentObject { private final Instant dataEndTime; public Sample(double[] data, Instant dataStartTime, Instant dataEndTime) { - super(); this.data = data; this.dataStartTime = dataStartTime; this.dataEndTime = dataEndTime; @@ -82,6 +82,9 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws * Key: value_list, Value type: java.util.ArrayList * Item type: java.lang.Double * Value: 8840.0, Type: java.lang.Double + * Key: feature_imputed, Value type: java.util.ArrayList + * Item type: java.lang.Boolean + * Value: true, Type: java.lang.Boolean * @return a Sample. */ public static Sample extractSample(Map map) { @@ -102,7 +105,6 @@ public static Sample extractSample(Map map) { Instant dataEndTime = Instant.ofEpochMilli(dataEndTimeLong); Instant dataStartTime = Instant.ofEpochMilli(dataStartTimeLong); - // Create a new Sample object and return it return new Sample(data, dataStartTime, dataEndTime); } @@ -112,7 +114,11 @@ public boolean isInvalid() { @Override public String toString() { - return "Sample [data=" + Arrays.toString(data) + ", dataStartTime=" + dataStartTime + ", dataEndTime=" + dataEndTime + "]"; + return new ToStringBuilder(this) + .append("data", Arrays.toString(data)) + .append("dataStartTime", dataStartTime) + .append("dataEndTime", dataEndTime) + .toString(); } @Generated @@ -125,11 +131,6 @@ public boolean equals(Object o) { return false; } Sample sample = (Sample) o; - // a few fields not included: - // 1)didn't include uiMetadata since toXContent/parse will produce a map of map - // and cause the parsed one not equal to the original one. This can be confusing. - // 2)didn't include id, schemaVersion, and lastUpdateTime as we deemed equality based on contents. - // Including id fails tests like AnomalyDetectorExecutionInput.testParseAnomalyDetectorExecutionInput. return Arrays.equals(data, sample.data) && dataStartTime.truncatedTo(ChronoUnit.MILLIS).equals(sample.dataStartTime.truncatedTo(ChronoUnit.MILLIS)) && dataEndTime.truncatedTo(ChronoUnit.MILLIS).equals(sample.dataEndTime.truncatedTo(ChronoUnit.MILLIS)); @@ -138,6 +139,7 @@ public boolean equals(Object o) { @Generated @Override public int hashCode() { - return Objects.hashCode(data, dataStartTime.truncatedTo(ChronoUnit.MILLIS), dataEndTime.truncatedTo(ChronoUnit.MILLIS)); + return Objects + .hashCode(Arrays.hashCode(data), dataStartTime.truncatedTo(ChronoUnit.MILLIS), dataEndTime.truncatedTo(ChronoUnit.MILLIS)); } } diff --git a/src/main/java/org/opensearch/timeseries/model/Config.java b/src/main/java/org/opensearch/timeseries/model/Config.java index 1dfdfb54c..b1a968cd1 100644 --- a/src/main/java/org/opensearch/timeseries/model/Config.java +++ b/src/main/java/org/opensearch/timeseries/model/Config.java @@ -190,31 +190,6 @@ protected Config( return; } - if (imputationOption != null && imputationOption.getMethod() == ImputationMethod.FIXED_VALUES) { - Optional defaultFill = imputationOption.getDefaultFill(); - if (defaultFill.isEmpty()) { - issueType = ValidationIssueType.IMPUTATION; - errorMessage = "No given values for fixed value interpolation"; - return; - } - - // Calculate the number of enabled features - long expectedFeatures = features == null ? 0 : features.stream().filter(Feature::getEnabled).count(); - - // Check if the length of the defaultFill array matches the number of expected features - if (defaultFill.get().length != expectedFeatures) { - issueType = ValidationIssueType.IMPUTATION; - errorMessage = String - .format( - Locale.ROOT, - "Incorrect number of values to fill. Got: %d. Expected: %d.", - defaultFill.get().length, - expectedFeatures - ); - return; - } - } - if (recencyEmphasis != null && (recencyEmphasis <= 0)) { issueType = ValidationIssueType.RECENCY_EMPHASIS; errorMessage = "recency emphasis has to be a positive integer"; @@ -240,12 +215,50 @@ protected Config( return; } + if (imputationOption != null && imputationOption.getMethod() == ImputationMethod.FIXED_VALUES) { + Map defaultFill = imputationOption.getDefaultFill(); + if (defaultFill.isEmpty()) { + issueType = ValidationIssueType.IMPUTATION; + errorMessage = "No given values for fixed value interpolation"; + return; + } + + // Calculate the number of enabled features + List enabledFeatures = features == null + ? null + : features.stream().filter(Feature::getEnabled).collect(Collectors.toList()); + + // Check if the length of the defaultFill array matches the number of expected features + if (enabledFeatures == null || defaultFill.size() != enabledFeatures.size()) { + issueType = ValidationIssueType.IMPUTATION; + errorMessage = String + .format( + Locale.ROOT, + "Incorrect number of values to fill. Got: %d. Expected: %d.", + defaultFill.size(), + enabledFeatures == null ? 0 : enabledFeatures.size() + ); + return; + } + + Map defaultFills = imputationOption.getDefaultFill(); + + for (int i = 0; i < enabledFeatures.size(); i++) { + if (!defaultFills.containsKey(enabledFeatures.get(i).getName())) { + issueType = ValidationIssueType.IMPUTATION; + errorMessage = String.format(Locale.ROOT, "Missing feature name: %s.", enabledFeatures.get(i).getName()); + return; + } + } + } + this.id = id; this.version = version; this.name = name; this.description = description; this.timeField = timeField; this.indices = indices; + // we validate empty or no enabled features when starting config (Read IndexJobActionHandler.validateConfig) this.featureAttributes = features == null ? ImmutableList.of() : ImmutableList.copyOf(features); this.filterQuery = filterQuery; this.interval = interval; @@ -733,29 +746,27 @@ public static List findRedundantNames(List features) { @Generated @Override public String toString() { - return super.toString() - + ", " - + new ToStringBuilder(this) - .append("name", name) - .append("description", description) - .append("timeField", timeField) - .append("indices", indices) - .append("featureAttributes", featureAttributes) - .append("filterQuery", filterQuery) - .append("interval", interval) - .append("windowDelay", windowDelay) - .append("shingleSize", shingleSize) - .append("categoryFields", categoryFields) - .append("schemaVersion", schemaVersion) - .append("user", user) - .append("customResultIndex", customResultIndexOrAlias) - .append("imputationOption", imputationOption) - .append("recencyEmphasis", recencyEmphasis) - .append("seasonIntervals", seasonIntervals) - .append("historyIntervals", historyIntervals) - .append("customResultIndexMinSize", customResultIndexMinSize) - .append("customResultIndexMinAge", customResultIndexMinAge) - .append("customResultIndexTTL", customResultIndexTTL) - .toString(); + return new ToStringBuilder(this) + .append("name", name) + .append("description", description) + .append("timeField", timeField) + .append("indices", indices) + .append("featureAttributes", featureAttributes) + .append("filterQuery", filterQuery) + .append("interval", interval) + .append("windowDelay", windowDelay) + .append("shingleSize", shingleSize) + .append("categoryFields", categoryFields) + .append("schemaVersion", schemaVersion) + .append("user", user) + .append("customResultIndex", customResultIndexOrAlias) + .append("imputationOption", imputationOption) + .append("recencyEmphasis", recencyEmphasis) + .append("seasonIntervals", seasonIntervals) + .append("historyIntervals", historyIntervals) + .append("customResultIndexMinSize", customResultIndexMinSize) + .append("customResultIndexMinAge", customResultIndexMinAge) + .append("customResultIndexTTL", customResultIndexTTL) + .toString(); } } diff --git a/src/main/java/org/opensearch/timeseries/model/DataByFeatureId.java b/src/main/java/org/opensearch/timeseries/model/DataByFeatureId.java index c74679214..4098fb8d4 100644 --- a/src/main/java/org/opensearch/timeseries/model/DataByFeatureId.java +++ b/src/main/java/org/opensearch/timeseries/model/DataByFeatureId.java @@ -9,12 +9,14 @@ import java.io.IOException; +import org.apache.commons.lang.builder.ToStringBuilder; import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.common.io.stream.StreamOutput; import org.opensearch.core.common.io.stream.Writeable; import org.opensearch.core.xcontent.ToXContentObject; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.timeseries.annotation.Generated; import com.google.common.base.Objects; @@ -79,10 +81,12 @@ public static DataByFeatureId parse(XContentParser parser) throws IOException { @Override public boolean equals(Object o) { - if (this == o) + if (this == o) { return true; - if (o == null || getClass() != o.getClass()) + } + if (o == null || getClass() != o.getClass()) { return false; + } DataByFeatureId that = (DataByFeatureId) o; return Objects.equal(getFeatureId(), that.getFeatureId()) && Objects.equal(getData(), that.getData()); } @@ -100,10 +104,19 @@ public Double getData() { return data; } + public void setData(Double data) { + this.data = data; + } + @Override public void writeTo(StreamOutput out) throws IOException { out.writeString(featureId); out.writeDouble(data); } + @Generated + @Override + public String toString() { + return super.toString() + ", " + new ToStringBuilder(this).append("featureId", featureId).append("data", data).toString(); + } } diff --git a/src/main/java/org/opensearch/timeseries/model/Feature.java b/src/main/java/org/opensearch/timeseries/model/Feature.java index 045a6b96b..b3aec52a4 100644 --- a/src/main/java/org/opensearch/timeseries/model/Feature.java +++ b/src/main/java/org/opensearch/timeseries/model/Feature.java @@ -35,7 +35,7 @@ public class Feature implements Writeable, ToXContentObject { private static final String FEATURE_ID_FIELD = "feature_id"; - private static final String FEATURE_NAME_FIELD = "feature_name"; + public static final String FEATURE_NAME_FIELD = "feature_name"; private static final String FEATURE_ENABLED_FIELD = "feature_enabled"; private static final String AGGREGATION_QUERY = "aggregation_query"; @@ -135,10 +135,12 @@ public static Feature parse(XContentParser parser) throws IOException { @Generated @Override public boolean equals(Object o) { - if (this == o) + if (this == o) { return true; - if (o == null || getClass() != o.getClass()) + } + if (o == null || getClass() != o.getClass()) { return false; + } Feature feature = (Feature) o; return Objects.equal(getId(), feature.getId()) && Objects.equal(getName(), feature.getName()) diff --git a/src/main/java/org/opensearch/timeseries/ratelimit/CheckpointReadWorker.java b/src/main/java/org/opensearch/timeseries/ratelimit/CheckpointReadWorker.java index 2485def8a..41f145b72 100644 --- a/src/main/java/org/opensearch/timeseries/ratelimit/CheckpointReadWorker.java +++ b/src/main/java/org/opensearch/timeseries/ratelimit/CheckpointReadWorker.java @@ -44,6 +44,7 @@ import org.opensearch.timeseries.indices.IndexManagement; import org.opensearch.timeseries.indices.TimeSeriesIndex; import org.opensearch.timeseries.ml.CheckpointDao; +import org.opensearch.timeseries.ml.Inferencer; import org.opensearch.timeseries.ml.IntermediateResult; import org.opensearch.timeseries.ml.ModelColdStart; import org.opensearch.timeseries.ml.ModelManager; @@ -51,13 +52,12 @@ import org.opensearch.timeseries.ml.Sample; import org.opensearch.timeseries.model.Config; import org.opensearch.timeseries.model.IndexableResult; -import org.opensearch.timeseries.stats.StatNames; -import org.opensearch.timeseries.stats.Stats; +import org.opensearch.timeseries.util.ActionListenerExecutor; import org.opensearch.timeseries.util.ExceptionUtil; import com.amazon.randomcutforest.parkservices.ThresholdedRandomCutForest; -public abstract class CheckpointReadWorker, IndexType extends Enum & TimeSeriesIndex, IndexManagementType extends IndexManagement, CheckpointType extends CheckpointDao, CheckpointWriteWorkerType extends CheckpointWriteWorker, ColdStarterType extends ModelColdStart, ModelManagerType extends ModelManager, CacheType extends TimeSeriesCache, SaveResultStrategyType extends SaveResultStrategy, ColdStartWorkerType extends ColdStartWorker> +public abstract class CheckpointReadWorker, IndexType extends Enum & TimeSeriesIndex, IndexManagementType extends IndexManagement, CheckpointType extends CheckpointDao, CheckpointWriteWorkerType extends CheckpointWriteWorker, ColdStarterType extends ModelColdStart, ModelManagerType extends ModelManager, CacheType extends TimeSeriesCache, SaveResultStrategyType extends SaveResultStrategy, ColdStartWorkerType extends ColdStartWorker, InferencerType extends Inferencer> extends BatchWorker { private static final Logger LOG = LogManager.getLogger(CheckpointReadWorker.class); @@ -65,13 +65,10 @@ public abstract class CheckpointReadWorker> cacheProvider; protected final String checkpointIndexName; - protected final StatNames modelCorruptionStat; + protected final InferencerType inferencer; public CheckpointReadWorker( String workerName, @@ -94,17 +91,14 @@ public CheckpointReadWorker( CheckpointType checkpointDao, ColdStartWorkerType entityColdStartWorker, NodeStateManager stateManager, - IndexManagementType indexUtil, Provider> cacheProvider, Duration stateTtl, CheckpointWriteWorkerType checkpointWriteWorker, - Stats timeSeriesStats, Setting concurrencySetting, Setting batchSizeSetting, String checkpointIndexName, - StatNames modelCorruptionStat, AnalysisType context, - SaveResultStrategyType resultWriteWorker + InferencerType inferencer ) { super( workerName, @@ -133,13 +127,10 @@ public CheckpointReadWorker( this.modelManager = modelManager; this.checkpointDao = checkpointDao; this.coldStartWorker = entityColdStartWorker; - this.indexUtil = indexUtil; this.cacheProvider = cacheProvider; this.checkpointWriteWorker = checkpointWriteWorker; - this.timeSeriesStats = timeSeriesStats; this.checkpointIndexName = checkpointIndexName; - this.modelCorruptionStat = modelCorruptionStat; - this.resultWriteWorker = resultWriteWorker; + this.inferencer = inferencer; } @Override @@ -347,7 +338,7 @@ protected ActionListener> processIterationUsingConfig ModelState restoredModelState, String modelId ) { - return ActionListener.wrap(configOptional -> { + return ActionListenerExecutor.wrap(configOptional -> { if (configOptional.isEmpty()) { LOG.warn(new ParameterizedMessage("Config [{}] is not available.", configId)); processCheckpointIteration(index + 1, toProcess, successfulRequests, retryableRequests); @@ -356,51 +347,26 @@ protected ActionListener> processIterationUsingConfig Config config = configOptional.get(); - RCFResultType result = null; - try { - result = modelManager - .getResult( - new Sample( - origRequest.getCurrentFeature(), - Instant.ofEpochMilli(origRequest.getDataStartTimeMillis()), - Instant.ofEpochMilli(origRequest.getDataStartTimeMillis() + config.getIntervalInMilliseconds()) - ), - restoredModelState, - modelId, - config, - origRequest.getTaskId() - ); - } catch (IllegalArgumentException e) { - // fail to score likely due to model corruption. Re-cold start to recover. - LOG.error(new ParameterizedMessage("Likely model corruption for [{}]", origRequest.getModelId()), e); - timeSeriesStats.getStat(modelCorruptionStat.getName()).increment(); - if (null != origRequest.getModelId()) { - String entityModelId = origRequest.getModelId(); - checkpointDao - .deleteModelCheckpoint( - entityModelId, - ActionListener - .wrap( - r -> LOG.debug(new ParameterizedMessage("Succeeded in deleting checkpoint [{}].", entityModelId)), - ex -> LOG.error(new ParameterizedMessage("Failed to delete checkpoint [{}].", entityModelId), ex) - ) - ); + boolean processed = inferencer + .process( + new Sample( + origRequest.getCurrentFeature(), + Instant.ofEpochMilli(origRequest.getDataStartTimeMillis()), + Instant.ofEpochMilli(origRequest.getDataStartTimeMillis() + config.getIntervalInMilliseconds()) + ), + restoredModelState, + config, + origRequest.getTaskId() + ); + if (processed) { + // try to load to cache + boolean loaded = cacheProvider.get().hostIfPossible(config, restoredModelState); + + if (false == loaded) { + // not in memory. Maybe cold entities or some other entities + // have filled the slot while waiting for loading checkpoints. + checkpointWriteWorker.write(restoredModelState, true, RequestPriority.LOW); } - - coldStartWorker.put(origRequest); - processCheckpointIteration(index + 1, toProcess, successfulRequests, retryableRequests); - return; - } - - resultWriteWorker.saveResult(result, config, origRequest, modelId); - - // try to load to cache - boolean loaded = cacheProvider.get().hostIfPossible(config, restoredModelState); - - if (false == loaded) { - // not in memory. Maybe cold entities or some other entities - // have filled the slot while waiting for loading checkpoints. - checkpointWriteWorker.write(restoredModelState, true, RequestPriority.LOW); } processCheckpointIteration(index + 1, toProcess, successfulRequests, retryableRequests); @@ -408,6 +374,6 @@ protected ActionListener> processIterationUsingConfig LOG.error(new ParameterizedMessage("fail to get checkpoint [{}]", modelId, exception)); nodeStateManager.setException(configId, exception); processCheckpointIteration(index + 1, toProcess, successfulRequests, retryableRequests); - }); + }, threadPool.executor(threadPoolName)); } } diff --git a/src/main/java/org/opensearch/timeseries/ratelimit/ColdEntityWorker.java b/src/main/java/org/opensearch/timeseries/ratelimit/ColdEntityWorker.java index 703360a3f..5e3f20196 100644 --- a/src/main/java/org/opensearch/timeseries/ratelimit/ColdEntityWorker.java +++ b/src/main/java/org/opensearch/timeseries/ratelimit/ColdEntityWorker.java @@ -28,6 +28,7 @@ import org.opensearch.timeseries.indices.IndexManagement; import org.opensearch.timeseries.indices.TimeSeriesIndex; import org.opensearch.timeseries.ml.CheckpointDao; +import org.opensearch.timeseries.ml.Inferencer; import org.opensearch.timeseries.ml.IntermediateResult; import org.opensearch.timeseries.ml.ModelColdStart; import org.opensearch.timeseries.ml.ModelManager; @@ -35,7 +36,7 @@ import com.amazon.randomcutforest.parkservices.ThresholdedRandomCutForest; -public class ColdEntityWorker & TimeSeriesIndex, IndexManagementType extends IndexManagement, CheckpointDaoType extends CheckpointDao, RCFResultType extends IntermediateResult, ModelManagerType extends ModelManager, CheckpointWriteWorkerType extends CheckpointWriteWorker, ColdStarterType extends ModelColdStart, CacheType extends TimeSeriesCache, SaveResultStrategyType extends SaveResultStrategy, ColdStartWorkerType extends ColdStartWorker, CheckpointReadWorkerType extends CheckpointReadWorker> +public class ColdEntityWorker & TimeSeriesIndex, IndexManagementType extends IndexManagement, CheckpointDaoType extends CheckpointDao, RCFResultType extends IntermediateResult, ModelManagerType extends ModelManager, CheckpointWriteWorkerType extends CheckpointWriteWorker, ColdStarterType extends ModelColdStart, CacheType extends TimeSeriesCache, SaveResultStrategyType extends SaveResultStrategy, ColdStartWorkerType extends ColdStartWorker, InferencerType extends Inferencer, CheckpointReadWorkerType extends CheckpointReadWorker> extends ScheduledWorker { public ColdEntityWorker( diff --git a/src/main/java/org/opensearch/timeseries/rest/handler/AbstractTimeSeriesActionHandler.java b/src/main/java/org/opensearch/timeseries/rest/handler/AbstractTimeSeriesActionHandler.java index d37c60480..251512cff 100644 --- a/src/main/java/org/opensearch/timeseries/rest/handler/AbstractTimeSeriesActionHandler.java +++ b/src/main/java/org/opensearch/timeseries/rest/handler/AbstractTimeSeriesActionHandler.java @@ -411,7 +411,7 @@ private void onGetConfigResponse(GetResponse response, boolean indexingDryRun, S // If single-category HC changed category field from IP to error type, the AD result page may show both IP and error type // in top N entities list. That's confusing. // So we decide to block updating detector category field. - // for forecasting, we will not show results after forecaster configuration change (excluding changes like description) + // for forecasting, we will not show results after forecaster configuration change // thus it is safe to allow updating everything. In the future, we might change AD to allow such behavior. if (!canUpdateEverything) { if (!ParseUtils.listEqualsWithoutConsideringOrder(existingConfig.getCategoryFields(), config.getCategoryFields())) { @@ -435,15 +435,15 @@ private void onGetConfigResponse(GetResponse response, boolean indexingDryRun, S ); handler.confirmBatchRunning(id, batchTasks, confirmBatchRunningListener); - } catch (IOException e) { - String message = "Failed to parse anomaly detector " + id; + } catch (Exception e) { + String message = "Failed to parse config " + id; logger.error(message, e); listener.onFailure(new OpenSearchStatusException(message, RestStatus.INTERNAL_SERVER_ERROR)); } } - protected void validateAgainstExistingHCConfig(String detectorId, boolean indexingDryRun, ActionListener listener) { + protected void validateAgainstExistingHCConfig(String configId, boolean indexingDryRun, ActionListener listener) { if (timeSeriesIndices.doesConfigIndexExist()) { QueryBuilder query = QueryBuilders.boolQuery().filter(QueryBuilders.existsQuery(Config.CATEGORY_FIELD)); @@ -455,12 +455,12 @@ protected void validateAgainstExistingHCConfig(String detectorId, boolean indexi searchRequest, ActionListener .wrap( - response -> onSearchHCConfigResponse(response, detectorId, indexingDryRun, listener), + response -> onSearchHCConfigResponse(response, configId, indexingDryRun, listener), exception -> listener.onFailure(exception) ) ); } else { - validateCategoricalField(detectorId, indexingDryRun, listener); + validateCategoricalField(configId, indexingDryRun, listener); } } @@ -527,25 +527,14 @@ protected void onSearchHCConfigResponse(SearchResponse response, String detector } @SuppressWarnings("unchecked") - protected void validateCategoricalField(String detectorId, boolean indexingDryRun, ActionListener listener) { + protected void validateCategoricalField(String configId, boolean indexingDryRun, ActionListener listener) { List categoryField = config.getCategoryFields(); - if (categoryField == null) { - searchConfigInputIndices(detectorId, indexingDryRun, listener); - return; - } + // categoryField should have at least 1 element. Otherwise, we won't reach here. // we only support a certain number of categorical field // If there is more fields than required, Config's constructor - // throws validation exception before reaching this line - int maxCategoryFields = maxCategoricalFields; - if (categoryField.size() > maxCategoryFields) { - listener - .onFailure( - createValidationException(CommonMessages.getTooManyCategoricalFieldErr(maxCategoryFields), ValidationIssueType.CATEGORY) - ); - return; - } + // throws validation exception before reaching here String categoryField0 = categoryField.get(0); @@ -585,10 +574,8 @@ protected void validateCategoricalField(String detectorId, boolean indexingDryRu Map metadataMap = (Map) type; String typeName = (String) metadataMap.get(CommonName.TYPE); if (!typeName.equals(CommonName.KEYWORD_TYPE) && !typeName.equals(CommonName.IP_TYPE)) { - listener - .onFailure( - createValidationException(CATEGORICAL_FIELD_TYPE_ERR_MSG, ValidationIssueType.CATEGORY) - ); + String error = String.format(Locale.ROOT, CATEGORICAL_FIELD_TYPE_ERR_MSG, field2Metadata.getKey()); + listener.onFailure(createValidationException(error, ValidationIssueType.CATEGORY)); return; } } @@ -610,9 +597,9 @@ protected void validateCategoricalField(String detectorId, boolean indexingDryRu return; } - searchConfigInputIndices(detectorId, indexingDryRun, listener); + searchConfigInputIndices(configId, indexingDryRun, listener); }, error -> { - String message = String.format(Locale.ROOT, "Fail to get the index mapping of %s", config.getIndices()); + String message = String.format(Locale.ROOT, CommonMessages.FAIL_TO_GET_MAPPING_MSG, config.getIndices()); logger.error(message, error); listener.onFailure(new IllegalArgumentException(message)); }); @@ -621,7 +608,7 @@ protected void validateCategoricalField(String detectorId, boolean indexingDryRu .executeWithInjectedSecurity(GetFieldMappingsAction.INSTANCE, getMappingsRequest, user, client, context, mappingsListener); } - protected void searchConfigInputIndices(String detectorId, boolean indexingDryRun, ActionListener listener) { + protected void searchConfigInputIndices(String configId, boolean indexingDryRun, ActionListener listener) { SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder() .query(QueryBuilders.matchAllQuery()) .size(0) @@ -631,7 +618,7 @@ protected void searchConfigInputIndices(String detectorId, boolean indexingDryRu ActionListener searchResponseListener = ActionListener .wrap( - searchResponse -> onSearchConfigInputIndicesResponse(searchResponse, detectorId, indexingDryRun, listener), + searchResponse -> onSearchConfigInputIndicesResponse(searchResponse, configId, indexingDryRun, listener), exception -> listener.onFailure(exception) ); @@ -640,7 +627,7 @@ protected void searchConfigInputIndices(String detectorId, boolean indexingDryRu protected void onSearchConfigInputIndicesResponse( SearchResponse response, - String detectorId, + String configId, boolean indexingDryRun, ActionListener listener ) throws IOException { @@ -653,7 +640,7 @@ protected void onSearchConfigInputIndicesResponse( } listener.onFailure(new IllegalArgumentException(errorMsg)); } else { - validateConfigFeatures(detectorId, indexingDryRun, listener); + validateConfigFeatures(configId, indexingDryRun, listener); } } @@ -724,7 +711,6 @@ protected void finishConfigValidationOrContinueToModelValidation(ActionListener< } } - @SuppressWarnings("unchecked") protected void indexConfig(String id, ActionListener listener) throws IOException { Config copiedConfig = copyConfig(user, config); IndexRequest indexRequest = new IndexRequest(CommonName.CONFIG_INDEX) @@ -776,7 +762,7 @@ protected void onCreateMappingsResponse(CreateIndexResponse response, boolean in } } - protected String checkShardsFailure(IndexResponse response) { + public String checkShardsFailure(IndexResponse response) { StringBuilder failureReasons = new StringBuilder(); if (response.getShardInfo().getFailed() > 0) { for (ReplicationResponse.ShardInfo.Failure failure : response.getShardInfo().getFailures()) { @@ -832,7 +818,7 @@ protected void validateConfigFeatures(String id, boolean indexingDryRun, ActionL ssb.aggregation(internalAgg.getAggregatorFactories().iterator().next()); SearchRequest searchRequest = new SearchRequest().indices(config.getIndices().toArray(new String[0])).source(ssb); ActionListener searchResponseListener = ActionListener.wrap(response -> { - Optional aggFeatureResult = searchFeatureDao.parseResponse(response, Arrays.asList(feature.getId())); + Optional aggFeatureResult = searchFeatureDao.parseResponse(response, Arrays.asList(feature.getId()), false); if (aggFeatureResult.isPresent()) { multiFeatureQueriesResponseListener .onResponse( diff --git a/src/main/java/org/opensearch/timeseries/rest/handler/AggregationPrep.java b/src/main/java/org/opensearch/timeseries/rest/handler/AggregationPrep.java index 534e4b3b9..cafc3a021 100644 --- a/src/main/java/org/opensearch/timeseries/rest/handler/AggregationPrep.java +++ b/src/main/java/org/opensearch/timeseries/rest/handler/AggregationPrep.java @@ -72,6 +72,10 @@ public double getBucketHitRate(SearchResponse response, IntervalTimeConfiguratio } public double getHistorgramBucketHitRate(SearchResponse response) { + int numberOfSamples = getNumberOfSamples(); + if (numberOfSamples == 0) { + return 0; + } Histogram histogram = validateAndRetrieveHistogramAggregation(response); if (histogram == null || histogram.getBuckets() == null) { logger.warn("Empty histogram buckets"); @@ -80,7 +84,7 @@ public double getHistorgramBucketHitRate(SearchResponse response) { // getBuckets returns non-empty bucket (e.g., doc_count > 0) int bucketCount = histogram.getBuckets().size(); - return bucketCount / getNumberOfSamples(); + return bucketCount / numberOfSamples; } public List getTimestamps(SearchResponse response) { diff --git a/src/main/java/org/opensearch/timeseries/rest/handler/IntervalCalculation.java b/src/main/java/org/opensearch/timeseries/rest/handler/IntervalCalculation.java index 20c090d4b..5a174fe59 100644 --- a/src/main/java/org/opensearch/timeseries/rest/handler/IntervalCalculation.java +++ b/src/main/java/org/opensearch/timeseries/rest/handler/IntervalCalculation.java @@ -136,13 +136,13 @@ private int increaseAndGetNewInterval(IntervalTimeConfiguration oldInterval) { * Bucket aggregation with different interval lengths are executed one by one to check if the data is dense enough * We only need to execute the next query if the previous one led to data that is too sparse. */ - class IntervalRecommendationListener implements ActionListener { + public class IntervalRecommendationListener implements ActionListener { private final ActionListener intervalListener; IntervalTimeConfiguration currentIntervalToTry; private final long expirationEpochMs; private LongBounds currentTimeStampBounds; - IntervalRecommendationListener( + public IntervalRecommendationListener( ActionListener intervalListener, SearchSourceBuilder searchSourceBuilder, IntervalTimeConfiguration currentIntervalToTry, diff --git a/src/main/java/org/opensearch/timeseries/settings/TimeSeriesSettings.java b/src/main/java/org/opensearch/timeseries/settings/TimeSeriesSettings.java index c99ea519a..86ee34e88 100644 --- a/src/main/java/org/opensearch/timeseries/settings/TimeSeriesSettings.java +++ b/src/main/java/org/opensearch/timeseries/settings/TimeSeriesSettings.java @@ -98,11 +98,6 @@ public class TimeSeriesSettings { // ====================================== public static final int MAX_BATCH_TASK_PIECE_SIZE = 10_000; - // within an interval, how many percents are used to process requests. - // 1.0 means we use all of the detection interval to process requests. - // to ensure we don't block next interval, it is better to set it less than 1.0. - public static final float INTERVAL_RATIO_FOR_REQUESTS = 0.9f; - public static final Duration HOURLY_MAINTENANCE = Duration.ofHours(1); // Maximum number of deleted tasks can keep in cache. diff --git a/src/main/java/org/opensearch/timeseries/stats/StatNames.java b/src/main/java/org/opensearch/timeseries/stats/StatNames.java index 8ea32dffe..e06773015 100644 --- a/src/main/java/org/opensearch/timeseries/stats/StatNames.java +++ b/src/main/java/org/opensearch/timeseries/stats/StatNames.java @@ -20,8 +20,9 @@ */ public enum StatNames { // common stats - CONFIG_INDEX_STATUS("config_index_status", StatType.TIMESERIES), - JOB_INDEX_STATUS("job_index_status", StatType.TIMESERIES), + // keep the name the same for bwc + CONFIG_INDEX_STATUS("anomaly_detectors_index_status", StatType.TIMESERIES), + JOB_INDEX_STATUS("anomaly_detection_job_index_status", StatType.TIMESERIES), // AD stats AD_EXECUTE_REQUEST_COUNT("ad_execute_request_count", StatType.AD), AD_EXECUTE_FAIL_COUNT("ad_execute_failure_count", StatType.AD), @@ -31,7 +32,8 @@ public enum StatNames { SINGLE_STREAM_DETECTOR_COUNT("single_stream_detector_count", StatType.AD), HC_DETECTOR_COUNT("hc_detector_count", StatType.AD), ANOMALY_RESULTS_INDEX_STATUS("anomaly_results_index_status", StatType.AD), - AD_MODELS_CHECKPOINT_INDEX_STATUS("anomaly_models_checkpoint_index_status", StatType.AD), + // keep the name the same for bwc + AD_MODELS_CHECKPOINT_INDEX_STATUS("models_checkpoint_index_status", StatType.AD), ANOMALY_DETECTION_STATE_STATUS("anomaly_detection_state_status", StatType.AD), MODEL_INFORMATION("models", StatType.AD), AD_EXECUTING_BATCH_TASK_COUNT("ad_executing_batch_task_count", StatType.AD), diff --git a/src/main/java/org/opensearch/timeseries/transport/AbstractSingleStreamResultTransportAction.java b/src/main/java/org/opensearch/timeseries/transport/AbstractSingleStreamResultTransportAction.java index b5f08785a..3cf3aa8dc 100644 --- a/src/main/java/org/opensearch/timeseries/transport/AbstractSingleStreamResultTransportAction.java +++ b/src/main/java/org/opensearch/timeseries/transport/AbstractSingleStreamResultTransportAction.java @@ -6,7 +6,6 @@ package org.opensearch.timeseries.transport; import java.time.Instant; -import java.util.List; import java.util.Optional; import org.apache.logging.log4j.LogManager; @@ -17,6 +16,7 @@ import org.opensearch.action.support.master.AcknowledgedResponse; import org.opensearch.core.action.ActionListener; import org.opensearch.tasks.Task; +import org.opensearch.threadpool.ThreadPool; import org.opensearch.timeseries.AnalysisType; import org.opensearch.timeseries.NodeStateManager; import org.opensearch.timeseries.breaker.CircuitBreakerService; @@ -30,6 +30,7 @@ import org.opensearch.timeseries.indices.IndexManagement; import org.opensearch.timeseries.indices.TimeSeriesIndex; import org.opensearch.timeseries.ml.CheckpointDao; +import org.opensearch.timeseries.ml.Inferencer; import org.opensearch.timeseries.ml.IntermediateResult; import org.opensearch.timeseries.ml.ModelColdStart; import org.opensearch.timeseries.ml.ModelManager; @@ -44,31 +45,24 @@ import org.opensearch.timeseries.ratelimit.FeatureRequest; import org.opensearch.timeseries.ratelimit.RequestPriority; import org.opensearch.timeseries.ratelimit.ResultWriteRequest; -import org.opensearch.timeseries.ratelimit.ResultWriteWorker; import org.opensearch.timeseries.ratelimit.SaveResultStrategy; -import org.opensearch.timeseries.stats.Stats; -import org.opensearch.timeseries.transport.handler.IndexMemoryPressureAwareResultHandler; +import org.opensearch.timeseries.util.ActionListenerExecutor; import org.opensearch.timeseries.util.ExceptionUtil; -import org.opensearch.timeseries.util.ParseUtils; import org.opensearch.transport.TransportService; import com.amazon.randomcutforest.parkservices.ThresholdedRandomCutForest; -public abstract class AbstractSingleStreamResultTransportAction & TimeSeriesIndex, IndexManagementType extends IndexManagement, CheckpointDaoType extends CheckpointDao, CheckpointWriterType extends CheckpointWriteWorker, CheckpointMaintainerType extends CheckpointMaintainWorker, CacheBufferType extends CacheBuffer, PriorityCacheType extends PriorityCache, CacheProviderType extends CacheProvider, ResultType extends IndexableResult, RCFResultType extends IntermediateResult, ColdStarterType extends ModelColdStart, ModelManagerType extends ModelManager, CacheType extends TimeSeriesCache, SaveResultStrategyType extends SaveResultStrategy, ColdStartWorkerType extends ColdStartWorker, CheckpointReadWorkerType extends CheckpointReadWorker, ResultWriteRequestType extends ResultWriteRequest, BatchRequestType extends ResultBulkRequest, ResultHandlerType extends IndexMemoryPressureAwareResultHandler, ResultWriteWorkerType extends ResultWriteWorker> +public abstract class AbstractSingleStreamResultTransportAction & TimeSeriesIndex, IndexManagementType extends IndexManagement, CheckpointDaoType extends CheckpointDao, CheckpointWriterType extends CheckpointWriteWorker, CheckpointMaintainerType extends CheckpointMaintainWorker, CacheBufferType extends CacheBuffer, PriorityCacheType extends PriorityCache, CacheProviderType extends CacheProvider, ResultType extends IndexableResult, RCFResultType extends IntermediateResult, ColdStarterType extends ModelColdStart, ModelManagerType extends ModelManager, CacheType extends TimeSeriesCache, SaveResultStrategyType extends SaveResultStrategy, ColdStartWorkerType extends ColdStartWorker, InferencerType extends Inferencer, CheckpointReadWorkerType extends CheckpointReadWorker, ResultWriteRequestType extends ResultWriteRequest> extends HandledTransportAction { private static final Logger LOG = LogManager.getLogger(AbstractSingleStreamResultTransportAction.class); protected CircuitBreakerService circuitBreakerService; protected CacheProviderType cache; protected final NodeStateManager stateManager; protected CheckpointReadWorkerType checkpointReadQueue; - protected ModelManagerType modelManager; - protected IndexManagementType indexUtil; - protected ResultWriteWorkerType resultWriteQueue; - protected Stats stats; - protected ColdStartWorkerType coldStartWorker; - protected IndexType resultIndex; protected AnalysisType analysisType; - private String modelCorrptionStat; + private InferencerType inferencer; + private ThreadPool threadPool; + private String threadPoolName; public AbstractSingleStreamResultTransportAction( TransportService transportService, @@ -77,29 +71,21 @@ public AbstractSingleStreamResultTransportAction( CacheProviderType cache, NodeStateManager stateManager, CheckpointReadWorkerType checkpointReadQueue, - ModelManagerType modelManager, - IndexManagementType indexUtil, - ResultWriteWorkerType resultWriteQueue, - Stats stats, - ColdStartWorkerType coldStartQueue, String resultAction, - IndexType resultIndex, AnalysisType analysisType, - String modelCorrptionStat + InferencerType inferencer, + ThreadPool threadPool, + String threadPoolName ) { super(resultAction, transportService, actionFilters, SingleStreamResultRequest::new); this.circuitBreakerService = circuitBreakerService; this.cache = cache; this.stateManager = stateManager; this.checkpointReadQueue = checkpointReadQueue; - this.modelManager = modelManager; - this.indexUtil = indexUtil; - this.resultWriteQueue = resultWriteQueue; - this.stats = stats; - this.coldStartWorker = coldStartQueue; - this.resultIndex = resultIndex; this.analysisType = analysisType; - this.modelCorrptionStat = modelCorrptionStat; + this.inferencer = inferencer; + this.threadPool = threadPool; + this.threadPoolName = threadPoolName; } @Override @@ -141,7 +127,7 @@ public ActionListener> onGetConfig( SingleStreamResultRequest request, Optional prevException ) { - return ActionListener.wrap(configOptional -> { + return ActionListenerExecutor.wrap(configOptional -> { if (!configOptional.isPresent()) { listener.onFailure(new EndRunException(configId, "Config " + configId + " is not available.", false)); return; @@ -149,8 +135,6 @@ public ActionListener> onGetConfig( Config config = configOptional.get(); - Instant executionStartTime = Instant.now(); - String modelId = request.getModelId(); double[] datapoint = request.getDataPoint(); ModelState modelState = cache.get().get(modelId, config); @@ -169,54 +153,13 @@ public ActionListener> onGetConfig( ) ); } else { - try { - RCFResultType result = modelManager - .getResult( - new Sample(datapoint, Instant.ofEpochMilli(request.getStart()), Instant.ofEpochMilli(request.getEnd())), - modelState, - modelId, - config, - request.getTaskId() - ); - // result.getRcfScore() = 0 means the model is not initialized - if (result.getRcfScore() > 0) { - List indexableResults = result - .toIndexableResults( - config, - Instant.ofEpochMilli(request.getStart()), - Instant.ofEpochMilli(request.getEnd()), - executionStartTime, - Instant.now(), - ParseUtils.getFeatureData(datapoint, config), - Optional.empty(), - indexUtil.getSchemaVersion(resultIndex), - modelId, - null, - null - ); - - for (ResultType r : indexableResults) { - resultWriteQueue.put(createResultWriteRequest(config, r)); - } - } - } catch (IllegalArgumentException e) { - // fail to score likely due to model corruption. Re-cold start to recover. - LOG.error(new ParameterizedMessage("Likely model corruption for [{}]", modelId), e); - stats.getStat(modelCorrptionStat).increment(); - cache.get().removeModel(configId, modelId); - coldStartWorker - .put( - new FeatureRequest( - System.currentTimeMillis() + config.getIntervalInMilliseconds(), - configId, - RequestPriority.MEDIUM, - modelId, - datapoint, - request.getStart(), - request.getTaskId() - ) - ); - } + inferencer + .process( + new Sample(datapoint, Instant.ofEpochMilli(request.getStart()), Instant.ofEpochMilli(request.getEnd())), + modelState, + config, + request.getTaskId() + ); } // respond back @@ -229,7 +172,7 @@ public ActionListener> onGetConfig( LOG .error( new ParameterizedMessage( - "fail to get entity's result for config [{}]: start: [{}], end: [{}]", + "fail to get single stream result for config [{}]: start: [{}], end: [{}]", configId, request.getStart(), request.getEnd() @@ -237,7 +180,7 @@ public ActionListener> onGetConfig( exception ); listener.onFailure(exception); - }); + }, threadPool.executor(threadPoolName)); } public abstract ResultWriteRequestType createResultWriteRequest(Config config, ResultType result); diff --git a/src/main/java/org/opensearch/timeseries/transport/CronTransportAction.java b/src/main/java/org/opensearch/timeseries/transport/CronTransportAction.java index 0e4176273..25b7a2170 100644 --- a/src/main/java/org/opensearch/timeseries/transport/CronTransportAction.java +++ b/src/main/java/org/opensearch/timeseries/transport/CronTransportAction.java @@ -33,14 +33,12 @@ import org.opensearch.forecast.task.ForecastTaskManager; import org.opensearch.threadpool.ThreadPool; import org.opensearch.timeseries.NodeStateManager; -import org.opensearch.timeseries.feature.FeatureManager; import org.opensearch.transport.TransportService; public class CronTransportAction extends TransportNodesAction { private final Logger LOG = LogManager.getLogger(CronTransportAction.class); private NodeStateManager transportStateManager; private ADModelManager adModelManager; - private FeatureManager featureManager; private ADCacheProvider adCacheProvider; private ForecastCacheProvider forecastCacheProvider; private ADColdStart adEntityColdStarter; @@ -56,7 +54,6 @@ public CronTransportAction( ActionFilters actionFilters, NodeStateManager tarnsportStatemanager, ADModelManager adModelManager, - FeatureManager featureManager, ADCacheProvider adCacheProvider, ForecastCacheProvider forecastCacheProvider, ADColdStart adEntityColdStarter, @@ -77,7 +74,6 @@ public CronTransportAction( ); this.transportStateManager = tarnsportStatemanager; this.adModelManager = adModelManager; - this.featureManager = featureManager; this.adCacheProvider = adCacheProvider; this.forecastCacheProvider = forecastCacheProvider; this.adEntityColdStarter = adEntityColdStarter; diff --git a/src/main/java/org/opensearch/timeseries/transport/EntityResultProcessor.java b/src/main/java/org/opensearch/timeseries/transport/EntityResultProcessor.java index a0df0da92..484265b49 100644 --- a/src/main/java/org/opensearch/timeseries/transport/EntityResultProcessor.java +++ b/src/main/java/org/opensearch/timeseries/transport/EntityResultProcessor.java @@ -19,6 +19,7 @@ import org.apache.logging.log4j.message.ParameterizedMessage; import org.opensearch.action.support.master.AcknowledgedResponse; import org.opensearch.core.action.ActionListener; +import org.opensearch.threadpool.ThreadPool; import org.opensearch.timeseries.AnalysisType; import org.opensearch.timeseries.caching.CacheProvider; import org.opensearch.timeseries.caching.TimeSeriesCache; @@ -27,6 +28,7 @@ import org.opensearch.timeseries.indices.IndexManagement; import org.opensearch.timeseries.indices.TimeSeriesIndex; import org.opensearch.timeseries.ml.CheckpointDao; +import org.opensearch.timeseries.ml.Inferencer; import org.opensearch.timeseries.ml.IntermediateResult; import org.opensearch.timeseries.ml.ModelColdStart; import org.opensearch.timeseries.ml.ModelManager; @@ -42,8 +44,7 @@ import org.opensearch.timeseries.ratelimit.FeatureRequest; import org.opensearch.timeseries.ratelimit.RequestPriority; import org.opensearch.timeseries.ratelimit.SaveResultStrategy; -import org.opensearch.timeseries.stats.StatNames; -import org.opensearch.timeseries.stats.Stats; +import org.opensearch.timeseries.util.ActionListenerExecutor; import com.amazon.randomcutforest.parkservices.ThresholdedRandomCutForest; @@ -52,56 +53,50 @@ * (e.g., EntityForecastResultTransportAction) * */ -public class EntityResultProcessor, IndexType extends Enum & TimeSeriesIndex, IndexManagementType extends IndexManagement, CheckpointDaoType extends CheckpointDao, CheckpointWriteWorkerType extends CheckpointWriteWorker, ModelColdStartType extends ModelColdStart, ModelManagerType extends ModelManager, CacheType extends TimeSeriesCache, SaveResultStrategyType extends SaveResultStrategy, ColdStartWorkerType extends ColdStartWorker, HCCheckpointReadWorkerType extends CheckpointReadWorker, ColdEntityWorkerType extends ColdEntityWorker> { +public class EntityResultProcessor, IndexType extends Enum & TimeSeriesIndex, IndexManagementType extends IndexManagement, CheckpointDaoType extends CheckpointDao, CheckpointWriteWorkerType extends CheckpointWriteWorker, ModelColdStartType extends ModelColdStart, ModelManagerType extends ModelManager, CacheType extends TimeSeriesCache, SaveResultStrategyType extends SaveResultStrategy, ColdStartWorkerType extends ColdStartWorker, InferencerType extends Inferencer, HCCheckpointReadWorkerType extends CheckpointReadWorker, ColdEntityWorkerType extends ColdEntityWorker> { private static final Logger LOG = LogManager.getLogger(EntityResultProcessor.class); private CacheProvider cache; - private ModelManagerType modelManager; - private Stats stats; - private ColdStartWorkerType entityColdStartWorker; private HCCheckpointReadWorkerType checkpointReadQueue; private ColdEntityWorkerType coldEntityQueue; - private SaveResultStrategyType saveResultStrategy; - private StatNames modelCorruptionStat; + private InferencerType inferencer; + private ThreadPool threadPool; + private String threadPoolName; public EntityResultProcessor( CacheProvider cache, - ModelManagerType manager, - Stats stats, - ColdStartWorkerType entityColdStartWorker, HCCheckpointReadWorkerType checkpointReadQueue, ColdEntityWorkerType coldEntityQueue, - SaveResultStrategyType saveResultStrategy, - StatNames modelCorruptionStat + InferencerType inferencer, + ThreadPool threadPool, + String threadPoolName ) { this.cache = cache; - this.modelManager = manager; - this.stats = stats; - this.entityColdStartWorker = entityColdStartWorker; this.checkpointReadQueue = checkpointReadQueue; this.coldEntityQueue = coldEntityQueue; - this.saveResultStrategy = saveResultStrategy; - this.modelCorruptionStat = modelCorruptionStat; + this.inferencer = inferencer; + this.threadPool = threadPool; + this.threadPoolName = threadPoolName; } public ActionListener> onGetConfig( ActionListener listener, - String forecasterId, + String configId, EntityResultRequest request, Optional prevException, AnalysisType analysisType ) { - return ActionListener.wrap(configOptional -> { + return ActionListenerExecutor.wrap(configOptional -> { if (!configOptional.isPresent()) { - listener.onFailure(new EndRunException(forecasterId, "Config " + forecasterId + " is not available.", false)); + listener.onFailure(new EndRunException(configId, "Config " + configId + " is not available.", false)); return; } Config config = configOptional.get(); if (request.getEntities() == null) { - listener.onFailure(new EndRunException(forecasterId, "Fail to get any entities from request.", false)); + listener.onFailure(new EndRunException(configId, "Fail to get any entities from request.", false)); return; } @@ -115,7 +110,7 @@ public ActionListener> onGetConfig( entity = Entity.createSingleAttributeEntity(config.getCategoryFields().get(0), attrValues.get(CommonName.EMPTY_FIELD)); } - Optional modelIdOptional = entity.getModelId(forecasterId); + Optional modelIdOptional = entity.getModelId(configId); if (modelIdOptional.isEmpty()) { continue; } @@ -128,51 +123,19 @@ public ActionListener> onGetConfig( cacheMissEntities.put(entity, datapoint); continue; } - try { - IntermediateResultType result = modelManager - .getResult( - new Sample(datapoint, Instant.ofEpochMilli(request.getStart()), Instant.ofEpochMilli(request.getEnd())), - entityModel, - modelId, - config, - request.getTaskId() - ); - - saveResultStrategy - .saveResult( - result, - config, - Instant.ofEpochMilli(request.getStart()), - Instant.ofEpochMilli(request.getEnd()), - modelId, - datapoint, - Optional.of(entity), - request.getTaskId() - ); - } catch (IllegalArgumentException e) { - // fail to score likely due to model corruption. Re-cold start to recover. - LOG.error(new ParameterizedMessage("Likely model corruption for [{}]", modelId), e); - stats.getStat(modelCorruptionStat.getName()).increment(); - cache.get().removeModel(forecasterId, modelId); - entityColdStartWorker - .put( - new FeatureRequest( - System.currentTimeMillis() + config.getIntervalInMilliseconds(), - forecasterId, - RequestPriority.MEDIUM, - datapoint, - request.getStart(), - entity, - request.getTaskId() - ) - ); - } + inferencer + .process( + new Sample(datapoint, Instant.ofEpochMilli(request.getStart()), Instant.ofEpochMilli(request.getEnd())), + entityModel, + config, + request.getTaskId() + ); } // split hot and cold entities Pair, List> hotColdEntities = cache .get() - .selectUpdateCandidate(cacheMissEntities.keySet(), forecasterId, config); + .selectUpdateCandidate(cacheMissEntities.keySet(), configId, config); List hotEntityRequests = new ArrayList<>(); List coldEntityRequests = new ArrayList<>(); @@ -187,7 +150,7 @@ public ActionListener> onGetConfig( .add( new FeatureRequest( System.currentTimeMillis() + config.getIntervalInMilliseconds(), - forecasterId, + configId, // hot entities has MEDIUM priority RequestPriority.MEDIUM, hotEntityValue, @@ -208,7 +171,7 @@ public ActionListener> onGetConfig( .add( new FeatureRequest( System.currentTimeMillis() + config.getIntervalInMilliseconds(), - forecasterId, + configId, // cold entities has LOW priority RequestPriority.LOW, coldEntityValue, @@ -232,14 +195,14 @@ public ActionListener> onGetConfig( .error( new ParameterizedMessage( "fail to get entity's analysis result for config [{}]: start: [{}], end: [{}]", - forecasterId, + configId, request.getStart(), request.getEnd() ), exception ); listener.onFailure(exception); - }); + }, threadPool.executor(threadPoolName)); } /** diff --git a/src/main/java/org/opensearch/timeseries/transport/JobRequest.java b/src/main/java/org/opensearch/timeseries/transport/JobRequest.java index 98b56930f..ab00bfa2f 100644 --- a/src/main/java/org/opensearch/timeseries/transport/JobRequest.java +++ b/src/main/java/org/opensearch/timeseries/transport/JobRequest.java @@ -22,6 +22,7 @@ public class JobRequest extends ActionRequest { private String configID; + // data start/end time. See ADBatchTaskRunner.getDateRangeOfSourceData. private DateRange dateRange; private boolean historical; private String rawPath; diff --git a/src/main/java/org/opensearch/timeseries/transport/ResultProcessor.java b/src/main/java/org/opensearch/timeseries/transport/ResultProcessor.java index ffee7717f..b244ee4ac 100644 --- a/src/main/java/org/opensearch/timeseries/transport/ResultProcessor.java +++ b/src/main/java/org/opensearch/timeseries/transport/ResultProcessor.java @@ -12,7 +12,9 @@ package org.opensearch.timeseries.transport; import java.net.ConnectException; +import java.time.Instant; import java.util.ArrayList; +import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.Locale; @@ -20,6 +22,8 @@ import java.util.Map.Entry; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -79,8 +83,10 @@ import org.opensearch.timeseries.stats.Stats; import org.opensearch.timeseries.task.TaskCacheManager; import org.opensearch.timeseries.task.TaskManager; +import org.opensearch.timeseries.util.DataUtil; import org.opensearch.timeseries.util.ExceptionUtil; import org.opensearch.timeseries.util.SecurityClientUtil; +import org.opensearch.timeseries.util.TimeUtil; import org.opensearch.transport.ActionNotFoundTransportException; import org.opensearch.transport.ConnectTransportException; import org.opensearch.transport.NodeNotConnectedException; @@ -111,20 +117,16 @@ public abstract class ResultProcessor transportResultResponseClazz; private StatNames hcRequestCountStat; private String threadPoolName; - // within an interval, how many percents are used to process requests. - // 1.0 means we use all of the detection interval to process requests. - // to ensure we don't block next interval, it is better to set it less than 1.0. - private final float intervalRatioForRequest; private int maxEntitiesPerInterval; private int pageSize; protected final ThreadPool threadPool; - private final HashRing hashRing; + protected final HashRing hashRing; protected final NodeStateManager nodeStateManager; protected final TransportService transportService; private final Stats timeSeriesStats; private final TaskManagerType realTimeTaskManager; private NamedXContentRegistry xContentRegistry; - private final Client client; + protected final Client client; private final SecurityClientUtil clientUtil; private Settings settings; private final IndexNameExpressionResolver indexNameExpressionResolver; @@ -137,7 +139,6 @@ public abstract class ResultProcessor requestTimeoutSetting, - float intervalRatioForRequests, String entityResultAction, StatNames hcRequestCountStat, Settings settings, @@ -166,8 +167,6 @@ public ResultProcessor( .withType(TransportRequestOptions.Type.REG) .withTimeout(requestTimeoutSetting.get(settings)) .build(); - this.intervalRatioForRequest = intervalRatioForRequests; - this.maxEntitiesPerInterval = maxEntitiesPerIntervalSetting.get(settings); clusterService.getClusterSettings().addSettingsUpdateConsumer(maxEntitiesPerIntervalSetting, it -> maxEntitiesPerInterval = it); @@ -205,35 +204,40 @@ public ResultProcessor( class PageListener implements ActionListener { private PageIterator pageIterator; private String configId; + private Config config; private long dataStartTime; private long dataEndTime; - private Runnable finishRunnable; private String taskId; + private AtomicInteger receivedPages; + private AtomicInteger sentOutPages; - PageListener( - PageIterator pageIterator, - String detectorId, - long dataStartTime, - long dataEndTime, - Runnable finishRunnable, - String taskId - ) { + PageListener(PageIterator pageIterator, Config config, long dataStartTime, long dataEndTime, String taskId) { this.pageIterator = pageIterator; - this.configId = detectorId; + this.configId = config.getId(); + this.config = config; this.dataStartTime = dataStartTime; this.dataEndTime = dataEndTime; - this.finishRunnable = finishRunnable; this.taskId = taskId; + this.receivedPages = new AtomicInteger(); + this.sentOutPages = new AtomicInteger(); } @Override public void onResponse(CompositeRetriever.Page entityFeatures) { + // start processing next page after sending out features for previous page if (pageIterator.hasNext()) { pageIterator.next(this); - } else { - finishRunnable.run(); } if (entityFeatures != null && false == entityFeatures.isEmpty()) { + sentOutPages.incrementAndGet(); + + LOG + .info( + "Sending an HC request to process data from timestamp {} to {} for config {}", + dataStartTime, + dataEndTime, + configId + ); // wrap expensive operation inside ad threadpool threadPool.executor(threadPoolName).execute(() -> { try { @@ -269,7 +273,7 @@ public void onResponse(CompositeRetriever.Page entityFeatures) { String .format( Locale.ROOT, - ResultProcessor.NODE_UNRESPONSIVE_ERR_MSG + " %s for detector %s", + ResultProcessor.NODE_UNRESPONSIVE_ERR_MSG + " %s for config %s", modelNodeId, configId ) @@ -279,6 +283,7 @@ public void onResponse(CompositeRetriever.Page entityFeatures) { } final AtomicReference failure = new AtomicReference<>(); + node2Entities.stream().forEach(nodeEntity -> { DiscoveryNode node = nodeEntity.getKey(); transportService @@ -295,7 +300,7 @@ public void onResponse(CompositeRetriever.Page entityFeatures) { ), option, new ActionListenerResponseHandler<>( - new ErrorResponseListener(node.getId(), configId, failure), + new ErrorResponseListener(node.getId(), configId, failure, receivedPages), AcknowledgedResponse::new, ThreadPool.Names.SAME ) @@ -308,17 +313,23 @@ public void onResponse(CompositeRetriever.Page entityFeatures) { } }); } + + if (!pageIterator.hasNext() && config.getImputationOption() != null) { + if (sentOutPages.get() > 0) { + // at least 1 page sent out. Wait until all responses are back. + scheduleImputeHCTask(); + } else { + // no data in current interval. Send out impute request right away. + imputeHC(dataStartTime, dataEndTime, configId, taskId); + } + + } } @Override public void onFailure(Exception e) { - try { - LOG.error("Unexpetected exception", e); - handleException(e); - } finally { - // make sure we return listener - finishRunnable.run(); - } + LOG.error("Unexpetected exception", e); + handleException(e); } private void handleException(Exception e) { @@ -329,6 +340,47 @@ private void handleException(Exception e) { } nodeStateManager.setException(configId, convertedException); } + + /** + * Schedules imputeHC to after sent pages are equal to received pages at a fixed interval. + * + * We need to send impute request after ensuring it happens after all other entity feature requests. + * otherwise, we may rescore the same entity. + * + * If the condition is not met, it checks the condition regularly. The checker task is automatically + * canceled and the scheduler is shut down after a specified timeout period. + */ + private void scheduleImputeHCTask() { + AtomicReference cancellable = new AtomicReference<>(); + AtomicBoolean sent = new AtomicBoolean(); + + final Runnable checkerTask = new Runnable() { + private final long timeoutMillis = TimeUtil.calculateTimeoutMillis(config, dataEndTime); + + @Override + public void run() { + if (sentOutPages.get() == receivedPages.get()) { + if (!sent.get()) { + // since we don't know when cancel will succeed, need sent to ensure imputeHC is only called once + sent.set(true); + imputeHC(dataStartTime, dataEndTime, configId, taskId); + } + + if (cancellable.get() != null) { + cancellable.get().cancel(); + } + } else if (Instant.now().toEpochMilli() >= timeoutMillis) { + LOG.warn("Scheduled impute HC task is cancelled due to timeout"); + if (cancellable != null) { + cancellable.get().cancel(); + } + } + } + }; + + // Schedule the task at a 2 second interval + cancellable.set(threadPool.scheduleWithFixedDelay(checkerTask, TimeValue.timeValueSeconds(2), threadPoolName)); + } } public ActionListener> onGetConfig( @@ -437,7 +489,7 @@ private void executeAnalysis( } // assume request are in epoch milliseconds - long nextDetectionStartTime = request.getEnd() + (long) (config.getIntervalInMilliseconds() * intervalRatioForRequest); + long nextDetectionStartTime = request.getEnd() + config.getIntervalInMilliseconds(); CompositeRetriever compositeRetriever = new CompositeRetriever( dataStartTime, @@ -464,38 +516,23 @@ private void executeAnalysis( return; } - Runnable finishRunnable = () -> { - // When pagination finishes or the time is up, - // return response or exceptions. - if (previousException.isPresent()) { - listener.onFailure(previousException.get()); - } else { - listener - .onResponse( - createResultResponse(new ArrayList(), null, null, config.getIntervalInMinutes(), true, taskId) - ); - } - }; + PageListener getEntityFeatureslistener = new PageListener(pageIterator, config, dataStartTime, dataEndTime, taskId); - PageListener getEntityFeatureslistener = new PageListener( - pageIterator, - configID, - dataStartTime, - dataEndTime, - finishRunnable, - taskId - ); + // hasNext is always true unless time is up at this point (won't happen in normal cases) if (pageIterator.hasNext()) { - LOG - .info( - "Sending an HC request to process data from timestamp {} to {} for config {}", - dataStartTime, - dataEndTime, - configID - ); pageIterator.next(getEntityFeatureslistener); + } else if (config.getImputationOption() != null) { + imputeHC(dataStartTime, dataEndTime, configID, taskId); + } + + // return early to not wait for completion of all entities so we won't block next interval + if (previousException.isPresent()) { + listener.onFailure(previousException.get()); } else { - finishRunnable.run(); + listener + .onResponse( + createResultResponse(new ArrayList(), null, null, config.getIntervalInMinutes(), true, taskId) + ); } return; @@ -513,6 +550,7 @@ private void executeAnalysis( DiscoveryNode rcfNode = asRCFNode.get(); + // early return listener in shouldStart if (!shouldStart(listener, configID, config, rcfNode.getId(), rcfModelID)) { return; } @@ -587,9 +625,9 @@ protected void findException(Throwable cause, String configID, AtomicReference actualException = NotSerializedExceptionName .convertWrappedTimeSeriesException((NotSerializableExceptionWrapper) causeException, configID); if (actualException.isPresent()) { - TimeSeriesException adException = actualException.get(); - failure.set(adException); - if (adException instanceof ResourceNotFoundException) { + TimeSeriesException tsException = actualException.get(); + failure.set(tsException); + if (tsException instanceof ResourceNotFoundException) { // During a rolling upgrade or blue/green deployment, ResourceNotFoundException might be caused by old node using RCF // 1.0 // cannot recognize new checkpoint produced by the coordinating node using compact RCF. Add pressure to mute the node @@ -764,16 +802,19 @@ public class ErrorResponseListener implements ActionListener failure; + private AtomicInteger receivedPages; - public ErrorResponseListener(String nodeId, String configId, AtomicReference failure) { + public ErrorResponseListener(String nodeId, String configId, AtomicReference failure, AtomicInteger receivedPage) { this.nodeId = nodeId; this.configId = configId; this.failure = failure; + this.receivedPages = receivedPage; } @Override public void onResponse(AcknowledgedResponse response) { try { + receivedPages.incrementAndGet(); if (response.isAcknowledged() == false) { LOG.error("Cannot send entities' features to {} for {}", nodeId, configId); nodeStateManager.addPressure(nodeId, configId); @@ -789,6 +830,7 @@ public void onResponse(AcknowledgedResponse response) { @Override public void onFailure(Exception e) { try { + receivedPages.incrementAndGet(); // e.g., we have connection issues with all of the nodes while restarting clusters LOG.error(new ParameterizedMessage("Cannot send entities' features to {} for {}", nodeId, configId), e); @@ -832,34 +874,68 @@ protected ActionListener> onFeatureResponseForSingleStreamCon } } - if (featureOptional.isEmpty()) { + if ((featureOptional.isEmpty() || DataUtil.areAnyElementsNaN(featureOptional.get())) && config.getImputationOption() == null) { // Feature not available is common when we have data holes. Respond empty response // and don't log to avoid bloating our logs. - LOG.debug("No data in current window between {} and {} for {}", dataStartTime, dataEndTime, configId); listener - .onResponse(createResultResponse(new ArrayList(), "No data in current window", null, null, false, taskId)); + .onResponse( + createResultResponse( + new ArrayList(), + String + .format( + Locale.ROOT, + "No data in current window between %d and %d for %s", + dataStartTime, + dataEndTime, + configId + ), + null, + null, + false, + taskId + ) + ); return; } final AtomicReference failure = new AtomicReference(); - LOG - .info( - "Sending a single stream request to node {} to process data from timestamp {} to {} for config {}", - rcfNode.getId(), - dataStartTime, - dataEndTime, - configId - ); + double[] point = null; + if (featureOptional.isPresent()) { + point = featureOptional.get(); + } else { + int featureSize = config.getEnabledFeatureIds().size(); + point = new double[featureSize]; + Arrays.fill(point, Double.NaN); + } + if (DataUtil.areAnyElementsNaN(point)) { + LOG + .info( + "Sending a single stream request to node {} to impute/process data from timestamp {} to {} for config {}", + rcfNode.getId(), + dataStartTime, + dataEndTime, + configId + ); + } else { + LOG + .info( + "Sending a single stream request to node {} to process data from timestamp {} to {} for config {}", + rcfNode.getId(), + dataStartTime, + dataEndTime, + configId + ); + } transportService .sendRequest( rcfNode, singleStreamActionName, - new SingleStreamResultRequest(configId, rcfModelId, dataStartTime, dataEndTime, featureOptional.get(), taskId), + new SingleStreamResultRequest(configId, rcfModelId, dataStartTime, dataEndTime, point, taskId), option, new ActionListenerResponseHandler<>( - new ErrorResponseListener(rcfNode.getId(), configId, failure), + new ErrorResponseListener(rcfNode.getId(), configId, failure, new AtomicInteger()), AcknowledgedResponse::new, ThreadPool.Names.SAME ) @@ -867,18 +943,13 @@ protected ActionListener> onFeatureResponseForSingleStreamCon if (previousException.isPresent()) { listener.onFailure(previousException.get()); - } else if (featureOptional.isEmpty()) { - // Feature not available is common when we have data holes. Respond empty response - // and don't log to avoid bloating our logs. - LOG.debug("No data in current window between {} and {} for {}", dataStartTime, dataEndTime, configId); - listener - .onResponse(createResultResponse(new ArrayList(), "No data in current window", null, null, false, taskId)); } else { listener .onResponse( createResultResponse(new ArrayList(), null, null, config.getIntervalInMinutes(), true, taskId) ); } + }, exception -> { handleQueryFailure(exception, listener, configId); }); } @@ -890,4 +961,6 @@ protected abstract ResultResponseType createResultResponse( Boolean isHC, String taskId ); + + protected abstract void imputeHC(long dataStartTime, long dataEndTime, String configID, String taskId); } diff --git a/src/main/java/org/opensearch/timeseries/util/ActionListenerExecutor.java b/src/main/java/org/opensearch/timeseries/util/ActionListenerExecutor.java new file mode 100644 index 000000000..b4cea8ebb --- /dev/null +++ b/src/main/java/org/opensearch/timeseries/util/ActionListenerExecutor.java @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.timeseries.util; + +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; + +import org.opensearch.common.CheckedConsumer; +import org.opensearch.core.action.ActionListener; + +public class ActionListenerExecutor { + /* + * Private constructor to avoid Jacoco complaining about public constructor + * not covered: https://tinyurl.com/yetc7tra + */ + private ActionListenerExecutor() {} + + /** + * Wraps the provided response and failure handlers in an ActionListener that executes the + * response handler asynchronously using the provided ExecutorService. + * + * @param the type of the response + * @param onResponse a CheckedConsumer that handles the response; it can throw an exception + * @param onFailure a Consumer that handles any exceptions thrown by the onResponse handler or the onFailure method + * @param executorService the ExecutorService used to execute the onResponse handler asynchronously + * @return an ActionListener that handles the response and failure cases + */ + public static ActionListener wrap( + CheckedConsumer onResponse, + Consumer onFailure, + ExecutorService executorService + ) { + return new ActionListener() { + @Override + public void onResponse(Response response) { + executorService.execute(() -> { + try { + onResponse.accept(response); + } catch (Exception e) { + onFailure(e); + } + }); + } + + @Override + public void onFailure(Exception e) { + onFailure.accept(e); + } + }; + } +} diff --git a/src/main/java/org/opensearch/timeseries/util/BulkUtil.java b/src/main/java/org/opensearch/timeseries/util/BulkUtil.java index c2b275a1f..6b801da7c 100644 --- a/src/main/java/org/opensearch/timeseries/util/BulkUtil.java +++ b/src/main/java/org/opensearch/timeseries/util/BulkUtil.java @@ -36,8 +36,11 @@ public static List getFailedIndexRequest(BulkRequest bulkRequest, Set failedId = new HashSet<>(); for (BulkItemResponse response : bulkResponse.getItems()) { - if (response.isFailed() && ExceptionUtil.isRetryAble(response.getFailure().getStatus())) { - failedId.add(response.getId()); + if (response.isFailed()) { + logger.info("bulk indexing failure: {}", response.getFailureMessage()); + if (ExceptionUtil.isRetryAble(response.getFailure().getStatus())) { + failedId.add(response.getId()); + } } } diff --git a/src/main/java/org/opensearch/timeseries/util/DataUtil.java b/src/main/java/org/opensearch/timeseries/util/DataUtil.java index 4f417e4f7..42dd16396 100644 --- a/src/main/java/org/opensearch/timeseries/util/DataUtil.java +++ b/src/main/java/org/opensearch/timeseries/util/DataUtil.java @@ -5,7 +5,11 @@ package org.opensearch.timeseries.util; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; public class DataUtil { /** @@ -45,4 +49,43 @@ public static double[][] ltrim(double[][] arr) { return Arrays.copyOfRange(arr, startIndex, arr.length); } + public static int[] generateMissingIndicesArray(double[] point) { + List intArray = new ArrayList<>(); + for (int i = 0; i < point.length; i++) { + if (Double.isNaN(point[i])) { + intArray.add(i); + } + } + // Return null if the array is empty + if (intArray.size() == 0) { + return null; + } + return intArray.stream().mapToInt(Integer::intValue).toArray(); + } + + public static boolean areAnyElementsNaN(double[] array) { + return Arrays.stream(array).anyMatch(Double::isNaN); + } + + /** + * Rounds the given double value to the specified number of decimal places. + * + * This method uses BigDecimal for precise rounding. It rounds using the + * HALF_UP rounding mode, which means it rounds towards the "nearest neighbor" + * unless both neighbors are equidistant, in which case it rounds up. + * + * @param value the double value to be rounded + * @param places the number of decimal places to round to + * @return the rounded double value + * @throws IllegalArgumentException if the specified number of decimal places is negative + */ + public static double roundDouble(double value, int places) { + if (places < 0) { + throw new IllegalArgumentException(); + } + + BigDecimal bd = new BigDecimal(Double.toString(value)); + bd = bd.setScale(places, RoundingMode.HALF_UP); + return bd.doubleValue(); + } } diff --git a/src/main/java/org/opensearch/timeseries/util/ModelUtil.java b/src/main/java/org/opensearch/timeseries/util/ModelUtil.java new file mode 100644 index 000000000..21527e785 --- /dev/null +++ b/src/main/java/org/opensearch/timeseries/util/ModelUtil.java @@ -0,0 +1,72 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.timeseries.util; + +import java.util.List; +import java.util.Map; + +import org.opensearch.ad.model.ImputedFeatureResult; +import org.opensearch.timeseries.dataprocessor.ImputationMethod; +import org.opensearch.timeseries.dataprocessor.ImputationOption; +import org.opensearch.timeseries.model.Config; + +import com.amazon.randomcutforest.parkservices.AnomalyDescriptor; + +public class ModelUtil { + public static ImputedFeatureResult calculateImputedFeatures( + AnomalyDescriptor anomalyDescriptor, + double[] point, + boolean isImputed, + Config config + ) { + int inputLength = anomalyDescriptor.getInputLength(); + boolean[] isFeatureImputed = null; + double[] actual = point; + + if (isImputed) { + actual = new double[inputLength]; + isFeatureImputed = new boolean[inputLength]; + + ImputationOption imputationOption = config.getImputationOption(); + if (imputationOption != null && imputationOption.getMethod() == ImputationMethod.ZERO) { + for (int i = 0; i < point.length; i++) { + if (Double.isNaN(point[i])) { + isFeatureImputed[i] = true; + actual[i] = 0; + } + } + } else if (imputationOption != null && imputationOption.getMethod() == ImputationMethod.FIXED_VALUES) { + Map defaultFills = imputationOption.getDefaultFill(); + List enabledFeatureNames = config.getEnabledFeatureNames(); + for (int i = 0; i < point.length; i++) { + if (Double.isNaN(point[i])) { + isFeatureImputed[i] = true; + actual[i] = defaultFills.get(enabledFeatureNames.get(i)); + } + } + } else { + float[] rcfPoint = anomalyDescriptor.getRCFPoint(); + if (rcfPoint == null) { + return new ImputedFeatureResult(isFeatureImputed, actual); + } + float[] transformedInput = new float[inputLength]; + System.arraycopy(rcfPoint, rcfPoint.length - inputLength, transformedInput, 0, inputLength); + + double[] scale = anomalyDescriptor.getScale(); + double[] shift = anomalyDescriptor.getShift(); + + for (int i = 0; i < point.length; i++) { + if (Double.isNaN(point[i])) { + isFeatureImputed[i] = true; + actual[i] = (transformedInput[i] * scale[i]) + shift[i]; + } + } + } + } + + return new ImputedFeatureResult(isFeatureImputed, actual); + } +} diff --git a/src/main/java/org/opensearch/timeseries/util/ParseUtils.java b/src/main/java/org/opensearch/timeseries/util/ParseUtils.java index 119e21dab..3b64be259 100644 --- a/src/main/java/org/opensearch/timeseries/util/ParseUtils.java +++ b/src/main/java/org/opensearch/timeseries/util/ParseUtils.java @@ -331,7 +331,7 @@ public static SearchSourceBuilder generateInternalFeatureQuery( return internalSearchSourceBuilder; } - public static SearchSourceBuilder generatePreviewQuery( + public static SearchSourceBuilder generateRangeQuery( Config config, List> ranges, NamedXContentRegistry xContentRegistry diff --git a/src/main/java/org/opensearch/timeseries/util/TimeUtil.java b/src/main/java/org/opensearch/timeseries/util/TimeUtil.java new file mode 100644 index 000000000..22ce700be --- /dev/null +++ b/src/main/java/org/opensearch/timeseries/util/TimeUtil.java @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.timeseries.util; + +import org.opensearch.timeseries.model.Config; +import org.opensearch.timeseries.model.IntervalTimeConfiguration; + +public class TimeUtil { + public static long calculateTimeoutMillis(Config config, long dataEndTimeMillis) { + long windowDelayMillis = config.getWindowDelay() == null + ? 0 + : ((IntervalTimeConfiguration) config.getWindowDelay()).toDuration().toMillis(); + long nextExecutionEnd = dataEndTimeMillis + config.getIntervalInMilliseconds() + windowDelayMillis; + return nextExecutionEnd; + } +} diff --git a/src/main/resources/mappings/anomaly-checkpoint.json b/src/main/resources/mappings/anomaly-checkpoint.json index af485860a..3c9532d81 100644 --- a/src/main/resources/mappings/anomaly-checkpoint.json +++ b/src/main/resources/mappings/anomaly-checkpoint.json @@ -1,7 +1,7 @@ { "dynamic": true, "_meta": { - "schema_version": 4 + "schema_version": 5 }, "properties": { "detectorId": { @@ -57,6 +57,17 @@ "data_end_time": { "type": "date", "format": "strict_date_time||epoch_millis" + }, + "feature_imputed": { + "type": "nested", + "properties": { + "feature_id": { + "type": "keyword" + }, + "imputed": { + "type": "boolean" + } + } } } } diff --git a/src/main/resources/mappings/anomaly-results.json b/src/main/resources/mappings/anomaly-results.json index 3fad67ec2..105fad141 100644 --- a/src/main/resources/mappings/anomaly-results.json +++ b/src/main/resources/mappings/anomaly-results.json @@ -1,7 +1,7 @@ { "dynamic": false, "_meta": { - "schema_version": 6 + "schema_version": 7 }, "properties": { "detector_id": { @@ -157,6 +157,17 @@ }, "threshold": { "type": "double" + }, + "feature_imputed": { + "type": "nested", + "properties": { + "feature_id": { + "type": "keyword" + }, + "imputed": { + "type": "boolean" + } + } } } } diff --git a/src/main/resources/mappings/config.json b/src/main/resources/mappings/config.json index c64a697e7..2dc4954c9 100644 --- a/src/main/resources/mappings/config.json +++ b/src/main/resources/mappings/config.json @@ -1,172 +1,234 @@ { - "dynamic": false, - "_meta": { - "schema_version": 5 - }, - "properties": { - "schema_version": { - "type": "integer" + "dynamic": false, + "_meta": { + "schema_version": 6 }, - "name": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "description": { - "type": "text" - }, - "time_field": { - "type": "keyword" - }, - "indices": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "result_index": { - "type": "keyword" - }, - "filter_query": { - "type": "object", - "enabled": false - }, - "feature_attributes": { - "type": "nested", - "properties": { - "feature_id": { - "type": "keyword", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 + "properties": { + "schema_version": { + "type": "integer" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } } - } - }, - "feature_name": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 + }, + "description": { + "type": "text" + }, + "time_field": { + "type": "keyword" + }, + "indices": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } } - } }, - "feature_enabled": { - "type": "boolean" + "result_index": { + "type": "keyword" }, - "aggregation_query": { - "type": "object", - "enabled": false - } - } - }, - "detection_interval": { - "properties": { - "period": { - "properties": { - "interval": { - "type": "integer" - }, - "unit": { - "type": "keyword" + "filter_query": { + "type": "object", + "enabled": false + }, + "feature_attributes": { + "type": "nested", + "properties": { + "feature_id": { + "type": "keyword", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "feature_name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "feature_enabled": { + "type": "boolean" + }, + "aggregation_query": { + "type": "object", + "enabled": false + } } - } - } - } - }, - "window_delay": { - "properties": { - "period": { - "properties": { - "interval": { - "type": "integer" - }, - "unit": { - "type": "keyword" + }, + "detection_interval": { + "properties": { + "period": { + "properties": { + "interval": { + "type": "integer" + }, + "unit": { + "type": "keyword" + } + } + } } - } - } - } - }, - "shingle_size": { - "type": "integer" - }, - "last_update_time": { - "type": "date", - "format": "strict_date_time||epoch_millis" - }, - "ui_metadata": { - "type": "object", - "enabled": false - }, - "user": { - "type": "nested", - "properties": { - "name": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 + }, + "window_delay": { + "properties": { + "period": { + "properties": { + "interval": { + "type": "integer" + }, + "unit": { + "type": "keyword" + } + } + } } - } }, - "backend_roles": { - "type" : "text", - "fields" : { - "keyword" : { - "type" : "keyword" + "shingle_size": { + "type": "integer" + }, + "last_update_time": { + "type": "date", + "format": "strict_date_time||epoch_millis" + }, + "ui_metadata": { + "type": "object", + "enabled": false + }, + "user": { + "type": "nested", + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "backend_roles": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "roles": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "custom_attribute_names": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } } - } }, - "roles": { - "type" : "text", - "fields" : { - "keyword" : { - "type" : "keyword" + "category_field": { + "type": "keyword" + }, + "detector_type": { + "type": "keyword" + }, + "forecast_interval": { + "properties": { + "period": { + "properties": { + "interval": { + "type": "integer" + }, + "unit": { + "type": "keyword" + } + } + } } - } }, - "custom_attribute_names": { - "type" : "text", - "fields" : { - "keyword" : { - "type" : "keyword" + "horizon": { + "type": "integer" + }, + "imputation_option": { + "type": "nested", + "properties": { + "method": { + "type": "keyword" + }, + "defaultFill": { + "type": "nested", + "properties": { + "feature_name": { + "type": "keyword" + }, + "data": { + "type": "double" + } + } + } } - } - } - } - }, - "category_field": { - "type": "keyword" - }, - "detector_type": { - "type": "keyword" - }, - "forecast_interval": { - "properties": { - "period": { - "properties": { - "interval": { - "type": "integer" - }, - "unit": { - "type": "keyword" + }, + "suggested_seasonality": { + "type": "integer" + }, + "recency_emphasis": { + "type": "integer" + }, + "history": { + "type": "integer" + }, + "result_index_min_size": { + "type": "integer" + }, + "result_index_min_age": { + "type": "integer" + }, + "result_index_ttl": { + "type": "integer" + }, + "rules": { + "type": "nested", + "properties": { + "action": { + "type": "keyword" + }, + "conditions": { + "type": "nested", + "properties": { + "feature_name": { + "type": "keyword" + }, + "threshold_type": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "value": { + "type": "double" + } + } + } } - } } - } - }, - "horizon": { - "type": "integer" } - } -} +} \ No newline at end of file diff --git a/src/test/java/org/opensearch/action/admin/indices/mapping/get/AbstractForecasterActionHandlerTestCase.java b/src/test/java/org/opensearch/action/admin/indices/mapping/get/AbstractForecasterActionHandlerTestCase.java new file mode 100644 index 000000000..5bee84cba --- /dev/null +++ b/src/test/java/org/opensearch/action/admin/indices/mapping/get/AbstractForecasterActionHandlerTestCase.java @@ -0,0 +1,130 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.action.admin.indices.mapping.get; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.util.Arrays; + +import org.junit.Before; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.client.Client; +import org.opensearch.cluster.ClusterName; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.forecast.indices.ForecastIndex; +import org.opensearch.forecast.indices.ForecastIndexManagement; +import org.opensearch.forecast.model.ForecastTask; +import org.opensearch.forecast.model.ForecastTaskType; +import org.opensearch.forecast.model.Forecaster; +import org.opensearch.forecast.task.ForecastTaskManager; +import org.opensearch.rest.RestRequest; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.timeseries.AbstractTimeSeriesTest; +import org.opensearch.timeseries.NodeStateManager; +import org.opensearch.timeseries.TestHelpers; +import org.opensearch.timeseries.feature.SearchFeatureDao; +import org.opensearch.timeseries.task.TaskCacheManager; +import org.opensearch.timeseries.task.TaskManager; +import org.opensearch.timeseries.transport.ValidateConfigResponse; +import org.opensearch.timeseries.util.SecurityClientUtil; +import org.opensearch.transport.TransportService; + +import com.google.common.collect.ImmutableList; + +public class AbstractForecasterActionHandlerTestCase extends AbstractTimeSeriesTest { + + protected ClusterService clusterService; + protected ActionListener channel; + protected TransportService transportService; + protected ForecastIndexManagement forecastISM; + protected String forecasterId; + protected Long seqNo; + protected Long primaryTerm; + protected Forecaster forecaster; + protected WriteRequest.RefreshPolicy refreshPolicy; + protected TimeValue requestTimeout; + protected Integer maxSingleStreamForecasters; + protected Integer maxHCForecasters; + protected Integer maxForecastFeatures; + protected Integer maxCategoricalFields; + protected Settings settings; + protected RestRequest.Method method; + protected TaskManager forecastTaskManager; + protected SearchFeatureDao searchFeatureDao; + protected Clock clock; + @Mock + protected Client clientMock; + @Mock + protected ThreadPool threadPool; + protected ThreadContext threadContext; + protected SecurityClientUtil clientUtil; + protected String categoricalField; + + @SuppressWarnings("unchecked") + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + MockitoAnnotations.initMocks(this); + + settings = Settings.EMPTY; + + clusterService = mock(ClusterService.class); + ClusterName clusterName = new ClusterName("test"); + ClusterState clusterState = ClusterState.builder(clusterName).metadata(Metadata.builder().build()).build(); + when(clusterService.state()).thenReturn(clusterState); + + channel = mock(ActionListener.class); + transportService = mock(TransportService.class); + + forecastISM = mock(ForecastIndexManagement.class); + when(forecastISM.doesConfigIndexExist()).thenReturn(true); + + forecasterId = "123"; + seqNo = 0L; + primaryTerm = 0L; + clock = mock(Clock.class); + + refreshPolicy = WriteRequest.RefreshPolicy.IMMEDIATE; + + categoricalField = "a"; + forecaster = TestHelpers.ForecasterBuilder + .newInstance() + .setConfigId(forecasterId) + .setTimeField("timestamp") + .setIndices(ImmutableList.of("test-index")) + .setCategoryFields(Arrays.asList(categoricalField)) + .build(); + + requestTimeout = new TimeValue(1000L); + maxSingleStreamForecasters = 1000; + maxHCForecasters = 10; + maxForecastFeatures = 5; + maxCategoricalFields = 10; + method = RestRequest.Method.POST; + forecastTaskManager = mock(ForecastTaskManager.class); + searchFeatureDao = mock(SearchFeatureDao.class); + + threadContext = new ThreadContext(settings); + Mockito.doReturn(threadPool).when(clientMock).threadPool(); + Mockito.doReturn(threadContext).when(threadPool).getThreadContext(); + + NodeStateManager nodeStateManager = mock(NodeStateManager.class); + clientUtil = new SecurityClientUtil(nodeStateManager, settings); + } + +} diff --git a/src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexAnomalyDetectorActionHandlerTests.java b/src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexAnomalyDetectorActionHandlerTests.java index e1e86e1ec..bd1159047 100644 --- a/src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexAnomalyDetectorActionHandlerTests.java +++ b/src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexAnomalyDetectorActionHandlerTests.java @@ -12,8 +12,8 @@ package org.opensearch.action.admin.indices.mapping.get; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -22,6 +22,7 @@ import static org.mockito.Mockito.when; import java.io.IOException; +import java.time.Instant; import java.util.Arrays; import java.util.Locale; import java.util.concurrent.CountDownLatch; @@ -32,6 +33,7 @@ import org.junit.Before; import org.junit.BeforeClass; import org.junit.Ignore; +import org.opensearch.OpenSearchStatusException; import org.opensearch.action.ActionRequest; import org.opensearch.action.ActionType; import org.opensearch.action.get.GetAction; @@ -116,7 +118,12 @@ public void setUp() throws Exception { super.setUp(); settings = Settings.EMPTY; + clusterService = mock(ClusterService.class); + ClusterName clusterName = new ClusterName("test"); + ClusterState clusterState = ClusterState.builder(clusterName).metadata(Metadata.builder().build()).build(); + when(clusterService.state()).thenReturn(clusterState); + clientMock = spy(new NodeClient(settings, threadPool)); NodeStateManager nodeStateManager = mock(NodeStateManager.class); clientUtil = new SecurityClientUtil(nodeStateManager, settings); @@ -311,7 +318,8 @@ public void doE assertTrue("should throw eror", false); inProgressLatch.countDown(); }, e -> { - assertTrue(e.getMessage().contains(CommonMessages.CATEGORICAL_FIELD_TYPE_ERR_MSG)); + String error = String.format(Locale.ROOT, CommonMessages.CATEGORICAL_FIELD_TYPE_ERR_MSG, field); + assertTrue("actual: " + e.getMessage(), e.getMessage().contains(error)); inProgressLatch.countDown(); })); assertTrue(inProgressLatch.await(100, TimeUnit.SECONDS)); @@ -505,7 +513,8 @@ public void doE if (fieldTypeName.equals(CommonName.IP_TYPE) || fieldTypeName.equals(CommonName.KEYWORD_TYPE)) { assertTrue(e.getMessage().contains(IndexAnomalyDetectorActionHandler.NO_DOCS_IN_USER_INDEX_MSG)); } else { - assertTrue(e.getMessage().contains(CommonMessages.CATEGORICAL_FIELD_TYPE_ERR_MSG)); + String error = String.format(Locale.ROOT, CommonMessages.CATEGORICAL_FIELD_TYPE_ERR_MSG, field); + assertTrue("actual: " + e.getMessage(), e.getMessage().contains(error)); } inProgressLatch.countDown(); })); @@ -799,4 +808,102 @@ public void testTenMultiEntityDetectorsUpdateExistingMultiEntityAd() throws IOEx verify(clientMock, times(0)).search(any(SearchRequest.class), any()); verify(clientMock, times(1)).get(any(GetRequest.class), any()); } + + public void testUpdateDifferentCategoricalField() throws InterruptedException { + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + try { + GetFieldMappingsResponse response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(detector.getIndices().get(0), "timestamp", "date") + ); + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(GetAction.INSTANCE)) { + // Serialize the object + AnomalyDetector clone = new AnomalyDetector( + detector.getId(), + detector.getVersion(), + detector.getName(), + detector.getDescription(), + detector.getTimeField(), + detector.getIndices(), + detector.getFeatureAttributes(), + detector.getFilterQuery(), + detector.getInterval(), + detector.getWindowDelay(), + detector.getShingleSize(), + detector.getUiMetadata(), + detector.getSchemaVersion(), + Instant.now(), + detector.getCategoryFields(), + detector.getUser(), + "opensearch-ad-plugin-result-blah", + detector.getImputationOption(), + detector.getRecencyEmphasis(), + detector.getSeasonIntervals(), + detector.getHistoryIntervals(), + null, + detector.getCustomResultIndexMinSize(), + detector.getCustomResultIndexMinAge(), + detector.getCustomResultIndexTTL() + ); + try { + listener.onResponse((Response) TestHelpers.createGetResponse(clone, clone.getId(), CommonName.CONFIG_INDEX)); + } catch (IOException e) { + LOG.error(e); + } + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.PUT; + + handler = new IndexAnomalyDetectorActionHandler( + clusterService, + clientSpy, + clientUtil, + transportService, + anomalyDetectionIndices, + detectorId, + seqNo, + primaryTerm, + refreshPolicy, + detector, + requestTimeout, + maxSingleEntityAnomalyDetectors, + maxMultiEntityAnomalyDetectors, + maxAnomalyFeatures, + maxCategoricalFields, + RestRequest.Method.PUT, + xContentRegistry(), + null, + adTaskManager, + searchFeatureDao, + Settings.EMPTY + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue("actual: " + e, e instanceof OpenSearchStatusException); + OpenSearchStatusException statusException = (OpenSearchStatusException) e; + assertTrue(statusException.getMessage().contains(CommonMessages.CAN_NOT_CHANGE_CUSTOM_RESULT_INDEX)); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + verify(clientSpy, times(1)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + verify(clientSpy, times(1)).execute(eq(GetAction.INSTANCE), any(), any()); + } } diff --git a/src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexForecasterActionHandlerTests.java b/src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexForecasterActionHandlerTests.java new file mode 100644 index 000000000..e78b154ea --- /dev/null +++ b/src/test/java/org/opensearch/action/admin/indices/mapping/get/IndexForecasterActionHandlerTests.java @@ -0,0 +1,1405 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.action.admin.indices.mapping.get; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.opensearch.index.seqno.SequenceNumbers.UNASSIGNED_SEQ_NO; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Locale; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.lucene.search.TotalHits; +import org.opensearch.OpenSearchStatusException; +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionType; +import org.opensearch.action.DocWriteResponse.Result; +import org.opensearch.action.admin.indices.create.CreateIndexResponse; +import org.opensearch.action.get.GetAction; +import org.opensearch.action.get.GetResponse; +import org.opensearch.action.index.IndexAction; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.action.search.SearchAction; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.search.SearchResponseSections; +import org.opensearch.action.search.ShardSearchFailure; +import org.opensearch.action.support.replication.ReplicationResponse; +import org.opensearch.action.support.replication.ReplicationResponse.ShardInfo; +import org.opensearch.client.node.NodeClient; +import org.opensearch.cluster.routing.AllocationId; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.forecast.rest.handler.IndexForecasterActionHandler; +import org.opensearch.forecast.task.ForecastTaskManager; +import org.opensearch.index.get.GetResult; +import org.opensearch.index.query.BoolQueryBuilder; +import org.opensearch.rest.RestRequest; +import org.opensearch.search.SearchHit; +import org.opensearch.search.SearchHits; +import org.opensearch.search.aggregations.Aggregations; +import org.opensearch.search.aggregations.bucket.histogram.Histogram; +import org.opensearch.timeseries.TestHelpers; +import org.opensearch.timeseries.constant.CommonMessages; +import org.opensearch.timeseries.constant.CommonName; +import org.opensearch.timeseries.rest.handler.AggregationPrep; +import org.opensearch.transport.TransportService; + +import com.google.common.collect.ImmutableList; + +public class IndexForecasterActionHandlerTests extends AbstractForecasterActionHandlerTestCase { + protected IndexForecasterActionHandler handler; + + public void testCreateOrUpdateConfigException() throws InterruptedException, IOException { + doAnswer(invocation -> { + Object[] args = invocation.getArguments(); + ActionListener listner = (ActionListener) args[0]; + listner.onFailure(new IllegalArgumentException()); + return null; + }).when(forecastISM).initConfigIndex(any()); + when(forecastISM.doesConfigIndexExist()).thenReturn(false); + + handler = new IndexForecasterActionHandler( + clusterService, + clientMock, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + null, + null, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + null, + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue(e instanceof IllegalArgumentException); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + } + + public void testUpdateConfigException() throws InterruptedException { + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + try { + GetFieldMappingsResponse response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(GetAction.INSTANCE)) { + listener.onFailure(new IllegalArgumentException()); + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.PUT; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + null, + null, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + null, + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue(e instanceof IllegalArgumentException); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(100, TimeUnit.SECONDS)); + verify(clientSpy, times(1)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + verify(clientSpy, times(1)).execute(eq(GetAction.INSTANCE), any(), any()); + } + + public void testGetConfigNotExists() throws InterruptedException { + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + try { + GetFieldMappingsResponse response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(GetAction.INSTANCE)) { + GetResult notFoundResult = new GetResult("ab", "_doc", UNASSIGNED_SEQ_NO, 0, -1, false, null, null, null); + listener.onResponse((Response) new GetResponse(notFoundResult)); + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.PUT; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + null, + null, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + null, + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue(e instanceof OpenSearchStatusException); + OpenSearchStatusException statusException = (OpenSearchStatusException) e; + assertTrue(statusException.getMessage().contains(CommonMessages.FAIL_TO_FIND_CONFIG_MSG)); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(100, TimeUnit.SECONDS)); + verify(clientSpy, times(1)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + verify(clientSpy, times(1)).execute(eq(GetAction.INSTANCE), any(), any()); + } + + public void testFaiToParse() throws InterruptedException { + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + try { + GetFieldMappingsResponse response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(GetAction.INSTANCE)) { + try { + listener + .onResponse( + (Response) TestHelpers + .createGetResponse(AllocationId.newInitializing(), forecaster.getId(), CommonName.CONFIG_INDEX) + ); + } catch (IOException e) { + LOG.error(e); + } + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.PUT; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + null, + null, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + mock(ForecastTaskManager.class), + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue("actual: " + e, e instanceof OpenSearchStatusException); + OpenSearchStatusException statusException = (OpenSearchStatusException) e; + assertTrue(statusException.getMessage().contains("Failed to parse config")); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + verify(clientSpy, times(1)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + verify(clientSpy, times(1)).execute(eq(GetAction.INSTANCE), any(), any()); + } + + public void testSearchHCForecasterException() throws InterruptedException { + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + try { + GetFieldMappingsResponse response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(SearchAction.INSTANCE)) { + listener.onFailure(new IllegalArgumentException()); + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.POST; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + null, + null, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + mock(ForecastTaskManager.class), + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue("actual: " + e, e instanceof IllegalArgumentException); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + verify(clientSpy, times(1)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + verify(clientSpy, times(1)).execute(eq(SearchAction.INSTANCE), any(), any()); + } + + public void testSearchSingleStreamForecasterException() throws InterruptedException, IOException { + forecaster = TestHelpers.ForecasterBuilder + .newInstance() + .setConfigId(forecasterId) + .setTimeField("timestamp") + .setIndices(ImmutableList.of("test-index")) + .setCategoryFields(Arrays.asList()) + .build(); + + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + GetFieldMappingsRequest getMappingsRequest = (GetFieldMappingsRequest) request; + try { + GetFieldMappingsResponse response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(SearchAction.INSTANCE)) { + listener.onFailure(new IllegalArgumentException()); + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.POST; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + null, + null, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + mock(ForecastTaskManager.class), + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue("actual: " + e, e instanceof IllegalArgumentException); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + verify(clientSpy, times(1)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + verify(clientSpy, times(1)).execute(eq(SearchAction.INSTANCE), any(), any()); + } + + public void testValidateCategoricalFieldException() throws InterruptedException, IOException { + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + GetFieldMappingsRequest getMappingsRequest = (GetFieldMappingsRequest) request; + try { + GetFieldMappingsResponse response = null; + if (getMappingsRequest.fields()[0].equals(categoricalField)) { + listener.onFailure(new IllegalArgumentException()); + } else { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + } + + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(SearchAction.INSTANCE)) { + Histogram histogram = mock(Histogram.class); + when(histogram.getName()).thenReturn(AggregationPrep.AGGREGATION); + Aggregations aggs = new Aggregations(Arrays.asList(histogram)); + SearchResponseSections sections = new SearchResponseSections( + new SearchHits(new SearchHit[0], new TotalHits(0, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + listener + .onResponse( + (Response) new SearchResponse( + sections, + null, + 0, + 0, + 0, + 0L, + ShardSearchFailure.EMPTY_ARRAY, + SearchResponse.Clusters.EMPTY + ) + ); + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.POST; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + null, + null, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + mock(ForecastTaskManager.class), + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + String message = String.format(Locale.ROOT, CommonMessages.FAIL_TO_GET_MAPPING_MSG, forecaster.getIndices()); + assertTrue("actual: " + e, e instanceof IllegalArgumentException); + assertTrue("actual: " + message, e.getMessage().contains(message)); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + // once for timestamp, once for categorical field + verify(clientSpy, times(2)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + verify(clientSpy, times(1)).execute(eq(SearchAction.INSTANCE), any(), any()); + } + + public void testSearchConfigInputException() throws InterruptedException, IOException { + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + GetFieldMappingsRequest getMappingsRequest = (GetFieldMappingsRequest) request; + try { + GetFieldMappingsResponse response = null; + if (getMappingsRequest.fields()[0].equals(categoricalField)) { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), categoricalField, "keyword") + ); + } else { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + } + + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(SearchAction.INSTANCE)) { + LOG.info(Thread.currentThread().getName()); + StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); + for (StackTraceElement element : stackTrace) { + LOG.info(element); + } + SearchRequest searchRequest = (SearchRequest) request; + if (searchRequest.indices()[0].equals(CommonName.CONFIG_INDEX)) { + Histogram histogram = mock(Histogram.class); + when(histogram.getName()).thenReturn(AggregationPrep.AGGREGATION); + Aggregations aggs = new Aggregations(Arrays.asList(histogram)); + SearchResponseSections sections = new SearchResponseSections( + new SearchHits(new SearchHit[0], new TotalHits(0, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + listener + .onResponse( + (Response) new SearchResponse( + sections, + null, + 0, + 0, + 0, + 0L, + ShardSearchFailure.EMPTY_ARRAY, + SearchResponse.Clusters.EMPTY + ) + ); + } else { + listener.onFailure(new IllegalArgumentException()); + } + + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.POST; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + null, + null, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + mock(ForecastTaskManager.class), + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue("actual: " + e, e instanceof IllegalArgumentException); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + // once for timestamp, once for categorical field + verify(clientSpy, times(2)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + // validateAgainstExistingHCConfig, validateCategoricalField/searchConfigInputIndices + verify(clientSpy, times(2)).execute(eq(SearchAction.INSTANCE), any(), any()); + } + + public void testCheckConfigNameExistsException() throws InterruptedException, IOException { + forecaster = TestHelpers.ForecasterBuilder + .newInstance() + .setConfigId(forecasterId) + .setTimeField("timestamp") + .setIndices(ImmutableList.of("test-index")) + .setFeatureAttributes(Arrays.asList()) + .setCategoryFields(Arrays.asList(categoricalField)) + .setNullImputationOption() + .build(); + + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + GetFieldMappingsRequest getMappingsRequest = (GetFieldMappingsRequest) request; + try { + GetFieldMappingsResponse response = null; + if (getMappingsRequest.fields()[0].equals(categoricalField)) { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), categoricalField, "keyword") + ); + } else { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + } + + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(SearchAction.INSTANCE)) { + SearchRequest searchRequest = (SearchRequest) request; + Histogram histogram = mock(Histogram.class); + when(histogram.getName()).thenReturn(AggregationPrep.AGGREGATION); + Aggregations aggs = new Aggregations(Arrays.asList(histogram)); + SearchResponseSections sections = null; + if (searchRequest.indices()[0].equals(CommonName.CONFIG_INDEX)) { + BoolQueryBuilder boolQuery = (BoolQueryBuilder) searchRequest.source().query(); + if (boolQuery.must() != null && boolQuery.must().size() > 0) { + // checkConfigNameExists + listener.onFailure(new IllegalArgumentException()); + return; + } else { + // validateAgainstExistingHCConfig + sections = new SearchResponseSections( + new SearchHits(new SearchHit[0], new TotalHits(0, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + } + } else { + SearchHit[] hits = new SearchHit[1]; + hits[0] = new SearchHit(randomIntBetween(1, Integer.MAX_VALUE)); + + sections = new SearchResponseSections( + new SearchHits(hits, new TotalHits(1, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + } + listener + .onResponse( + (Response) new SearchResponse( + sections, + null, + 0, + 0, + 0, + 0L, + ShardSearchFailure.EMPTY_ARRAY, + SearchResponse.Clusters.EMPTY + ) + ); + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.POST; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + null, + null, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + mock(ForecastTaskManager.class), + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue("actual: " + e, e instanceof IllegalArgumentException); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + // once for timestamp, once for categorical field + verify(clientSpy, times(2)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + // validateAgainstExistingHCConfig, checkConfigNameExists, validateCategoricalField/searchConfigInputIndices + verify(clientSpy, times(3)).execute(eq(SearchAction.INSTANCE), any(), any()); + } + + public void testRedundantNames() throws InterruptedException, IOException { + forecaster = TestHelpers.ForecasterBuilder + .newInstance() + .setConfigId(forecasterId) + .setTimeField("timestamp") + .setIndices(ImmutableList.of("test-index")) + .setFeatureAttributes(Arrays.asList()) + .setCategoryFields(Arrays.asList(categoricalField)) + .setNullImputationOption() + .build(); + + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + GetFieldMappingsRequest getMappingsRequest = (GetFieldMappingsRequest) request; + try { + GetFieldMappingsResponse response = null; + if (getMappingsRequest.fields()[0].equals(categoricalField)) { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), categoricalField, "keyword") + ); + } else { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + } + + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(SearchAction.INSTANCE)) { + SearchRequest searchRequest = (SearchRequest) request; + Histogram histogram = mock(Histogram.class); + when(histogram.getName()).thenReturn(AggregationPrep.AGGREGATION); + Aggregations aggs = new Aggregations(Arrays.asList(histogram)); + SearchResponseSections sections = null; + if (searchRequest.indices()[0].equals(CommonName.CONFIG_INDEX)) { + BoolQueryBuilder boolQuery = (BoolQueryBuilder) searchRequest.source().query(); + if (boolQuery.must() != null && boolQuery.must().size() > 0) { + // checkConfigNameExists + SearchHit[] hits = new SearchHit[1]; + hits[0] = new SearchHit(randomIntBetween(1, Integer.MAX_VALUE)); + + sections = new SearchResponseSections( + new SearchHits(hits, new TotalHits(1, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + } else { + // validateAgainstExistingHCConfig + sections = new SearchResponseSections( + new SearchHits(new SearchHit[0], new TotalHits(0, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + } + } else { + SearchHit[] hits = new SearchHit[1]; + hits[0] = new SearchHit(randomIntBetween(1, Integer.MAX_VALUE)); + + sections = new SearchResponseSections( + new SearchHits(hits, new TotalHits(1, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + } + listener + .onResponse( + (Response) new SearchResponse( + sections, + null, + 0, + 0, + 0, + 0L, + ShardSearchFailure.EMPTY_ARRAY, + SearchResponse.Clusters.EMPTY + ) + ); + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.POST; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + null, + null, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + mock(ForecastTaskManager.class), + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue("actual: " + e, e instanceof OpenSearchStatusException); + String error = handler.getDuplicateConfigErrorMsg(forecaster.getName()); + assertTrue("actual: " + e.getMessage(), e.getMessage().contains(error)); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + // once for timestamp, once for categorical field + verify(clientSpy, times(2)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + // validateAgainstExistingHCConfig, checkConfigNameExists, validateCategoricalField/searchConfigInputIndices + verify(clientSpy, times(3)).execute(eq(SearchAction.INSTANCE), any(), any()); + } + + public void testIndexConfigVersionConflict() throws InterruptedException, IOException { + forecaster = TestHelpers.ForecasterBuilder + .newInstance() + .setConfigId(forecasterId) + .setTimeField("timestamp") + .setIndices(ImmutableList.of("test-index")) + .setFeatureAttributes(Arrays.asList()) + .setCategoryFields(Arrays.asList(categoricalField)) + .setNullImputationOption() + .build(); + + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + GetFieldMappingsRequest getMappingsRequest = (GetFieldMappingsRequest) request; + try { + GetFieldMappingsResponse response = null; + if (getMappingsRequest.fields()[0].equals(categoricalField)) { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), categoricalField, "keyword") + ); + } else { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + } + + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(SearchAction.INSTANCE)) { + SearchRequest searchRequest = (SearchRequest) request; + Histogram histogram = mock(Histogram.class); + when(histogram.getName()).thenReturn(AggregationPrep.AGGREGATION); + Aggregations aggs = new Aggregations(Arrays.asList(histogram)); + SearchResponseSections sections = null; + if (searchRequest.indices()[0].equals(CommonName.CONFIG_INDEX)) { + sections = new SearchResponseSections( + new SearchHits(new SearchHit[0], new TotalHits(0, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + } else { + SearchHit[] hits = new SearchHit[1]; + hits[0] = new SearchHit(randomIntBetween(1, Integer.MAX_VALUE)); + + sections = new SearchResponseSections( + new SearchHits(hits, new TotalHits(1, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + } + listener + .onResponse( + (Response) new SearchResponse( + sections, + null, + 0, + 0, + 0, + 0L, + ShardSearchFailure.EMPTY_ARRAY, + SearchResponse.Clusters.EMPTY + ) + ); + } else if (action.equals(IndexAction.INSTANCE)) { + listener.onFailure(new IllegalArgumentException("version conflict")); + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.POST; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + 1L, + 1L, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + mock(ForecastTaskManager.class), + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue("actual: " + e, e instanceof IllegalArgumentException); + String error = "There was a problem updating the config:[" + forecaster.getId() + "]"; + assertTrue("actual: " + e.getMessage(), e.getMessage().contains(error)); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + // once for timestamp, once for categorical field + verify(clientSpy, times(2)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + // validateAgainstExistingHCConfig, checkConfigNameExists, validateCategoricalField/searchConfigInputIndices + verify(clientSpy, times(3)).execute(eq(SearchAction.INSTANCE), any(), any()); + // indexConfig + verify(clientSpy, times(1)).execute(eq(IndexAction.INSTANCE), any(), any()); + } + + public void testIndexConfigException() throws InterruptedException, IOException { + forecaster = TestHelpers.ForecasterBuilder + .newInstance() + .setConfigId(forecasterId) + .setTimeField("timestamp") + .setIndices(ImmutableList.of("test-index")) + .setFeatureAttributes(Arrays.asList()) + .setCategoryFields(Arrays.asList(categoricalField)) + .setNullImputationOption() + .build(); + + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + GetFieldMappingsRequest getMappingsRequest = (GetFieldMappingsRequest) request; + try { + GetFieldMappingsResponse response = null; + if (getMappingsRequest.fields()[0].equals(categoricalField)) { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), categoricalField, "keyword") + ); + } else { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + } + + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(SearchAction.INSTANCE)) { + SearchRequest searchRequest = (SearchRequest) request; + Histogram histogram = mock(Histogram.class); + when(histogram.getName()).thenReturn(AggregationPrep.AGGREGATION); + Aggregations aggs = new Aggregations(Arrays.asList(histogram)); + SearchResponseSections sections = null; + if (searchRequest.indices()[0].equals(CommonName.CONFIG_INDEX)) { + sections = new SearchResponseSections( + new SearchHits(new SearchHit[0], new TotalHits(0, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + } else { + SearchHit[] hits = new SearchHit[1]; + hits[0] = new SearchHit(randomIntBetween(1, Integer.MAX_VALUE)); + + sections = new SearchResponseSections( + new SearchHits(hits, new TotalHits(1, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + } + listener + .onResponse( + (Response) new SearchResponse( + sections, + null, + 0, + 0, + 0, + 0L, + ShardSearchFailure.EMPTY_ARRAY, + SearchResponse.Clusters.EMPTY + ) + ); + } else if (action.equals(IndexAction.INSTANCE)) { + listener.onFailure(new IllegalArgumentException()); + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.POST; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + 1L, + 1L, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + mock(ForecastTaskManager.class), + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue("actual: " + e, e instanceof IllegalArgumentException); + assertEquals("actual: " + e.getMessage(), null, e.getMessage()); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + // once for timestamp, once for categorical field + verify(clientSpy, times(2)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + // validateAgainstExistingHCConfig, checkConfigNameExists, validateCategoricalField/searchConfigInputIndices + verify(clientSpy, times(3)).execute(eq(SearchAction.INSTANCE), any(), any()); + // indexConfig + verify(clientSpy, times(1)).execute(eq(IndexAction.INSTANCE), any(), any()); + } + + public void testIndexShardFailure() throws InterruptedException, IOException { + forecaster = TestHelpers.ForecasterBuilder + .newInstance() + .setConfigId(forecasterId) + .setTimeField("timestamp") + .setIndices(ImmutableList.of("test-index")) + .setFeatureAttributes(Arrays.asList()) + .setCategoryFields(Arrays.asList(categoricalField)) + .setNullImputationOption() + .build(); + + IndexResponse.Builder notCreatedResponse = new IndexResponse.Builder(); + notCreatedResponse.setResult(Result.CREATED); + notCreatedResponse.setShardId(new ShardId("index", "_uuid", 0)); + notCreatedResponse.setId("blah"); + notCreatedResponse.setVersion(1L); + + ReplicationResponse.ShardInfo.Failure[] failures = new ReplicationResponse.ShardInfo.Failure[1]; + failures[0] = new ReplicationResponse.ShardInfo.Failure( + new ShardId("index", "_uuid", 1), + null, + new Exception("shard failed"), + RestStatus.GATEWAY_TIMEOUT, + false + ); + notCreatedResponse.setShardInfo(new ShardInfo(2, 1, failures)); + IndexResponse indexResponse = notCreatedResponse.build(); + + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + GetFieldMappingsRequest getMappingsRequest = (GetFieldMappingsRequest) request; + try { + GetFieldMappingsResponse response = null; + if (getMappingsRequest.fields()[0].equals(categoricalField)) { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), categoricalField, "keyword") + ); + } else { + response = new GetFieldMappingsResponse( + TestHelpers.createFieldMappings(forecaster.getIndices().get(0), "timestamp", "date") + ); + } + + listener.onResponse((Response) response); + } catch (IOException e) { + logger.error("Create field mapping threw an exception", e); + } + } else if (action.equals(SearchAction.INSTANCE)) { + SearchRequest searchRequest = (SearchRequest) request; + Histogram histogram = mock(Histogram.class); + when(histogram.getName()).thenReturn(AggregationPrep.AGGREGATION); + Aggregations aggs = new Aggregations(Arrays.asList(histogram)); + SearchResponseSections sections = null; + if (searchRequest.indices()[0].equals(CommonName.CONFIG_INDEX)) { + sections = new SearchResponseSections( + new SearchHits(new SearchHit[0], new TotalHits(0, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + } else { + SearchHit[] hits = new SearchHit[1]; + hits[0] = new SearchHit(randomIntBetween(1, Integer.MAX_VALUE)); + + sections = new SearchResponseSections( + new SearchHits(hits, new TotalHits(1, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + } + listener + .onResponse( + (Response) new SearchResponse( + sections, + null, + 0, + 0, + 0, + 0L, + ShardSearchFailure.EMPTY_ARRAY, + SearchResponse.Clusters.EMPTY + ) + ); + } else if (action.equals(IndexAction.INSTANCE)) { + + listener.onResponse((Response) indexResponse); + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + method = RestRequest.Method.POST; + + handler = new IndexForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + 1L, + 1L, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + mock(ForecastTaskManager.class), + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue("actual: " + e, e instanceof OpenSearchStatusException); + String errorMsg = handler.checkShardsFailure(indexResponse); + assertEquals("actual: " + e.getMessage(), errorMsg, e.getMessage()); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + // once for timestamp, once for categorical field + verify(clientSpy, times(2)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + // validateAgainstExistingHCConfig, checkConfigNameExists, validateCategoricalField/searchConfigInputIndices + verify(clientSpy, times(3)).execute(eq(SearchAction.INSTANCE), any(), any()); + // indexConfig + verify(clientSpy, times(1)).execute(eq(IndexAction.INSTANCE), any(), any()); + } + + public void testCreateMappingException() throws InterruptedException, IOException { + doAnswer(invocation -> { + Object[] args = invocation.getArguments(); + ActionListener listner = (ActionListener) args[0]; + listner.onResponse(new CreateIndexResponse(false, false, "blah")); + return null; + }).when(forecastISM).initConfigIndex(any()); + when(forecastISM.doesIndexExist(anyString())).thenReturn(false); + when(forecastISM.doesAliasExist(anyString())).thenReturn(false); + when(forecastISM.doesConfigIndexExist()).thenReturn(false); + + handler = new IndexForecasterActionHandler( + clusterService, + clientMock, + clientUtil, + mock(TransportService.class), + forecastISM, + forecaster.getId(), + null, + null, + null, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + null, + searchFeatureDao, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue("actual: " + e, e instanceof OpenSearchStatusException); + assertEquals( + "actual: " + e.getMessage(), + "Created " + CommonName.CONFIG_INDEX + "with mappings call not acknowledged.", + e.getMessage() + ); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(10, TimeUnit.SECONDS)); + } +} diff --git a/src/test/java/org/opensearch/action/admin/indices/mapping/get/ValidateForecasterActionHandlerTests.java b/src/test/java/org/opensearch/action/admin/indices/mapping/get/ValidateForecasterActionHandlerTests.java new file mode 100644 index 000000000..e179a326f --- /dev/null +++ b/src/test/java/org/opensearch/action/admin/indices/mapping/get/ValidateForecasterActionHandlerTests.java @@ -0,0 +1,109 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.action.admin.indices.mapping.get; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionType; +import org.opensearch.client.node.NodeClient; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.action.ActionResponse; +import org.opensearch.forecast.rest.handler.ValidateForecasterActionHandler; +import org.opensearch.timeseries.model.ValidationAspect; + +public class ValidateForecasterActionHandlerTests extends AbstractForecasterActionHandlerTestCase { + protected ValidateForecasterActionHandler handler; + + public void testCreateOrUpdateConfigException() throws InterruptedException { + doThrow(IllegalArgumentException.class).when(forecastISM).doesConfigIndexExist(); + handler = new ValidateForecasterActionHandler( + clusterService, + clientMock, + clientUtil, + forecastISM, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + searchFeatureDao, + ValidationAspect.FORECASTER.getName(), + clock, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue(e instanceof IllegalArgumentException); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(100, TimeUnit.SECONDS)); + } + + public void testValidateTimeFieldException() throws InterruptedException { + NodeClient client = new NodeClient(Settings.EMPTY, threadPool) { + @Override + public void doExecute( + ActionType action, + Request request, + ActionListener listener + ) { + if (action.equals(GetFieldMappingsAction.INSTANCE)) { + listener.onFailure(new IllegalArgumentException()); + } else { + assertTrue("should not reach here", false); + } + } + }; + NodeClient clientSpy = spy(client); + + handler = new ValidateForecasterActionHandler( + clusterService, + clientSpy, + clientUtil, + forecastISM, + forecaster, + requestTimeout, + maxSingleStreamForecasters, + maxHCForecasters, + maxForecastFeatures, + maxCategoricalFields, + method, + xContentRegistry(), + null, + searchFeatureDao, + ValidationAspect.FORECASTER.getName(), + clock, + settings + ); + final CountDownLatch inProgressLatch = new CountDownLatch(1); + handler.start(ActionListener.wrap(r -> { + assertTrue("should not reach here", false); + inProgressLatch.countDown(); + }, e -> { + assertTrue(e instanceof IllegalArgumentException); + inProgressLatch.countDown(); + })); + assertTrue(inProgressLatch.await(100, TimeUnit.SECONDS)); + verify(clientSpy, times(1)).execute(eq(GetFieldMappingsAction.INSTANCE), any(), any()); + } +} diff --git a/src/test/java/org/opensearch/ad/AbstractADSyntheticDataTest.java b/src/test/java/org/opensearch/ad/AbstractADSyntheticDataTest.java index 0adfb7c0f..ef5afa1c5 100644 --- a/src/test/java/org/opensearch/ad/AbstractADSyntheticDataTest.java +++ b/src/test/java/org/opensearch/ad/AbstractADSyntheticDataTest.java @@ -11,38 +11,79 @@ package org.opensearch.ad; -import static org.opensearch.timeseries.TestHelpers.toHttpEntity; +import static org.opensearch.ad.settings.AnomalyDetectorSettings.AD_MODEL_MAX_SIZE_PERCENTAGE; import java.io.File; import java.io.FileReader; import java.io.IOException; import java.nio.charset.Charset; +import java.time.Duration; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; +import java.util.TreeMap; -import org.apache.hc.core5.http.HttpHeaders; -import org.apache.hc.core5.http.message.BasicHeader; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.core.Logger; +import org.junit.Before; import org.opensearch.client.Request; +import org.opensearch.client.Response; import org.opensearch.client.RestClient; import org.opensearch.timeseries.AbstractSyntheticDataTest; -import org.opensearch.timeseries.TestHelpers; -import com.google.common.collect.ImmutableList; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; public class AbstractADSyntheticDataTest extends AbstractSyntheticDataTest { + protected static class TrainResult { + public String detectorId; + public List data; + // actual index of training data. As we have multiple entities, + // trainTestSplit means how many groups of entities are used for training. + // rawDataTrainTestSplit is the actual index of training data. + public int rawDataTrainTestSplit; + public Duration windowDelay; + public Instant trainTime; + // first data time in data + public Instant firstDataTime; + // last data time in data + public Instant finalDataTime; + + public TrainResult(String detectorId, List data, int rawDataTrainTestSplit, Duration windowDelay, Instant trainTime) { + this.detectorId = detectorId; + this.data = data; + this.rawDataTrainTestSplit = rawDataTrainTestSplit; + this.windowDelay = windowDelay; + this.trainTime = trainTime; + + this.firstDataTime = getDataTime(0); + this.finalDataTime = getDataTime(data.size() - 1); + } + + private Instant getDataTime(int index) { + String finalTimeStr = data.get(index).get("timestamp").getAsString(); + return Instant.ofEpochMilli(Long.parseLong(finalTimeStr)); + } + } + public static final Logger LOG = (Logger) LogManager.getLogger(AbstractADSyntheticDataTest.class); - private static int batchSize = 1000; + protected static final double EPSILON = 1e-3; + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + // increase the AD memory percentage. Since enabling jacoco coverage instrumentation, + // the memory is not enough to finish HistoricalAnalysisRestApiIT. + updateClusterSettings(AD_MODEL_MAX_SIZE_PERCENTAGE.getKey(), 0.5); + } protected void runDetectionResult(String detectorId, Instant begin, Instant end, RestClient client, int entitySize) throws IOException, InterruptedException { @@ -59,11 +100,80 @@ protected void runDetectionResult(String detectorId, Instant begin, Instant end, Thread.sleep(50 * entitySize); } - protected List getAnomalyResult(String detectorId, Instant end, int entitySize, RestClient client) - throws InterruptedException { + protected void startHistorical(String detectorId, Instant begin, Instant end, RestClient client, int entitySize) throws IOException, + InterruptedException { + // trigger run in current interval + Request request = new Request( + "POST", + String.format(Locale.ROOT, "/_opendistro/_anomaly_detection/detectors/%s/_start", detectorId) + ); + request + .setJsonEntity( + String.format(Locale.ROOT, "{ \"start_time\": %d, \"end_time\": %d }", begin.toEpochMilli(), end.toEpochMilli()) + ); + int statusCode = client.performRequest(request).getStatusLine().getStatusCode(); + assert (statusCode >= 200 && statusCode < 300); + + // wait for 50 milliseconds per entity before next query + Thread.sleep(50 * entitySize); + } + + protected Map preview(String detector, Instant begin, Instant end, RestClient client) throws IOException, + InterruptedException { + LOG.info("preview detector {}", detector); + // trigger run in current interval + Request request = new Request("POST", "/_plugins/_anomaly_detection/detectors/_preview"); + request + .setJsonEntity( + String + .format( + Locale.ROOT, + "{ \"period_start\": %d, \"period_end\": %d, \"detector\": %s }", + begin.toEpochMilli(), + end.toEpochMilli(), + detector + ) + ); + Response response = client.performRequest(request); + int statusCode = response.getStatusLine().getStatusCode(); + assert (statusCode >= 200 && statusCode < 300); + + return entityAsMap(response); + } + + protected Map previewWithFailure(String detector, Instant begin, Instant end, RestClient client) throws IOException, + InterruptedException { + // trigger run in current interval + Request request = new Request("POST", "/_plugins/_anomaly_detection/detectors/_preview"); + request + .setJsonEntity( + String + .format( + Locale.ROOT, + "{ \"period_start\": %d, \"period_end\": %d, \"detector\": %s }", + begin.toEpochMilli(), + end.toEpochMilli(), + detector + ) + ); + Response response = client.performRequest(request); + int statusCode = response.getStatusLine().getStatusCode(); + assert (statusCode == 400); + + return entityAsMap(response); + } + + protected List getAnomalyResult( + String detectorId, + Instant end, + int entitySize, + RestClient client, + boolean approximateDataEndTime, + long intervalMillis + ) throws InterruptedException { Request request = new Request("POST", "/_plugins/_anomaly_detection/detectors/results/_search"); - String jsonTemplate = "{\n" + String jsonTemplatePrefix = "{\n" + " \"query\": {\n" + " \"bool\": {\n" + " \"filter\": [\n" @@ -81,19 +191,38 @@ protected List getAnomalyResult(String detectorId, Instant end, int + " },\n" + " {\n" + " \"range\": {\n" - + " \"data_end_time\": {\n" - + " \"gte\": %d,\n" - + " \"lte\": %d\n" - + " }\n" - + " }\n" - + " }\n" - + " ]\n" - + " }\n" - + " }\n" - + "}"; + + " \"data_end_time\": {\n"; + + StringBuilder jsonTemplate = new StringBuilder(); + jsonTemplate.append(jsonTemplatePrefix); + + if (approximateDataEndTime) { + // we may get two interval results if using gte + jsonTemplate.append(" \"gt\": %d,\n \"lte\": %d\n"); + } else { + jsonTemplate.append(" \"gte\": %d,\n \"lte\": %d\n"); + } + + jsonTemplate + .append( + " }\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + " }\n" + + "}" + ); long dateEndTime = end.toEpochMilli(); - String formattedJson = String.format(Locale.ROOT, jsonTemplate, detectorId, dateEndTime, dateEndTime); + String formattedJson = null; + + if (approximateDataEndTime) { + formattedJson = String.format(Locale.ROOT, jsonTemplate.toString(), detectorId, dateEndTime - intervalMillis, dateEndTime); + } else { + formattedJson = String.format(Locale.ROOT, jsonTemplate.toString(), detectorId, dateEndTime, dateEndTime); + } + request.setJsonEntity(formattedJson); // wait until results are available @@ -135,6 +264,7 @@ protected List getAnomalyResult(String detectorId, Instant end, int String matchAll = "{\n" + " \"size\": 1000,\n" + " \"query\": {\n" + " \"match_all\": {}\n" + " }\n" + "}"; request.setJsonEntity(matchAll); JsonArray hits = getHits(client, request); + LOG.info("Query: {}", formattedJson); LOG.info("match all result: {}", hits); } catch (Exception e) { LOG.warn("Exception while waiting for match all result", e); @@ -143,12 +273,26 @@ protected List getAnomalyResult(String detectorId, Instant end, int return new ArrayList<>(); } - protected double getAnomalyGrade(JsonObject source) { + protected List getRealTimeAnomalyResult(String detectorId, Instant end, int entitySize, RestClient client) + throws InterruptedException { + return getAnomalyResult(detectorId, end, entitySize, client, false, 0); + } + + public double getAnomalyGrade(JsonObject source) { return source.get("anomaly_grade").getAsDouble(); } - protected String getEntity(JsonObject source) { - return source.get("entity").getAsJsonArray().get(0).getAsJsonObject().get("value").getAsString(); + public double getConfidence(JsonObject source) { + return source.get("confidence").getAsDouble(); + } + + public String getEntity(JsonObject source) { + JsonElement element = source.get("entity"); + if (element == null) { + // single stream + return "dummy"; + } + return element.getAsJsonArray().get(0).getAsJsonObject().get("value").getAsString(); } /** @@ -157,7 +301,7 @@ protected String getEntity(JsonObject source) { * @param defaultVal default anomaly time. Usually data end time. * @return anomaly event time. */ - protected Instant getAnomalyTime(JsonObject source, Instant defaultVal) { + public Instant getAnomalyTime(JsonObject source, Instant defaultVal) { JsonElement anomalyTime = source.get("approx_anomaly_start_time"); if (anomalyTime != null) { long epochhMillis = anomalyTime.getAsLong(); @@ -166,6 +310,39 @@ protected Instant getAnomalyTime(JsonObject source, Instant defaultVal) { return defaultVal; } + public JsonObject getFeature(JsonObject source, int index) { + JsonArray featureDataArray = source.getAsJsonArray("feature_data"); + + // Get the index element from the JsonArray + return featureDataArray.get(index).getAsJsonObject(); + } + + public JsonObject getImputed(JsonObject source, int index) { + JsonArray featureDataArray = source.getAsJsonArray("feature_imputed"); + if (featureDataArray == null) { + return null; + } + + // Get the index element from the JsonArray + return featureDataArray.get(index).getAsJsonObject(); + } + + protected JsonObject getImputed(JsonObject source, String featureId) { + JsonArray featureDataArray = source.getAsJsonArray("feature_imputed"); + if (featureDataArray == null) { + return null; + } + + for (int i = 0; i < featureDataArray.size(); i++) { + // Get the index element from the JsonArray + JsonObject jsonObject = featureDataArray.get(i).getAsJsonObject(); + if (jsonObject.get("feature_id").getAsString().equals(featureId)) { + return jsonObject; + } + } + return null; + } + protected String createDetector(RestClient client, String detectorJson) throws Exception { Request request = new Request("POST", "/_plugins/_anomaly_detection/detectors/"); @@ -247,9 +424,11 @@ protected void waitForInitDetector(String detectorId, RestClient client) throws * @param client OpenSearch Client * @param end date end time of the most recent detection period * @param entitySize the number of entity results to wait for + * @return initial result * @throws Exception when failing to query/indexing from/to OpenSearch */ - protected void simulateWaitForInitDetector(String detectorId, RestClient client, Instant end, int entitySize) throws Exception { + protected List simulateWaitForInitDetector(String detectorId, RestClient client, Instant end, int entitySize) + throws Exception { long startTime = System.currentTimeMillis(); long duration = 0; @@ -257,45 +436,42 @@ protected void simulateWaitForInitDetector(String detectorId, RestClient client, Thread.sleep(1_000); - List sourceList = getAnomalyResult(detectorId, end, entitySize, client); + List sourceList = getRealTimeAnomalyResult(detectorId, end, entitySize, client); if (sourceList.size() > 0 && getAnomalyGrade(sourceList.get(0)) >= 0) { - break; + return sourceList; } duration = System.currentTimeMillis() - startTime; } while (duration <= 60_000); - assertTrue("time out while waiting for initing detector", duration <= 60_000); + assertTrue("time out while waiting for initing detector", false); + return null; } - protected void bulkIndexData(List data, String datasetName, RestClient client, String mapping, int ingestDataSize) - throws Exception { - createIndex(datasetName, client, mapping); - StringBuilder bulkRequestBuilder = new StringBuilder(); - LOG.info("data size {}", data.size()); - int count = 0; - int pickedIngestSize = Math.min(ingestDataSize, data.size()); - for (int i = 0; i < pickedIngestSize; i++) { - bulkRequestBuilder.append("{ \"index\" : { \"_index\" : \"" + datasetName + "\", \"_id\" : \"" + i + "\" } }\n"); - bulkRequestBuilder.append(data.get(i).toString()).append("\n"); - count++; - if (count >= batchSize || i == pickedIngestSize - 1) { - count = 0; - TestHelpers - .makeRequest( - client, - "POST", - "_bulk?refresh=true", - null, - toHttpEntity(bulkRequestBuilder.toString()), - ImmutableList.of(new BasicHeader(HttpHeaders.USER_AGENT, "Kibana")) - ); - Thread.sleep(1_000); + protected List waitForHistoricalDetector( + String detectorId, + RestClient client, + Instant end, + int entitySize, + int intervalMillis + ) throws Exception { + + long startTime = System.currentTimeMillis(); + long duration = 0; + do { + + Thread.sleep(1_000); + + List sourceList = getAnomalyResult(detectorId, end, entitySize, client, true, intervalMillis); + if (sourceList.size() > 0 && getAnomalyGrade(sourceList.get(0)) >= 0) { + return sourceList; } - } - waitAllSyncheticDataIngested(data.size(), datasetName, client); - LOG.info("data ingestion complete"); + duration = System.currentTimeMillis() - startTime; + } while (duration <= 60_000); + + assertTrue("time out while waiting for historical detector to finish", false); + return null; } /** @@ -313,7 +489,7 @@ protected void simulateStartDetector(String detectorId, Instant begin, Instant e runDetectionResult(detectorId, begin, end, client, entitySize); } - protected int isAnomaly(Instant time, List> labels) { + public int isAnomaly(Instant time, List> labels) { for (int i = 0; i < labels.size(); i++) { Entry window = labels.get(i); if (time.compareTo(window.getKey()) >= 0 && time.compareTo(window.getValue()) <= 0) { @@ -331,4 +507,124 @@ protected List getData(String datasetFileName) throws Exception { jsonArray.iterator().forEachRemaining(i -> list.add(i.getAsJsonObject())); return list; } + + protected Instant dataToExecutionTime(Instant instant, Duration windowDelay) { + return instant.plus(windowDelay); + } + + /** + * Assume the data is sorted in time. The method look up and below startIndex + * and return how many timestamps equal to timestampStr. + * @param startIndex where to start look for timestamp + * @return how many timestamps equal to timestampStr + */ + protected int findGivenTimeEntities(int startIndex, List data) { + String timestampStr = data.get(startIndex).get("timestamp").getAsString(); + int count = 1; + for (int i = startIndex - 1; i >= 0; i--) { + String trainTimeStr = data.get(i).get("timestamp").getAsString(); + if (trainTimeStr.equals(timestampStr)) { + count++; + } else { + break; + } + } + for (int i = startIndex + 1; i < data.size(); i++) { + String trainTimeStr = data.get(i).get("timestamp").getAsString(); + if (trainTimeStr.equals(timestampStr)) { + count++; + } else { + break; + } + } + return count; + } + + /** + * + * @param beginTimeStampAsString data start time in string + * @param entityMap a map to record the number of times we have seen a timestamp. Used to detect missing values. + * @param windowDelay ingestion delay + * @param intervalMinutes detector interval + * @param detectorId detector Id + * @param client RestFul client + * @param numberOfEntities the number of entities. + * @return whether we erred out. + */ + protected boolean scoreOneResult( + String beginTimeStampAsString, + TreeMap entityMap, + Duration windowDelay, + int intervalMinutes, + String detectorId, + RestClient client, + int numberOfEntities + ) { + Integer newCount = entityMap.compute(beginTimeStampAsString, (key, oldValue) -> (oldValue == null) ? 1 : oldValue + 1); + if (newCount > 1) { + // we have seen this timestamp before. Without this line, we will get rcf IllegalArgumentException about out of order tuples + return false; + } + Instant begin = dataToExecutionTime(Instant.ofEpochMilli(Long.parseLong(beginTimeStampAsString)), windowDelay); + Instant end = begin.plus(intervalMinutes, ChronoUnit.MINUTES); + try { + runDetectionResult(detectorId, begin, end, client, numberOfEntities); + } catch (Exception e) { + LOG.error("failed to run detection result", e); + return true; + } + return false; + } + + protected List startRealTimeDetector( + TrainResult trainResult, + int numberOfEntities, + int intervalMinutes, + boolean imputeEnabled + ) throws Exception { + Instant executeBegin = dataToExecutionTime(trainResult.trainTime, trainResult.windowDelay); + Instant executeEnd = executeBegin.plus(intervalMinutes, ChronoUnit.MINUTES); + Instant dataEnd = trainResult.trainTime.plus(intervalMinutes, ChronoUnit.MINUTES); + + LOG.info("start detector {}, dataStart {}, dataEnd {}", trainResult.detectorId, trainResult.trainTime, dataEnd); + simulateStartDetector(trainResult.detectorId, executeBegin, executeEnd, client(), numberOfEntities); + int resultsToWait = numberOfEntities; + if (!imputeEnabled) { + resultsToWait = findGivenTimeEntities(trainResult.rawDataTrainTestSplit - 1, trainResult.data); + } + LOG.info("wait for initting detector {}. {} results are expected.", trainResult.detectorId, resultsToWait); + return simulateWaitForInitDetector(trainResult.detectorId, client(), dataEnd, resultsToWait); + } + + protected List startHistoricalDetector( + TrainResult trainResult, + int numberOfEntities, + int intervalMinutes, + boolean imputeEnabled + ) throws Exception { + LOG.info("start historical detector {}", trainResult.detectorId); + startHistorical(trainResult.detectorId, trainResult.firstDataTime, trainResult.finalDataTime, client(), numberOfEntities); + int resultsToWait = numberOfEntities; + if (!imputeEnabled) { + findGivenTimeEntities(trainResult.data.size() - 1, trainResult.data); + } + LOG + .info( + "wait for historical detector {} at {}. {} results are expected.", + trainResult.detectorId, + trainResult.finalDataTime, + resultsToWait + ); + return waitForHistoricalDetector( + trainResult.detectorId, + client(), + trainResult.finalDataTime, + resultsToWait, + intervalMinutes * 60000 + ); + } + + public static boolean areDoublesEqual(double d1, double d2) { + return Math.abs(d1 - d2) < EPSILON; + } } diff --git a/src/test/java/org/opensearch/ad/AnomalyDetectorJobRunnerTests.java b/src/test/java/org/opensearch/ad/AnomalyDetectorJobRunnerTests.java index fe9776f11..97faa29f5 100644 --- a/src/test/java/org/opensearch/ad/AnomalyDetectorJobRunnerTests.java +++ b/src/test/java/org/opensearch/ad/AnomalyDetectorJobRunnerTests.java @@ -262,7 +262,7 @@ public void setup() throws Exception { when(adTaskCacheManager.hasQueriedResultIndex(anyString())).thenReturn(false); - detector = TestHelpers.randomAnomalyDetectorWithEmptyFeature(); + detector = TestHelpers.randomAnomalyDetector("timestamp", "sourceIndex"); doAnswer(invocation -> { ActionListener> listener = invocation.getArgument(2); listener.onResponse(Optional.of(detector)); diff --git a/src/test/java/org/opensearch/ad/e2e/AbstractMissingSingleFeatureTestCase.java b/src/test/java/org/opensearch/ad/e2e/AbstractMissingSingleFeatureTestCase.java new file mode 100644 index 000000000..3eec05657 --- /dev/null +++ b/src/test/java/org/opensearch/ad/e2e/AbstractMissingSingleFeatureTestCase.java @@ -0,0 +1,301 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.e2e; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.NavigableSet; +import java.util.TreeMap; + +import org.apache.commons.lang3.tuple.Pair; +import org.opensearch.timeseries.AbstractSyntheticDataTest; +import org.opensearch.timeseries.dataprocessor.ImputationMethod; + +import com.google.gson.JsonObject; + +public abstract class AbstractMissingSingleFeatureTestCase extends MissingIT { + @Override + protected String genDetector( + int trainTestSplit, + long windowDelayMinutes, + boolean hc, + ImputationMethod imputation, + long trainTimeMillis + ) { + StringBuilder sb = new StringBuilder(); + // common part + sb + .append( + "{ \"name\": \"test\", \"description\": \"test\", \"time_field\": \"timestamp\"" + + ", \"indices\": [\"%s\"], \"feature_attributes\": [{ \"feature_name\": \"feature 1\", \"feature_enabled\": " + + "\"true\", \"aggregation_query\": { \"Feature1\": { \"avg\": { \"field\": \"data\" } } } }" + + "], \"detection_interval\": { \"period\": { \"interval\": %d, \"unit\": \"Minutes\" } }, " + + "\"history\": %d," + ); + + if (windowDelayMinutes > 0) { + sb + .append( + String + .format( + Locale.ROOT, + "\"window_delay\": { \"period\": {\"interval\": %d, \"unit\": \"MINUTES\"}},", + windowDelayMinutes + ) + ); + } + if (hc) { + sb.append("\"category_field\": [\"%s\"], "); + } + + switch (imputation) { + case ZERO: + sb.append("\"imputation_option\": { \"method\": \"zero\" },"); + break; + case PREVIOUS: + sb.append("\"imputation_option\": { \"method\": \"previous\" },"); + break; + case FIXED_VALUES: + sb + .append( + "\"imputation_option\": { \"method\": \"fixed_values\", \"defaultFill\": [{ \"feature_name\" : \"feature 1\", \"data\": 1 }] }," + ); + break; + } + // end + sb.append("\"schema_version\": 0}"); + + if (hc) { + return String.format(Locale.ROOT, sb.toString(), datasetName, intervalMinutes, trainTestSplit - 1, categoricalField); + } else { + return String.format(Locale.ROOT, sb.toString(), datasetName, intervalMinutes, trainTestSplit - 1); + } + + } + + @Override + protected AbstractSyntheticDataTest.GenData genData( + int trainTestSplit, + int numberOfEntities, + AbstractSyntheticDataTest.MISSING_MODE missingMode + ) throws Exception { + return genUniformSingleFeatureData( + intervalMinutes, + trainTestSplit, + numberOfEntities, + categoricalField, + missingMode, + continuousImputeStartIndex, + continuousImputeEndIndex, + randomDoubles + ); + } + + @Override + protected double extractFeatureValue(JsonObject source) { + JsonObject feature0 = getFeature(source, 0); + return feature0.get("data").getAsDouble(); + } + + protected void verifyGrade(Integer testIndex, ImputationMethod imputation, double anomalyGrade) { + if (testIndex == continuousImputeStartIndex || testIndex == continuousImputeEndIndex + 1) { + switch (imputation) { + case ZERO: + assertTrue(Double.compare(anomalyGrade, 0) > 0); + break; + case PREVIOUS: + assertEquals(anomalyGrade, 0, EPSILON); + break; + case FIXED_VALUES: + assertTrue(Double.compare(anomalyGrade, 0) > 0); + break; + default: + assertTrue(false); + break; + } + } else { + assertEquals("testIndex: " + testIndex, 0, anomalyGrade, EPSILON); + } + } + + protected boolean verifyConfidence(Integer testIndex, double confidence, Double lastConfidence) { + if (lastConfidence == null) { + return false; + } + + // we will see confidence increasing again after some point in shingle size (default 8) + if (testIndex <= continuousImputeStartIndex || testIndex >= continuousImputeEndIndex + 8) { + assertTrue( + String.format(Locale.ROOT, "confidence: %f, lastConfidence: %f, testIndex: %d", confidence, lastConfidence, testIndex), + Double.compare(confidence, lastConfidence) >= 0 + ); + } else if (testIndex > continuousImputeStartIndex && testIndex <= continuousImputeEndIndex) { + assertTrue( + String.format(Locale.ROOT, "confidence: %f, lastConfidence: %f, testIndex: %d", confidence, lastConfidence, testIndex), + Double.compare(confidence, lastConfidence) <= 0 + ); + } + return true; + } + + @Override + protected void runTest( + long firstDataStartTime, + AbstractSyntheticDataTest.GenData dataGenerated, + Duration windowDelay, + String detectorId, + int numberOfEntities, + AbstractSyntheticDataTest.MISSING_MODE mode, + ImputationMethod imputation, + int numberOfMissingToCheck, + boolean realTime + ) { + int errors = 0; + List data = dataGenerated.data; + long lastDataStartTime = data.get(data.size() - 1).get("timestamp").getAsLong(); + long dataStartTime = firstDataStartTime + intervalMillis; + NavigableSet missingTimestamps = dataGenerated.missingTimestamps; + NavigableSet> missingEntities = dataGenerated.missingEntities; + + // we might miss timestamps at the end + if (mode == AbstractSyntheticDataTest.MISSING_MODE.MISSING_TIMESTAMP + || mode == AbstractSyntheticDataTest.MISSING_MODE.CONTINUOUS_IMPUTE) { + lastDataStartTime = Math.max(missingTimestamps.last(), lastDataStartTime); + } else if (mode == AbstractSyntheticDataTest.MISSING_MODE.MISSING_ENTITY) { + lastDataStartTime = Math.max(missingEntities.last().getLeft(), lastDataStartTime); + } + // an entity might have missing values (e.g., at timestamp 1694713200000). + // Use a map to record the number of times we have seen them. + // data start time -> the number of entities + TreeMap entityMap = new TreeMap<>(); + + int missingIndex = 0; + Map lastConfidence = new HashMap<>(); + + // look two default shingle size after imputation area + long continuousModeStopTime = dataStartTime + intervalMillis * (continuousImputeEndIndex + 16); + + // exit when reaching last date time or we have seen at least three missing values. + // In continuous impute mode, we may want to read a few more points to check if confidence increases or not + LOG.info("lastDataStartTime: {}, dataStartTime: {}", lastDataStartTime, dataStartTime); + // test data 0 is used trigger cold start + int testIndex = 1; + while (lastDataStartTime >= dataStartTime && (missingIndex <= numberOfMissingToCheck && continuousModeStopTime >= dataStartTime)) { + // no need to call _run api in each interval in historical case + if (realTime + && scoreOneResult( + String.valueOf(dataStartTime), + entityMap, + windowDelay, + intervalMinutes, + detectorId, + client(), + numberOfEntities + )) { + errors++; + } + + LOG.info("test index: {}", testIndex); + + Instant begin = Instant.ofEpochMilli(dataStartTime); + Instant end = begin.plus(intervalMinutes, ChronoUnit.MINUTES); + try { + List sourceList = null; + if (realTime) { + sourceList = getRealTimeAnomalyResult(detectorId, end, numberOfEntities, client()); + } else { + sourceList = getAnomalyResult(detectorId, end, numberOfEntities, client(), true, intervalMillis); + } + + assertTrue( + String + .format( + Locale.ROOT, + "the number of results is %d at %s, expected %d ", + sourceList.size(), + end.toEpochMilli(), + numberOfEntities + ), + sourceList.size() == numberOfEntities + ); + + // used to track if any entity within a timestamp has imputation and then we increment + // missingIndex outside the loop. Used in MISSING_TIMESTAMP mode. + boolean imputed = false; + for (int j = 0; j < numberOfEntities; j++) { + JsonObject source = sourceList.get(j); + JsonObject feature0 = getFeature(source, 0); + double dataValue = feature0.get("data").getAsDouble(); + + JsonObject imputed0 = getImputed(source, 0); + + String entity = getEntity(source); + + if (mode == AbstractSyntheticDataTest.MISSING_MODE.MISSING_TIMESTAMP && missingTimestamps.contains(dataStartTime)) { + verifyImputation(imputation, lastSeen, dataValue, imputed0, entity); + imputed = true; + } else if (mode == AbstractSyntheticDataTest.MISSING_MODE.MISSING_ENTITY + && missingEntities.contains(Pair.of(dataStartTime, entity))) { + verifyImputation(imputation, lastSeen, dataValue, imputed0, entity); + missingIndex++; + } else if (mode == AbstractSyntheticDataTest.MISSING_MODE.CONTINUOUS_IMPUTE) { + int imputeIndex = getIndex(missingTimestamps, dataStartTime); + double grade = getAnomalyGrade(source); + verifyGrade(testIndex, imputation, grade); + + if (imputeIndex >= 0) { + imputed = true; + } + + double confidence = getConfidence(source); + verifyConfidence(testIndex, confidence, lastConfidence.get(entity)); + lastConfidence.put(entity, confidence); + } else { + assertEquals(null, imputed0); + } + + lastSeen.put(entity, dataValue); + } + if (imputed) { + missingIndex++; + } + } catch (Exception e) { + errors++; + LOG.error("failed to get detection results", e); + } finally { + testIndex++; + } + + dataStartTime += intervalMillis; + } + + // at least numberOfMissingToCheck missing value imputation is seen + assertTrue( + String.format(Locale.ROOT, "missingIndex %d, numberOfMissingToCheck %d", missingIndex, numberOfMissingToCheck), + missingIndex >= numberOfMissingToCheck + ); + assertTrue(errors < maxError); + } + + /** + * + * @param element type + * @param set ordered set + * @param element element to compare + * @return the index of the element that is less than or equal to the given element. + */ + protected static int getIndex(NavigableSet set, E element) { + if (!set.contains(element)) { + return -1; + } + return set.headSet(element, true).size() - 1; + } +} diff --git a/src/test/java/org/opensearch/ad/e2e/AbstractRuleModelPerfTestCase.java b/src/test/java/org/opensearch/ad/e2e/AbstractRuleModelPerfTestCase.java new file mode 100644 index 000000000..30f07de3d --- /dev/null +++ b/src/test/java/org/opensearch/ad/e2e/AbstractRuleModelPerfTestCase.java @@ -0,0 +1,121 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.e2e; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import org.apache.commons.lang3.tuple.Triple; + +import com.google.gson.JsonObject; + +public abstract class AbstractRuleModelPerfTestCase extends AbstractRuleTestCase { + protected void verifyTestResults( + Triple, Integer, Map>> testResults, + Map>> anomalies, + Map minPrecision, + Map minRecall, + int maxError + ) { + Map resultMap = testResults.getLeft(); + Map> foundWindows = testResults.getRight(); + + for (Entry entry : resultMap.entrySet()) { + String entity = entry.getKey(); + double[] testResultsArray = entry.getValue(); + double positives = testResultsArray[0]; + double truePositives = testResultsArray[1]; + + // precision = predicted anomaly points that are true / predicted anomaly points + double precision = positives > 0 ? truePositives / positives : 0; + double minPrecisionValue = minPrecision.getOrDefault(entity, .4); + assertTrue( + String + .format( + Locale.ROOT, + "precision expected at least %f but got %f. positives %f, truePositives %f", + minPrecisionValue, + precision, + positives, + truePositives + ), + precision >= minPrecisionValue + ); + + // recall = windows containing predicted anomaly points / total anomaly windows + int anomalyWindow = anomalies.getOrDefault(entity, new ArrayList<>()).size(); + int foundWindowSize = foundWindows.getOrDefault(entity, new HashSet<>()).size(); + double recall = anomalyWindow > 0 ? foundWindowSize * 1.0d / anomalyWindow : 0; + double minRecallValue = minRecall.getOrDefault(entity, .7); + assertTrue( + String + .format( + Locale.ROOT, + "recall should be at least %f but got %f. anomalyWindow %d, foundWindowSize %d ", + minRecallValue, + recall, + anomalyWindow, + foundWindowSize + ), + recall >= minRecallValue + ); + + LOG.info("Entity {}, Precision: {}, Window recall: {}", entity, precision, recall); + } + + int errors = testResults.getMiddle(); + assertTrue(errors <= maxError); + } + + protected void analyzeResults( + Map>> anomalies, + Map res, + Map> foundWindow, + String beginTimeStampAsString, + int entitySize, + Instant begin, + List sourceList + ) { + assertTrue( + String + .format( + Locale.ROOT, + "the number of results is %d at %s, expected %d ", + sourceList.size(), + beginTimeStampAsString, + entitySize + ), + sourceList.size() == entitySize + ); + for (int j = 0; j < entitySize; j++) { + JsonObject source = sourceList.get(j); + double anomalyGrade = getAnomalyGrade(source); + assertTrue("anomalyGrade cannot be negative", anomalyGrade >= 0); + if (anomalyGrade > 0) { + String entity = getEntity(source); + double[] entityResult = res.computeIfAbsent(entity, key -> new double[] { 0, 0 }); + // positive++ + entityResult[0]++; + Instant anomalyTime = getAnomalyTime(source, begin); + LOG.info("Found anomaly: entity {}, time {} result {}.", entity, anomalyTime, source); + int anomalyWindow = isAnomaly(anomalyTime, anomalies.getOrDefault(entity, new ArrayList<>())); + if (anomalyWindow != -1) { + LOG.info("True anomaly: entity {}, time {}.", entity, begin); + // truePositives++; + entityResult[1]++; + Set window = foundWindow.computeIfAbsent(entity, key -> new HashSet<>()); + window.add(anomalyWindow); + } + } + } + } +} diff --git a/src/test/java/org/opensearch/ad/e2e/AbstractRuleTestCase.java b/src/test/java/org/opensearch/ad/e2e/AbstractRuleTestCase.java index a5d6c8686..941af35a5 100644 --- a/src/test/java/org/opensearch/ad/e2e/AbstractRuleTestCase.java +++ b/src/test/java/org/opensearch/ad/e2e/AbstractRuleTestCase.java @@ -5,39 +5,32 @@ package org.opensearch.ad.e2e; +import java.io.File; +import java.io.FileReader; +import java.nio.charset.Charset; import java.time.Duration; import java.time.Instant; -import java.time.temporal.ChronoUnit; +import java.time.format.DateTimeFormatter; +import java.util.AbstractMap.SimpleEntry; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; -import java.util.TreeMap; +import java.util.Map; +import java.util.Map.Entry; import org.opensearch.ad.AbstractADSyntheticDataTest; import org.opensearch.client.RestClient; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonParser; -public class AbstractRuleTestCase extends AbstractADSyntheticDataTest { - - protected static class TrainResult { - String detectorId; - List data; - // actual index of training data. As we have multiple entities, - // trainTestSplit means how many groups of entities are used for training. - // rawDataTrainTestSplit is the actual index of training data. - int rawDataTrainTestSplit; - Duration windowDelay; - - public TrainResult(String detectorId, List data, int rawDataTrainTestSplit, Duration windowDelay) { - this.detectorId = detectorId; - this.data = data; - this.rawDataTrainTestSplit = rawDataTrainTestSplit; - this.windowDelay = windowDelay; - } - } +public abstract class AbstractRuleTestCase extends AbstractADSyntheticDataTest { + String categoricalField = "componentName"; /** - * Ingest all of the data in file datasetName + * Ingest all of the data in file datasetName and create detector * * @param datasetName data set file name * @param intervalMinutes detector interval @@ -47,14 +40,64 @@ public TrainResult(String detectorId, List data, int rawDataTrainTes * @return TrainResult for the following method calls * @throws Exception failing to ingest data */ - protected TrainResult ingestTrainData( + protected TrainResult ingestTrainDataAndCreateDetector( String datasetName, int intervalMinutes, int numberOfEntities, int trainTestSplit, boolean useDateNanos ) throws Exception { - return ingestTrainData(datasetName, intervalMinutes, numberOfEntities, trainTestSplit, useDateNanos, -1); + return ingestTrainDataAndCreateDetector(datasetName, intervalMinutes, numberOfEntities, trainTestSplit, useDateNanos, -1); + } + + protected TrainResult ingestTrainDataAndCreateDetector( + String datasetName, + int intervalMinutes, + int numberOfEntities, + int trainTestSplit, + boolean useDateNanos, + int ingestDataSize + ) throws Exception { + TrainResult trainResult = ingestTrainData( + datasetName, + intervalMinutes, + numberOfEntities, + trainTestSplit, + useDateNanos, + ingestDataSize + ); + + String detector = genDetector(datasetName, intervalMinutes, trainTestSplit, trainResult); + String detectorId = createDetector(client(), detector); + LOG.info("Created detector {}", detectorId); + trainResult.detectorId = detectorId; + + return trainResult; + } + + protected String genDetector(String datasetName, int intervalMinutes, int trainTestSplit, TrainResult trainResult) { + String detector = String + .format( + Locale.ROOT, + "{ \"name\": \"test\", \"description\": \"test\", \"time_field\": \"timestamp\"" + + ", \"indices\": [\"%s\"], \"feature_attributes\": [{ \"feature_name\": \"feature 1\", \"feature_enabled\": " + + "\"true\", \"aggregation_query\": { \"Feature1\": { \"sum\": { \"field\": \"transform._doc_count\" } } } }" + + "], \"detection_interval\": { \"period\": { \"interval\": %d, \"unit\": \"Minutes\" } }, " + + "\"category_field\": [\"%s\"], " + + "\"window_delay\": { \"period\": {\"interval\": %d, \"unit\": \"MINUTES\"}}," + + "\"history\": %d," + + "\"schema_version\": 0," + + "\"rules\": [{\"action\": \"ignore_anomaly\", \"conditions\": [{\"feature_name\": \"feature 1\", \"threshold_type\": \"actual_over_expected_ratio\", \"operator\": \"lte\", \"value\": 0.3}, " + + "{\"feature_name\": \"feature 1\", \"threshold_type\": \"expected_over_actual_ratio\", \"operator\": \"lte\", \"value\": 0.3}" + + "]}]" + + "}", + datasetName, + intervalMinutes, + categoricalField, + trainResult.windowDelay.toMinutes(), + trainTestSplit - 1 + ); + return detector; } protected TrainResult ingestTrainData( @@ -70,7 +113,6 @@ protected TrainResult ingestTrainData( List data = getData(dataFileName); RestClient client = client(); - String categoricalField = "componentName"; String mapping = String .format( Locale.ROOT, @@ -105,111 +147,29 @@ protected TrainResult ingestTrainData( */ long windowDelayMinutes = Duration.between(trainTime, Instant.now()).toMinutes(); Duration windowDelay = Duration.ofMinutes(windowDelayMinutes); - - String detector = String - .format( - Locale.ROOT, - "{ \"name\": \"test\", \"description\": \"test\", \"time_field\": \"timestamp\"" - + ", \"indices\": [\"%s\"], \"feature_attributes\": [{ \"feature_name\": \"feature 1\", \"feature_enabled\": " - + "\"true\", \"aggregation_query\": { \"Feature1\": { \"sum\": { \"field\": \"transform._doc_count\" } } } }" - + "], \"detection_interval\": { \"period\": { \"interval\": %d, \"unit\": \"Minutes\" } }, " - + "\"category_field\": [\"%s\"], " - + "\"window_delay\": { \"period\": {\"interval\": %d, \"unit\": \"MINUTES\"}}," - + "\"history\": %d," - + "\"schema_version\": 0," - + "\"rules\": [{\"action\": \"ignore_anomaly\", \"conditions\": [{\"feature_name\": \"feature 1\", \"threshold_type\": \"actual_over_expected_ratio\", \"operator\": \"lte\", \"value\": 0.3}, " - + "{\"feature_name\": \"feature 1\", \"threshold_type\": \"expected_over_actual_ratio\", \"operator\": \"lte\", \"value\": 0.3}" - + "]}]" - + "}", - datasetName, - intervalMinutes, - categoricalField, - windowDelayMinutes, - trainTestSplit - 1 - ); - String detectorId = createDetector(client, detector); - LOG.info("Created detector {}", detectorId); - - Instant executeBegin = dataToExecutionTime(trainTime, windowDelay); - Instant executeEnd = executeBegin.plus(intervalMinutes, ChronoUnit.MINUTES); - Instant dataEnd = trainTime.plus(intervalMinutes, ChronoUnit.MINUTES); - - LOG.info("start detector {}", detectorId); - simulateStartDetector(detectorId, executeBegin, executeEnd, client, numberOfEntities); - int resultsToWait = findTrainTimeEntities(rawDataTrainTestSplit - 1, data); - LOG.info("wait for initting detector {}. {} results are expected.", detectorId, resultsToWait); - simulateWaitForInitDetector(detectorId, client, dataEnd, resultsToWait); - - return new TrainResult(detectorId, data, rawDataTrainTestSplit, windowDelay); + return new TrainResult(null, data, rawDataTrainTestSplit, windowDelay, trainTime); } - /** - * Assume the data is sorted in time. The method look up and below startIndex - * and return how many timestamps equal to timestampStr. - * @param startIndex where to start look for timestamp - * @return how many timestamps equal to timestampStr - */ - protected int findTrainTimeEntities(int startIndex, List data) { - String timestampStr = data.get(startIndex).get("timestamp").getAsString(); - int count = 1; - for (int i = startIndex - 1; i >= 0; i--) { - String trainTimeStr = data.get(i).get("timestamp").getAsString(); - if (trainTimeStr.equals(timestampStr)) { - count++; - } else { - break; + public Map>> getAnomalyWindowsMap(String labelFileName) throws Exception { + JsonObject jsonObject = JsonParser + .parseReader(new FileReader(new File(getClass().getResource(labelFileName).toURI()), Charset.defaultCharset())) + .getAsJsonObject(); + + Map>> map = new HashMap<>(); + for (Map.Entry entry : jsonObject.entrySet()) { + List> anomalies = new ArrayList<>(); + JsonElement value = entry.getValue(); + if (value.isJsonArray()) { + for (JsonElement elem : value.getAsJsonArray()) { + JsonElement beginElement = elem.getAsJsonArray().get(0); + JsonElement endElement = elem.getAsJsonArray().get(1); + Instant begin = Instant.from(DateTimeFormatter.ISO_INSTANT.parse(beginElement.getAsString())); + Instant end = Instant.from(DateTimeFormatter.ISO_INSTANT.parse(endElement.getAsString())); + anomalies.add(new SimpleEntry<>(begin, end)); + } } + map.put(entry.getKey(), anomalies); } - for (int i = startIndex + 1; i < data.size(); i++) { - String trainTimeStr = data.get(i).get("timestamp").getAsString(); - if (trainTimeStr.equals(timestampStr)) { - count++; - } else { - break; - } - } - return count; + return map; } - - protected Instant dataToExecutionTime(Instant instant, Duration windowDelay) { - return instant.plus(windowDelay); - } - - /** - * - * @param testData current data to score - * @param entityMap a map to record the number of times we have seen a timestamp. Used to detect missing values. - * @param windowDelay ingestion delay - * @param intervalMinutes detector interval - * @param detectorId detector Id - * @param client RestFul client - * @param numberOfEntities the number of entities. - * @return whether we erred out. - */ - protected boolean scoreOneResult( - JsonObject testData, - TreeMap entityMap, - Duration windowDelay, - int intervalMinutes, - String detectorId, - RestClient client, - int numberOfEntities - ) { - String beginTimeStampAsString = testData.get("timestamp").getAsString(); - Integer newCount = entityMap.compute(beginTimeStampAsString, (key, oldValue) -> (oldValue == null) ? 1 : oldValue + 1); - if (newCount > 1) { - // we have seen this timestamp before. Without this line, we will get rcf IllegalArgumentException about out of order tuples - return false; - } - Instant begin = dataToExecutionTime(Instant.ofEpochMilli(Long.parseLong(beginTimeStampAsString)), windowDelay); - Instant end = begin.plus(intervalMinutes, ChronoUnit.MINUTES); - try { - runDetectionResult(detectorId, begin, end, client, numberOfEntities); - } catch (Exception e) { - LOG.error("failed to run detection result", e); - return true; - } - return false; - } - } diff --git a/src/test/java/org/opensearch/ad/e2e/HistoricalMissingSingleFeatureIT.java b/src/test/java/org/opensearch/ad/e2e/HistoricalMissingSingleFeatureIT.java new file mode 100644 index 000000000..997292c22 --- /dev/null +++ b/src/test/java/org/opensearch/ad/e2e/HistoricalMissingSingleFeatureIT.java @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.e2e; + +import org.opensearch.timeseries.AbstractSyntheticDataTest; +import org.opensearch.timeseries.dataprocessor.ImputationMethod; + +public class HistoricalMissingSingleFeatureIT extends AbstractMissingSingleFeatureTestCase { + public void testSingleStream() throws Exception { + int numberOfEntities = 1; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.CONTINUOUS_IMPUTE; + ImputationMethod method = ImputationMethod.ZERO; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + ingestUniformSingleFeatureData( + -1, // ingest all + dataGenerated.data + ); + + TrainResult trainResult = createAndStartHistoricalDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + false, + dataGenerated.testStartTime + ); + + // we allowed 25 (continuousImputeEndIndex - continuousImputeStartIndex + 1) continuous missing timestamps in shouldSkipDataPoint + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + continuousImputeEndIndex - continuousImputeStartIndex + 1, + false + ); + } + + public void testHCFixed() throws Exception { + int numberOfEntities = 1; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.CONTINUOUS_IMPUTE; + ImputationMethod method = ImputationMethod.FIXED_VALUES; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + ingestUniformSingleFeatureData( + -1, // ingest all + dataGenerated.data + ); + + TrainResult trainResult = createAndStartHistoricalDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + true, + dataGenerated.testStartTime + ); + + // we allowed 25 (continuousImputeEndIndex - continuousImputeStartIndex + 1) continuous missing timestamps in shouldSkipDataPoint + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + continuousImputeEndIndex - continuousImputeStartIndex + 1, + false + ); + } + + public void testHCPrevious() throws Exception { + int numberOfEntities = 1; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.CONTINUOUS_IMPUTE; + ImputationMethod method = ImputationMethod.PREVIOUS; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + ingestUniformSingleFeatureData( + -1, // ingest all + dataGenerated.data + ); + + TrainResult trainResult = createAndStartHistoricalDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + true, + dataGenerated.testStartTime + ); + + // we allowed 25 (continuousImputeEndIndex - continuousImputeStartIndex + 1) continuous missing timestamps in shouldSkipDataPoint + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + continuousImputeEndIndex - continuousImputeStartIndex + 1, + false + ); + } +} diff --git a/src/test/java/org/opensearch/ad/e2e/HistoricalRuleModelPerfIT.java b/src/test/java/org/opensearch/ad/e2e/HistoricalRuleModelPerfIT.java new file mode 100644 index 000000000..a5c19d4c7 --- /dev/null +++ b/src/test/java/org/opensearch/ad/e2e/HistoricalRuleModelPerfIT.java @@ -0,0 +1,118 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.e2e; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.TreeMap; + +import org.apache.commons.lang3.tuple.Triple; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.Logger; +import org.opensearch.client.RestClient; + +import com.google.gson.JsonObject; + +public class HistoricalRuleModelPerfIT extends AbstractRuleModelPerfTestCase { + static final Logger LOG = (Logger) LogManager.getLogger(HistoricalRuleModelPerfIT.class); + + public void testRule() throws Exception { + // TODO: this test case will run for a much longer time and timeout with security enabled + if (!isHttps()) { + disableResourceNotFoundFaultTolerence(); + // there are 8 entities in the data set. Each one needs 1500 rows as training data. + Map minPrecision = new HashMap<>(); + minPrecision.put("Phoenix", 0.4); + minPrecision.put("Scottsdale", 0.5); + Map minRecall = new HashMap<>(); + minRecall.put("Phoenix", 0.9); + minRecall.put("Scottsdale", 0.6); + verifyRule("rule", 10, minPrecision.size(), 1500, minPrecision, minRecall, 20); + } + } + + public void verifyRule( + String datasetName, + int intervalMinutes, + int numberOfEntities, + int trainTestSplit, + Map minPrecision, + Map minRecall, + int maxError + ) throws Exception { + + String labelFileName = String.format(Locale.ROOT, "data/%s.label", datasetName); + Map>> anomalies = getAnomalyWindowsMap(labelFileName); + + TrainResult trainResult = ingestTrainDataAndCreateDetector(datasetName, intervalMinutes, numberOfEntities, trainTestSplit, false); + startHistoricalDetector(trainResult, numberOfEntities, intervalMinutes, false); + + Triple, Integer, Map>> results = getTestResults( + trainResult.detectorId, + trainResult.data, + trainResult.rawDataTrainTestSplit, + intervalMinutes, + anomalies, + client(), + numberOfEntities, + trainResult.windowDelay + ); + verifyTestResults(results, anomalies, minPrecision, minRecall, maxError); + } + + private Triple, Integer, Map>> getTestResults( + String detectorId, + List data, + int rawTrainTestSplit, + int intervalMinutes, + Map>> anomalies, + RestClient client, + int numberOfEntities, + Duration windowDelay + ) throws Exception { + + Map res = new HashMap<>(); + int errors = 0; + // an entity might have missing values (e.g., at timestamp 1694713200000). + // Use a map to record the number of times we have seen them. + // data start time -> the number of entities + TreeMap entityMap = new TreeMap<>(); + // historical won't detect the last point as + // 1) ParseUtils.batchFeatureQuery uses left closed right open range to construct query + // 2) ADBatchTaskRunner.runNextPiece will stop when the next piece start time is larger than or equal to dataEnd time + // 3) ADBatchTaskRunner.getDateRangeOfSourceData will make data start/end time equal to min/max data time. + for (int i = rawTrainTestSplit; i < data.size() - numberOfEntities; i++) { + entityMap.compute(data.get(i).get("timestamp").getAsString(), (key, oldValue) -> (oldValue == null) ? 1 : oldValue + 1); + } + + // hash set to dedup + Map> foundWindow = new HashMap<>(); + long intervalMillis = intervalMinutes * 60000; + + // Iterate over the TreeMap in ascending order of keys + for (Map.Entry entry : entityMap.entrySet()) { + String beginTimeStampAsString = entry.getKey(); + int entitySize = entry.getValue(); + Instant begin = Instant.ofEpochMilli(Long.parseLong(beginTimeStampAsString)); + Instant end = begin.plus(intervalMinutes, ChronoUnit.MINUTES); + try { + List sourceList = getAnomalyResult(detectorId, end, entitySize, client, true, intervalMillis); + analyzeResults(anomalies, res, foundWindow, beginTimeStampAsString, entitySize, begin, sourceList); + } catch (Exception e) { + errors++; + LOG.error("failed to get detection results", e); + } + } + return Triple.of(res, errors, foundWindow); + } +} diff --git a/src/test/java/org/opensearch/ad/e2e/MissingIT.java b/src/test/java/org/opensearch/ad/e2e/MissingIT.java new file mode 100644 index 000000000..21f216819 --- /dev/null +++ b/src/test/java/org/opensearch/ad/e2e/MissingIT.java @@ -0,0 +1,181 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.e2e; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.BeforeClass; +import org.opensearch.ad.AbstractADSyntheticDataTest; +import org.opensearch.client.RestClient; +import org.opensearch.timeseries.AbstractSyntheticDataTest; +import org.opensearch.timeseries.dataprocessor.ImputationMethod; + +import com.google.gson.JsonObject; + +public abstract class MissingIT extends AbstractADSyntheticDataTest { + protected static double min = 200.0; + protected static double max = 240.0; + protected static int dataSize = 400; + + protected static List randomDoubles; + protected static String datasetName = "missing"; + + protected int intervalMinutes = 10; + public long intervalMillis = intervalMinutes * 60000L; + protected String categoricalField = "componentName"; + protected int maxError = 20; + protected int trainTestSplit = 100; + + public int continuousImputeStartIndex = 11; + public int continuousImputeEndIndex = 35; + + protected Map lastSeen = new HashMap<>(); + + @BeforeClass + public static void setUpOnce() { + // Generate the list of doubles + randomDoubles = generateUniformRandomDoubles(dataSize, min, max); + } + + protected void verifyImputation( + ImputationMethod imputation, + Map lastSeen, + double dataValue, + JsonObject imputed0, + String entity + ) { + assertTrue(imputed0.get("imputed").getAsBoolean()); + switch (imputation) { + case ZERO: + assertEquals(0, dataValue, EPSILON); + break; + case PREVIOUS: + // if we have recorded lastSeen + Double entityValue = lastSeen.get(entity); + if (entityValue != null && !areDoublesEqual(entityValue, -1)) { + assertEquals(entityValue, dataValue, EPSILON); + } + break; + case FIXED_VALUES: + assertEquals(1, dataValue, EPSILON); + break; + default: + assertTrue(false); + break; + } + } + + protected TrainResult createAndStartRealTimeDetector( + int numberOfEntities, + int trainTestSplit, + List data, + ImputationMethod imputation, + boolean hc, + long trainTimeMillis + ) throws Exception { + TrainResult trainResult = createDetector(numberOfEntities, trainTestSplit, data, imputation, hc, trainTimeMillis); + List result = startRealTimeDetector(trainResult, numberOfEntities, intervalMinutes, true); + recordLastSeenFromResult(result); + + return trainResult; + } + + protected TrainResult createAndStartHistoricalDetector( + int numberOfEntities, + int trainTestSplit, + List data, + ImputationMethod imputation, + boolean hc, + long trainTimeMillis + ) throws Exception { + TrainResult trainResult = createDetector(numberOfEntities, trainTestSplit, data, imputation, hc, trainTimeMillis); + List result = startHistoricalDetector(trainResult, numberOfEntities, intervalMinutes, true); + recordLastSeenFromResult(result); + + return trainResult; + } + + protected void recordLastSeenFromResult(List result) { + for (int j = 0; j < result.size(); j++) { + JsonObject source = result.get(j); + lastSeen.put(getEntity(source), extractFeatureValue(source)); + } + } + + protected TrainResult createDetector( + int numberOfEntities, + int trainTestSplit, + List data, + ImputationMethod imputation, + boolean hc, + long trainTimeMillis + ) throws Exception { + Instant trainTime = Instant.ofEpochMilli(trainTimeMillis); + + Duration windowDelay = getWindowDelay(trainTimeMillis); + String detector = genDetector(trainTestSplit, windowDelay.toMinutes(), hc, imputation, trainTimeMillis); + + RestClient client = client(); + String detectorId = createDetector(client, detector); + LOG.info("Created detector {}", detectorId); + + return new TrainResult(detectorId, data, trainTestSplit * numberOfEntities, windowDelay, trainTime); + } + + protected Duration getWindowDelay(long trainTimeMillis) { + /* + * AD accepts windowDelay in the unit of minutes. Thus, we need to convert the delay in minutes. This will + * make it easier to search for results based on data end time. Otherwise, real data time and the converted + * data time from request time. + * Assume x = real data time. y= real window delay. y'= window delay in minutes. If y and y' are different, + * x + y - y' != x. + */ + long currentTime = System.currentTimeMillis(); + long windowDelayMinutes = (trainTimeMillis - currentTime) / 60000; + LOG.info("train time {}, current time {}, window delay {}", trainTimeMillis, currentTime, windowDelayMinutes); + return Duration.ofMinutes(windowDelayMinutes); + } + + protected void ingestUniformSingleFeatureData(int ingestDataSize, List data) throws Exception { + ingestUniformSingleFeatureData(ingestDataSize, data, datasetName, categoricalField); + } + + protected JsonObject createJsonObject(long timestamp, String component, double dataValue) { + return createJsonObject(timestamp, component, dataValue, categoricalField); + } + + protected abstract String genDetector( + int trainTestSplit, + long windowDelayMinutes, + boolean hc, + ImputationMethod imputation, + long trainTimeMillis + ); + + protected abstract AbstractSyntheticDataTest.GenData genData( + int trainTestSplit, + int numberOfEntities, + AbstractSyntheticDataTest.MISSING_MODE missingMode + ) throws Exception; + + protected abstract void runTest( + long firstDataStartTime, + AbstractSyntheticDataTest.GenData dataGenerated, + Duration windowDelay, + String detectorId, + int numberOfEntities, + AbstractSyntheticDataTest.MISSING_MODE mode, + ImputationMethod imputation, + int numberOfMissingToCheck, + boolean realTime + ); + + protected abstract double extractFeatureValue(JsonObject source); +} diff --git a/src/test/java/org/opensearch/ad/e2e/MissingMultiFeatureIT.java b/src/test/java/org/opensearch/ad/e2e/MissingMultiFeatureIT.java new file mode 100644 index 000000000..1fe3bcb6f --- /dev/null +++ b/src/test/java/org/opensearch/ad/e2e/MissingMultiFeatureIT.java @@ -0,0 +1,357 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.e2e; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.NavigableSet; +import java.util.TreeMap; +import java.util.TreeSet; + +import org.apache.commons.lang3.tuple.Pair; +import org.opensearch.timeseries.AbstractSyntheticDataTest; +import org.opensearch.timeseries.dataprocessor.ImputationMethod; + +import com.google.gson.JsonObject; + +public class MissingMultiFeatureIT extends MissingIT { + + public void testSingleStream() throws Exception { + int numberOfEntities = 1; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.NO_MISSING_DATA; + ImputationMethod method = ImputationMethod.ZERO; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + // only ingest train data to avoid validation error as we use latest data time as starting point. + // otherwise, we will have too many missing points. + ingestUniformSingleFeatureData( + trainTestSplit + numberOfEntities * 6, // we only need a few to verify and trigger train. + dataGenerated.data + ); + + TrainResult trainResult = createAndStartRealTimeDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + false, + dataGenerated.testStartTime + ); + + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + 3, + true + ); + } + + public void testHCFixed() throws Exception { + int numberOfEntities = 2; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.NO_MISSING_DATA; + ImputationMethod method = ImputationMethod.FIXED_VALUES; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + // only ingest train data to avoid validation error as we use latest data time as starting point. + // otherwise, we will have too many missing points. + ingestUniformSingleFeatureData( + trainTestSplit + numberOfEntities * 6, // we only need a few to verify and trigger train. + dataGenerated.data + ); + + TrainResult trainResult = createAndStartRealTimeDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + true, + dataGenerated.testStartTime + ); + + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + 3, + true + ); + } + + public void testHCPrevious() throws Exception { + int numberOfEntities = 2; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.NO_MISSING_DATA; + ImputationMethod method = ImputationMethod.PREVIOUS; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + // only ingest train data to avoid validation error as we use latest data time as starting point. + // otherwise, we will have too many missing points. + ingestUniformSingleFeatureData( + trainTestSplit + numberOfEntities * 6, // we only need a few to verify and trigger train. + dataGenerated.data + ); + + TrainResult trainResult = createAndStartRealTimeDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + true, + dataGenerated.testStartTime + ); + + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + 3, + true + ); + } + + @Override + protected String genDetector( + int trainTestSplit, + long windowDelayMinutes, + boolean hc, + ImputationMethod imputation, + long trainTimeMillis + ) { + StringBuilder sb = new StringBuilder(); + + // feature with filter so that we only get data for training and test will have missing value on this feature + String featureWithFilter = String + .format( + Locale.ROOT, + "{\n" + + " \"feature_id\": \"feature1\",\n" + + " \"feature_name\": \"feature 1\",\n" + + " \"feature_enabled\": true,\n" + + " \"importance\": 1,\n" + + " \"aggregation_query\": {\n" + + " \"Feature1\": {\n" + + " \"filter\": {\n" + + " \"bool\": {\n" + + " \"must\": [\n" + + " {\n" + + " \"range\": {\n" + + " \"timestamp\": {\n" + + " \"lte\": %d\n" + + " }\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"aggregations\": {\n" + + " \"deny_max1\": {\n" + + " \"max\": {\n" + + " \"field\": \"data\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}", + trainTimeMillis + ); + + // common part + sb + .append( + "{ \"name\": \"test\", \"description\": \"test\", \"time_field\": \"timestamp\"" + + ", \"indices\": [\"%s\"], \"feature_attributes\": [{ \"feature_id\": \"feature2\", \"feature_name\": \"feature 2\", \"feature_enabled\": " + + "\"true\", \"aggregation_query\": { \"Feature2\": { \"avg\": { \"field\": \"data\" } } } }," + + featureWithFilter + + "], \"detection_interval\": { \"period\": { \"interval\": %d, \"unit\": \"Minutes\" } }, " + + "\"history\": %d," + ); + + if (windowDelayMinutes > 0) { + sb + .append( + String + .format( + Locale.ROOT, + "\"window_delay\": { \"period\": {\"interval\": %d, \"unit\": \"MINUTES\"}},", + windowDelayMinutes + ) + ); + } + if (hc) { + sb.append("\"category_field\": [\"%s\"], "); + } + + switch (imputation) { + case ZERO: + sb.append("\"imputation_option\": { \"method\": \"zero\" },"); + break; + case PREVIOUS: + sb.append("\"imputation_option\": { \"method\": \"previous\" },"); + break; + case FIXED_VALUES: + sb + .append( + "\"imputation_option\": { \"method\": \"fixed_values\", \"defaultFill\": [{ \"feature_name\" : \"feature 1\", \"data\": 1 }, { \"feature_name\" : \"feature 2\", \"data\": 2 }] }," + ); + break; + } + // end + sb.append("\"schema_version\": 0}"); + + if (hc) { + return String.format(Locale.ROOT, sb.toString(), datasetName, intervalMinutes, trainTestSplit - 1, categoricalField); + } else { + return String.format(Locale.ROOT, sb.toString(), datasetName, intervalMinutes, trainTestSplit - 1); + } + } + + @Override + protected AbstractSyntheticDataTest.GenData genData( + int trainTestSplit, + int numberOfEntities, + AbstractSyntheticDataTest.MISSING_MODE missingMode + ) throws Exception { + List data = new ArrayList<>(); + long currentTime = System.currentTimeMillis(); + long intervalMillis = intervalMinutes * 60000L; + long oldestTime = currentTime - intervalMillis * trainTestSplit / numberOfEntities; + int entityIndex = 0; + NavigableSet> missingEntities = new TreeSet<>(); + NavigableSet missingTimestamps = new TreeSet<>(); + long testStartTime = 0; + + for (int i = 0; i < randomDoubles.size(); i++) { + // we won't miss the train time (the first point triggering cold start) + if (oldestTime > currentTime && testStartTime == 0) { + LOG.info("test start time {}, index {}, current time {}", oldestTime, data.size(), currentTime); + testStartTime = oldestTime; + } + JsonObject jsonObject = createJsonObject(oldestTime, "entity" + entityIndex, randomDoubles.get(i)); + data.add(jsonObject); + entityIndex = (entityIndex + 1) % numberOfEntities; + if (entityIndex == 0) { + oldestTime += intervalMillis; + } + } + return new AbstractSyntheticDataTest.GenData(data, missingEntities, missingTimestamps, testStartTime); + } + + @Override + protected void runTest( + long firstDataStartTime, + AbstractSyntheticDataTest.GenData dataGenerated, + Duration windowDelay, + String detectorId, + int numberOfEntities, + AbstractSyntheticDataTest.MISSING_MODE mode, + ImputationMethod imputation, + int numberOfMissingToCheck, + boolean realTime + ) { + int errors = 0; + List data = dataGenerated.data; + long lastDataStartTime = data.get(data.size() - 1).get("timestamp").getAsLong(); + + long dataStartTime = firstDataStartTime + intervalMinutes * 60000; + int missingIndex = 0; + + // an entity might have missing values (e.g., at timestamp 1694713200000). + // Use a map to record the number of times we have seen them. + // data start time -> the number of entities + TreeMap entityMap = new TreeMap<>(); + + // exit when reaching last date time or we have seen at least three missing values + while (lastDataStartTime >= dataStartTime && missingIndex <= numberOfMissingToCheck) { + if (scoreOneResult( + String.valueOf(dataStartTime), + entityMap, + windowDelay, + intervalMinutes, + detectorId, + client(), + numberOfEntities + )) { + errors++; + } + + Instant begin = Instant.ofEpochMilli(dataStartTime); + Instant end = begin.plus(intervalMinutes, ChronoUnit.MINUTES); + try { + List sourceList = getRealTimeAnomalyResult(detectorId, end, numberOfEntities, client()); + + assertTrue( + String + .format( + Locale.ROOT, + "the number of results is %d at %s, expected %d ", + sourceList.size(), + end.toEpochMilli(), + numberOfEntities + ), + sourceList.size() == numberOfEntities + ); + + for (int j = 0; j < numberOfEntities; j++) { + JsonObject source = sourceList.get(j); + + double dataValue = extractFeatureValue(source); + + String entity = getEntity(source); + + JsonObject imputed1 = getImputed(source, "feature1"); + // the feature starts missing since train time. + verifyImputation(imputation, lastSeen, dataValue, imputed1, entity); + + JsonObject imputed2 = getImputed(source, "feature2"); + assertTrue(!imputed2.get("imputed").getAsBoolean()); + } + missingIndex++; + } catch (Exception e) { + errors++; + LOG.error("failed to get detection results", e); + } + + dataStartTime += intervalMinutes * 60000; + } + + assertTrue(missingIndex > numberOfMissingToCheck); + assertTrue(errors < maxError); + } + + @Override + protected double extractFeatureValue(JsonObject source) { + for (int i = 0; i < 2; i++) { + JsonObject feature = getFeature(source, i); + if (feature.get("feature_name").getAsString().equals("feature 1")) { + return feature.get("data").getAsDouble(); + } + } + throw new IllegalArgumentException("Fail to find feature 1"); + } +} diff --git a/src/test/java/org/opensearch/ad/e2e/MissingSingleFeatureIT.java b/src/test/java/org/opensearch/ad/e2e/MissingSingleFeatureIT.java new file mode 100644 index 000000000..a8717d53d --- /dev/null +++ b/src/test/java/org/opensearch/ad/e2e/MissingSingleFeatureIT.java @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.e2e; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.Logger; +import org.opensearch.timeseries.AbstractSyntheticDataTest; +import org.opensearch.timeseries.dataprocessor.ImputationMethod; + +public class MissingSingleFeatureIT extends AbstractMissingSingleFeatureTestCase { + public static final Logger LOG = (Logger) LogManager.getLogger(MissingSingleFeatureIT.class); + + public void testSingleStream() throws Exception { + int numberOfEntities = 1; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.MISSING_TIMESTAMP; + ImputationMethod method = ImputationMethod.ZERO; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + ingestUniformSingleFeatureData( + -1, // ingest all + dataGenerated.data + ); + + TrainResult trainResult = createAndStartRealTimeDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + false, + dataGenerated.testStartTime + ); + + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + 3, + true + ); + } + + public void testHCMissingTimeStamp() throws Exception { + int numberOfEntities = 2; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.MISSING_TIMESTAMP; + ImputationMethod method = ImputationMethod.PREVIOUS; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + ingestUniformSingleFeatureData( + -1, // ingest all + dataGenerated.data + ); + + TrainResult trainResult = createAndStartRealTimeDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + true, + dataGenerated.testStartTime + ); + + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + 3, + true + ); + } + + public void testHCMissingEntity() throws Exception { + int numberOfEntities = 2; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.MISSING_ENTITY; + ImputationMethod method = ImputationMethod.FIXED_VALUES; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + ingestUniformSingleFeatureData( + -1, // ingest all + dataGenerated.data + ); + + TrainResult trainResult = createAndStartRealTimeDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + true, + dataGenerated.testStartTime + ); + + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + 3, + true + ); + } +} diff --git a/src/test/java/org/opensearch/ad/e2e/PreviewMissingSingleFeatureIT.java b/src/test/java/org/opensearch/ad/e2e/PreviewMissingSingleFeatureIT.java new file mode 100644 index 000000000..6b0273c0a --- /dev/null +++ b/src/test/java/org/opensearch/ad/e2e/PreviewMissingSingleFeatureIT.java @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.e2e; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.Logger; +import org.opensearch.common.xcontent.support.XContentMapValues; +import org.opensearch.timeseries.AbstractSyntheticDataTest; +import org.opensearch.timeseries.dataprocessor.ImputationMethod; + +public class PreviewMissingSingleFeatureIT extends AbstractMissingSingleFeatureTestCase { + public static final Logger LOG = (Logger) LogManager.getLogger(MissingSingleFeatureIT.class); + + @SuppressWarnings("unchecked") + public void testSingleStream() throws Exception { + + int numberOfEntities = 1; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.MISSING_TIMESTAMP; + ImputationMethod method = ImputationMethod.ZERO; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + ingestUniformSingleFeatureData( + -1, // ingest all + dataGenerated.data + ); + + Duration windowDelay = getWindowDelay(dataGenerated.testStartTime); + String detector = genDetector(trainTestSplit, windowDelay.toMinutes(), false, method, dataGenerated.testStartTime); + + Instant begin = Instant.ofEpochMilli(dataGenerated.data.get(0).get("timestamp").getAsLong()); + Instant end = Instant.ofEpochMilli(dataGenerated.data.get(dataGenerated.data.size() - 1).get("timestamp").getAsLong()); + Map result = preview(detector, begin, end, client()); + // We return java.lang.IllegalArgumentException: Insufficient data for preview results. Minimum required: 400 + // But we return empty results instead. Read comments in AnomalyDetectorRunner.onFailure. + List results = (List) XContentMapValues.extractValue(result, "anomaly_result"); + assertTrue(results.size() == 0); + + } + + @SuppressWarnings("unchecked") + public void testHC() throws Exception { + + int numberOfEntities = 2; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.MISSING_TIMESTAMP; + ImputationMethod method = ImputationMethod.ZERO; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + ingestUniformSingleFeatureData( + -1, // ingest all + dataGenerated.data + ); + + Duration windowDelay = getWindowDelay(dataGenerated.testStartTime); + String detector = genDetector(trainTestSplit, windowDelay.toMinutes(), true, method, dataGenerated.testStartTime); + + Instant begin = Instant.ofEpochMilli(dataGenerated.data.get(0).get("timestamp").getAsLong()); + Instant end = Instant.ofEpochMilli(dataGenerated.data.get(dataGenerated.data.size() - 1).get("timestamp").getAsLong()); + Map result = preview(detector, begin, end, client()); + // We return java.lang.IllegalArgumentException: Insufficient data for preview results. Minimum required: 400 + // But we return empty results instead. Read comments in AnomalyDetectorRunner.onFailure. + List results = (List) XContentMapValues.extractValue(result, "anomaly_result"); + assertTrue(results.size() == 0); + + } +} diff --git a/src/test/java/org/opensearch/ad/e2e/PreviewRuleIT.java b/src/test/java/org/opensearch/ad/e2e/PreviewRuleIT.java new file mode 100644 index 000000000..8b481e3c9 --- /dev/null +++ b/src/test/java/org/opensearch/ad/e2e/PreviewRuleIT.java @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.e2e; + +import java.util.List; +import java.util.Map; + +import org.opensearch.common.xcontent.support.XContentMapValues; + +public class PreviewRuleIT extends AbstractRuleTestCase { + @SuppressWarnings("unchecked") + public void testRule() throws Exception { + // TODO: this test case will run for a much longer time and timeout with security enabled + if (!isHttps()) { + disableResourceNotFoundFaultTolerence(); + + String datasetName = "rule"; + int intervalMinutes = 10; + int numberOfEntities = 2; + int trainTestSplit = 100; + + TrainResult trainResult = ingestTrainData( + datasetName, + intervalMinutes, + numberOfEntities, + trainTestSplit, + true, + // ingest just enough for finish the test + (trainTestSplit + 1) * numberOfEntities + ); + + String detector = genDetector(datasetName, intervalMinutes, trainTestSplit, trainResult); + Map result = preview(detector, trainResult.firstDataTime, trainResult.finalDataTime, client()); + List results = (List) XContentMapValues.extractValue(result, "anomaly_result"); + assertTrue(results.size() > 100); + Map firstResult = (Map) results.get(0); + assertTrue((Double) XContentMapValues.extractValue(firstResult, "anomaly_grade") >= 0); + List feature = (List) XContentMapValues.extractValue(firstResult, "feature_data"); + Map firstFeatureValue = (Map) feature.get(0); + assertTrue((Double) XContentMapValues.extractValue(firstFeatureValue, "data") != null); + } + } +} diff --git a/src/test/java/org/opensearch/ad/e2e/RealTimeMissingSingleFeatureModelPerfIT.java b/src/test/java/org/opensearch/ad/e2e/RealTimeMissingSingleFeatureModelPerfIT.java new file mode 100644 index 000000000..0dc01d186 --- /dev/null +++ b/src/test/java/org/opensearch/ad/e2e/RealTimeMissingSingleFeatureModelPerfIT.java @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.e2e; + +import org.opensearch.timeseries.AbstractSyntheticDataTest; +import org.opensearch.timeseries.dataprocessor.ImputationMethod; + +public class RealTimeMissingSingleFeatureModelPerfIT extends AbstractMissingSingleFeatureTestCase { + public void testSingleStream() throws Exception { + int numberOfEntities = 1; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.CONTINUOUS_IMPUTE; + ImputationMethod method = ImputationMethod.ZERO; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + ingestUniformSingleFeatureData( + -1, // ingest all + dataGenerated.data + ); + + TrainResult trainResult = createAndStartRealTimeDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + false, + dataGenerated.testStartTime + ); + + // we allowed 25 (continuousImputeEndIndex - continuousImputeStartIndex + 1) continuous missing timestamps in shouldSkipDataPoint + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + continuousImputeEndIndex - continuousImputeStartIndex + 1, + true + ); + } + + public void testHCFixed() throws Exception { + int numberOfEntities = 1; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.CONTINUOUS_IMPUTE; + ImputationMethod method = ImputationMethod.FIXED_VALUES; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + ingestUniformSingleFeatureData( + -1, // ingest all + dataGenerated.data + ); + + TrainResult trainResult = createAndStartRealTimeDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + true, + dataGenerated.testStartTime + ); + + // we allowed 25 (continuousImputeEndIndex - continuousImputeStartIndex + 1) continuous missing timestamps in shouldSkipDataPoint + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + continuousImputeEndIndex - continuousImputeStartIndex + 1, + true + ); + } + + public void testHCPrevious() throws Exception { + int numberOfEntities = 1; + + AbstractSyntheticDataTest.MISSING_MODE mode = AbstractSyntheticDataTest.MISSING_MODE.CONTINUOUS_IMPUTE; + ImputationMethod method = ImputationMethod.PREVIOUS; + + AbstractSyntheticDataTest.GenData dataGenerated = genData(trainTestSplit, numberOfEntities, mode); + + ingestUniformSingleFeatureData( + -1, // ingest all + dataGenerated.data + ); + + TrainResult trainResult = createAndStartRealTimeDetector( + numberOfEntities, + trainTestSplit, + dataGenerated.data, + method, + true, + dataGenerated.testStartTime + ); + + // we allowed 25 (continuousImputeEndIndex - continuousImputeStartIndex + 1) continuous missing timestamps in shouldSkipDataPoint + runTest( + dataGenerated.testStartTime, + dataGenerated, + trainResult.windowDelay, + trainResult.detectorId, + numberOfEntities, + mode, + method, + continuousImputeEndIndex - continuousImputeStartIndex + 1, + true + ); + } +} diff --git a/src/test/java/org/opensearch/ad/e2e/RuleIT.java b/src/test/java/org/opensearch/ad/e2e/RealTimeRuleIT.java similarity index 86% rename from src/test/java/org/opensearch/ad/e2e/RuleIT.java rename to src/test/java/org/opensearch/ad/e2e/RealTimeRuleIT.java index 92f733f01..650fec64d 100644 --- a/src/test/java/org/opensearch/ad/e2e/RuleIT.java +++ b/src/test/java/org/opensearch/ad/e2e/RealTimeRuleIT.java @@ -14,7 +14,7 @@ import com.google.gson.JsonObject; -public class RuleIT extends AbstractRuleTestCase { +public class RealTimeRuleIT extends AbstractRuleTestCase { public void testRuleWithDateNanos() throws Exception { // TODO: this test case will run for a much longer time and timeout with security enabled if (!isHttps()) { @@ -25,7 +25,7 @@ public void testRuleWithDateNanos() throws Exception { int numberOfEntities = 2; int trainTestSplit = 100; - TrainResult trainResult = ingestTrainData( + TrainResult trainResult = ingestTrainDataAndCreateDetector( datasetName, intervalMinutes, numberOfEntities, @@ -35,11 +35,12 @@ public void testRuleWithDateNanos() throws Exception { (trainTestSplit + 1) * numberOfEntities ); + startRealTimeDetector(trainResult, numberOfEntities, intervalMinutes, false); List data = trainResult.data; LOG.info("scoring data at {}", data.get(trainResult.rawDataTrainTestSplit).get("timestamp").getAsString()); // one run call will evaluate all entities within an interval - int numberEntitiesScored = findTrainTimeEntities(trainResult.rawDataTrainTestSplit, data); + int numberEntitiesScored = findGivenTimeEntities(trainResult.rawDataTrainTestSplit, data); // an entity might have missing values (e.g., at timestamp 1694713200000). // Use a map to record the number of times we have seen them. // data start time -> the number of entities @@ -47,7 +48,7 @@ public void testRuleWithDateNanos() throws Exception { // rawDataTrainTestSplit is the actual index of next test data. assertFalse( scoreOneResult( - data.get(trainResult.rawDataTrainTestSplit), + data.get(trainResult.rawDataTrainTestSplit).get("timestamp").getAsString(), entityMap, trainResult.windowDelay, intervalMinutes, @@ -64,7 +65,7 @@ public void testRuleWithDateNanos() throws Exception { Instant begin = Instant.ofEpochMilli(Long.parseLong(beginTimeStampAsString)); Instant end = begin.plus(intervalMinutes, ChronoUnit.MINUTES); try { - List sourceList = getAnomalyResult(trainResult.detectorId, end, numberEntitiesScored, client()); + List sourceList = getRealTimeAnomalyResult(trainResult.detectorId, end, numberEntitiesScored, client()); assertTrue( String diff --git a/src/test/java/org/opensearch/ad/e2e/RealTimeRuleModelPerfIT.java b/src/test/java/org/opensearch/ad/e2e/RealTimeRuleModelPerfIT.java new file mode 100644 index 000000000..5062fe63c --- /dev/null +++ b/src/test/java/org/opensearch/ad/e2e/RealTimeRuleModelPerfIT.java @@ -0,0 +1,143 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.e2e; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.TreeMap; + +import org.apache.commons.lang3.tuple.Triple; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.Logger; +import org.opensearch.client.RestClient; + +import com.google.gson.JsonObject; + +public class RealTimeRuleModelPerfIT extends AbstractRuleModelPerfTestCase { + static final Logger LOG = (Logger) LogManager.getLogger(RealTimeRuleModelPerfIT.class); + + public void testRule() throws Exception { + // TODO: this test case will run for a much longer time and timeout with security enabled + if (!isHttps()) { + disableResourceNotFoundFaultTolerence(); + // there are 8 entities in the data set. Each one needs 1500 rows as training data. + Map minPrecision = new HashMap<>(); + minPrecision.put("Phoenix", 0.5); + minPrecision.put("Scottsdale", 0.5); + Map minRecall = new HashMap<>(); + minRecall.put("Phoenix", 0.9); + minRecall.put("Scottsdale", 0.6); + verifyRule("rule", 10, minPrecision.size(), 1500, minPrecision, minRecall, 20); + } + } + + public void verifyRule( + String datasetName, + int intervalMinutes, + int numberOfEntities, + int trainTestSplit, + Map minPrecision, + Map minRecall, + int maxError + ) throws Exception { + verifyRule(datasetName, intervalMinutes, numberOfEntities, trainTestSplit, minPrecision, minRecall, maxError, false); + } + + public void verifyRule( + String datasetName, + int intervalMinutes, + int numberOfEntities, + int trainTestSplit, + Map minPrecision, + Map minRecall, + int maxError, + boolean useDateNanos + ) throws Exception { + + String labelFileName = String.format(Locale.ROOT, "data/%s.label", datasetName); + Map>> anomalies = getAnomalyWindowsMap(labelFileName); + + TrainResult trainResult = ingestTrainDataAndCreateDetector( + datasetName, + intervalMinutes, + numberOfEntities, + trainTestSplit, + useDateNanos + ); + startRealTimeDetector(trainResult, numberOfEntities, intervalMinutes, false); + + Triple, Integer, Map>> results = getTestResults( + trainResult.detectorId, + trainResult.data, + trainResult.rawDataTrainTestSplit, + intervalMinutes, + anomalies, + client(), + numberOfEntities, + trainResult.windowDelay + ); + verifyTestResults(results, anomalies, minPrecision, minRecall, maxError); + } + + private Triple, Integer, Map>> getTestResults( + String detectorId, + List data, + int rawTrainTestSplit, + int intervalMinutes, + Map>> anomalies, + RestClient client, + int numberOfEntities, + Duration windowDelay + ) throws Exception { + + Map res = new HashMap<>(); + int errors = 0; + // an entity might have missing values (e.g., at timestamp 1694713200000). + // Use a map to record the number of times we have seen them. + // data start time -> the number of entities + TreeMap entityMap = new TreeMap<>(); + for (int i = rawTrainTestSplit; i < data.size(); i++) { + if (scoreOneResult( + data.get(i).get("timestamp").getAsString(), + entityMap, + windowDelay, + intervalMinutes, + detectorId, + client, + numberOfEntities + )) { + errors++; + } + } + + // hash set to dedup + Map> foundWindow = new HashMap<>(); + + // Iterate over the TreeMap in ascending order of keys + for (Map.Entry entry : entityMap.entrySet()) { + String beginTimeStampAsString = entry.getKey(); + int entitySize = entry.getValue(); + Instant begin = Instant.ofEpochMilli(Long.parseLong(beginTimeStampAsString)); + Instant end = begin.plus(intervalMinutes, ChronoUnit.MINUTES); + try { + List sourceList = getRealTimeAnomalyResult(detectorId, end, entitySize, client); + + analyzeResults(anomalies, res, foundWindow, beginTimeStampAsString, entitySize, begin, sourceList); + } catch (Exception e) { + errors++; + LOG.error("failed to get detection results", e); + } + } + return Triple.of(res, errors, foundWindow); + } +} diff --git a/src/test/java/org/opensearch/ad/e2e/RuleModelPerfIT.java b/src/test/java/org/opensearch/ad/e2e/RuleModelPerfIT.java deleted file mode 100644 index 208cfbe48..000000000 --- a/src/test/java/org/opensearch/ad/e2e/RuleModelPerfIT.java +++ /dev/null @@ -1,248 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.opensearch.ad.e2e; - -import java.io.File; -import java.io.FileReader; -import java.nio.charset.Charset; -import java.time.Duration; -import java.time.Instant; -import java.time.format.DateTimeFormatter; -import java.time.temporal.ChronoUnit; -import java.util.AbstractMap.SimpleEntry; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Set; -import java.util.TreeMap; - -import org.apache.commons.lang3.tuple.Triple; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.core.Logger; -import org.opensearch.client.RestClient; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -public class RuleModelPerfIT extends AbstractRuleTestCase { - static final Logger LOG = (Logger) LogManager.getLogger(RuleModelPerfIT.class); - - public void testRule() throws Exception { - // TODO: this test case will run for a much longer time and timeout with security enabled - if (!isHttps()) { - disableResourceNotFoundFaultTolerence(); - // there are 8 entities in the data set. Each one needs 1500 rows as training data. - Map minPrecision = new HashMap<>(); - minPrecision.put("Phoenix", 0.5); - minPrecision.put("Scottsdale", 0.5); - Map minRecall = new HashMap<>(); - minRecall.put("Phoenix", 0.9); - minRecall.put("Scottsdale", 0.6); - verifyRule("rule", 10, minPrecision.size(), 1500, minPrecision, minRecall, 20); - } - } - - private void verifyTestResults( - Triple, Integer, Map>> testResults, - Map>> anomalies, - Map minPrecision, - Map minRecall, - int maxError - ) { - Map resultMap = testResults.getLeft(); - Map> foundWindows = testResults.getRight(); - - for (Entry entry : resultMap.entrySet()) { - String entity = entry.getKey(); - double[] testResultsArray = entry.getValue(); - double positives = testResultsArray[0]; - double truePositives = testResultsArray[1]; - - // precision = predicted anomaly points that are true / predicted anomaly points - double precision = positives > 0 ? truePositives / positives : 0; - double minPrecisionValue = minPrecision.getOrDefault(entity, .4); - assertTrue( - String - .format( - Locale.ROOT, - "precision expected at least %f but got %f. positives %f, truePositives %f", - minPrecisionValue, - precision, - positives, - truePositives - ), - precision >= minPrecisionValue - ); - - // recall = windows containing predicted anomaly points / total anomaly windows - int anomalyWindow = anomalies.getOrDefault(entity, new ArrayList<>()).size(); - int foundWindowSize = foundWindows.getOrDefault(entity, new HashSet<>()).size(); - double recall = anomalyWindow > 0 ? foundWindowSize * 1.0d / anomalyWindow : 0; - double minRecallValue = minRecall.getOrDefault(entity, .7); - assertTrue( - String - .format( - Locale.ROOT, - "recall should be at least %f but got %f. anomalyWindow %d, foundWindowSize %d ", - minRecallValue, - recall, - anomalyWindow, - foundWindowSize - ), - recall >= minRecallValue - ); - - LOG.info("Entity {}, Precision: {}, Window recall: {}", entity, precision, recall); - } - - int errors = testResults.getMiddle(); - assertTrue(errors <= maxError); - } - - public void verifyRule( - String datasetName, - int intervalMinutes, - int numberOfEntities, - int trainTestSplit, - Map minPrecision, - Map minRecall, - int maxError - ) throws Exception { - verifyRule(datasetName, intervalMinutes, numberOfEntities, trainTestSplit, minPrecision, minRecall, maxError, false); - } - - public void verifyRule( - String datasetName, - int intervalMinutes, - int numberOfEntities, - int trainTestSplit, - Map minPrecision, - Map minRecall, - int maxError, - boolean useDateNanos - ) throws Exception { - - String labelFileName = String.format(Locale.ROOT, "data/%s.label", datasetName); - Map>> anomalies = getAnomalyWindowsMap(labelFileName); - - TrainResult trainResult = ingestTrainData(datasetName, intervalMinutes, numberOfEntities, trainTestSplit, useDateNanos); - - Triple, Integer, Map>> results = getTestResults( - trainResult.detectorId, - trainResult.data, - trainResult.rawDataTrainTestSplit, - intervalMinutes, - anomalies, - client(), - numberOfEntities, - trainResult.windowDelay - ); - verifyTestResults(results, anomalies, minPrecision, minRecall, maxError); - } - - private Triple, Integer, Map>> getTestResults( - String detectorId, - List data, - int rawTrainTestSplit, - int intervalMinutes, - Map>> anomalies, - RestClient client, - int numberOfEntities, - Duration windowDelay - ) throws Exception { - - Map res = new HashMap<>(); - int errors = 0; - // an entity might have missing values (e.g., at timestamp 1694713200000). - // Use a map to record the number of times we have seen them. - // data start time -> the number of entities - TreeMap entityMap = new TreeMap<>(); - for (int i = rawTrainTestSplit; i < data.size(); i++) { - if (scoreOneResult(data.get(i), entityMap, windowDelay, intervalMinutes, detectorId, client, numberOfEntities)) { - errors++; - } - } - - // hash set to dedup - Map> foundWindow = new HashMap<>(); - - // Iterate over the TreeMap in ascending order of keys - for (Map.Entry entry : entityMap.entrySet()) { - String beginTimeStampAsString = entry.getKey(); - int entitySize = entry.getValue(); - Instant begin = Instant.ofEpochMilli(Long.parseLong(beginTimeStampAsString)); - Instant end = begin.plus(intervalMinutes, ChronoUnit.MINUTES); - try { - List sourceList = getAnomalyResult(detectorId, end, entitySize, client); - - assertTrue( - String - .format( - Locale.ROOT, - "the number of results is %d at %s, expected %d ", - sourceList.size(), - beginTimeStampAsString, - entitySize - ), - sourceList.size() == entitySize - ); - for (int j = 0; j < entitySize; j++) { - JsonObject source = sourceList.get(j); - double anomalyGrade = getAnomalyGrade(source); - assertTrue("anomalyGrade cannot be negative", anomalyGrade >= 0); - if (anomalyGrade > 0) { - String entity = getEntity(source); - double[] entityResult = res.computeIfAbsent(entity, key -> new double[] { 0, 0 }); - // positive++ - entityResult[0]++; - Instant anomalyTime = getAnomalyTime(source, begin); - LOG.info("Found anomaly: entity {}, time {} result {}.", entity, anomalyTime, source); - int anomalyWindow = isAnomaly(anomalyTime, anomalies.getOrDefault(entity, new ArrayList<>())); - if (anomalyWindow != -1) { - LOG.info("True anomaly: entity {}, time {}.", entity, begin); - // truePositives++; - entityResult[1]++; - Set window = foundWindow.computeIfAbsent(entity, key -> new HashSet<>()); - window.add(anomalyWindow); - } - } - } - } catch (Exception e) { - errors++; - LOG.error("failed to get detection results", e); - } - } - return Triple.of(res, errors, foundWindow); - } - - public Map>> getAnomalyWindowsMap(String labelFileName) throws Exception { - JsonObject jsonObject = JsonParser - .parseReader(new FileReader(new File(getClass().getResource(labelFileName).toURI()), Charset.defaultCharset())) - .getAsJsonObject(); - - Map>> map = new HashMap<>(); - for (Map.Entry entry : jsonObject.entrySet()) { - List> anomalies = new ArrayList<>(); - JsonElement value = entry.getValue(); - if (value.isJsonArray()) { - for (JsonElement elem : value.getAsJsonArray()) { - JsonElement beginElement = elem.getAsJsonArray().get(0); - JsonElement endElement = elem.getAsJsonArray().get(1); - Instant begin = Instant.from(DateTimeFormatter.ISO_INSTANT.parse(beginElement.getAsString())); - Instant end = Instant.from(DateTimeFormatter.ISO_INSTANT.parse(endElement.getAsString())); - anomalies.add(new SimpleEntry<>(begin, end)); - } - } - map.put(entry.getKey(), anomalies); - } - return map; - } -} diff --git a/src/test/java/org/opensearch/ad/e2e/SingleStreamModelPerfIT.java b/src/test/java/org/opensearch/ad/e2e/SingleStreamModelPerfIT.java index 75855a1a8..50f067e0d 100644 --- a/src/test/java/org/opensearch/ad/e2e/SingleStreamModelPerfIT.java +++ b/src/test/java/org/opensearch/ad/e2e/SingleStreamModelPerfIT.java @@ -157,7 +157,7 @@ private double[] getTestResults( Instant begin = Instant.from(DateTimeFormatter.ISO_INSTANT.parse(data.get(i).get("timestamp").getAsString())); Instant end = begin.plus(intervalMinutes, ChronoUnit.MINUTES); try { - List sourceList = getAnomalyResult(detectorId, end, 1, client); + List sourceList = getRealTimeAnomalyResult(detectorId, end, 1, client); assertTrue("anomalyGrade cannot be negative", sourceList.size() == 1); double anomalyGrade = getAnomalyGrade(sourceList.get(0)); assertTrue("anomalyGrade cannot be negative", anomalyGrade >= 0); diff --git a/src/test/java/org/opensearch/ad/feature/FeatureManagerTests.java b/src/test/java/org/opensearch/ad/feature/FeatureManagerTests.java index 092fa982a..f2a33964d 100644 --- a/src/test/java/org/opensearch/ad/feature/FeatureManagerTests.java +++ b/src/test/java/org/opensearch/ad/feature/FeatureManagerTests.java @@ -184,12 +184,12 @@ public void getColdStartData_returnExpectedToListener( }).when(searchFeatureDao).getLatestDataTime(eq(detector), eq(Optional.empty()), eq(AnalysisType.AD), any(ActionListener.class)); if (latestTime != null) { doAnswer(invocation -> { - ActionListener>> listener = invocation.getArgument(3); + ActionListener>> listener = invocation.getArgument(4); listener.onResponse(samples); return null; }) .when(searchFeatureDao) - .getFeatureSamplesForPeriods(eq(detector), eq(sampleRanges), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods(eq(detector), eq(sampleRanges), eq(AnalysisType.AD), eq(false), any(ActionListener.class)); } ActionListener> listener = mock(ActionListener.class); @@ -237,7 +237,7 @@ public void getColdStartData_throwToListener_onQueryCreationError() throws Excep }).when(searchFeatureDao).getLatestDataTime(eq(detector), eq(Optional.empty()), eq(AnalysisType.AD), any(ActionListener.class)); doThrow(IOException.class) .when(searchFeatureDao) - .getFeatureSamplesForPeriods(eq(detector), any(), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods(eq(detector), any(), eq(AnalysisType.AD), eq(false), any(ActionListener.class)); ActionListener> listener = mock(ActionListener.class); featureManager.getColdStartData(detector, listener); @@ -317,8 +317,8 @@ private void getPreviewFeaturesTemplate(List> samplesResults, ActionListener>> listener = null; - if (args[3] instanceof ActionListener) { - listener = (ActionListener>>) args[3]; + if (args[4] instanceof ActionListener) { + listener = (ActionListener>>) args[4]; } if (querySuccess) { @@ -328,7 +328,7 @@ private void getPreviewFeaturesTemplate(List> samplesResults, } return null; - }).when(searchFeatureDao).getFeatureSamplesForPeriods(eq(detector), eq(sampleRanges), eq(AnalysisType.AD), any()); + }).when(searchFeatureDao).getFeatureSamplesForPeriods(eq(detector), eq(sampleRanges), eq(AnalysisType.AD), eq(false), any()); ActionListener listener = mock(ActionListener.class); featureManager.getPreviewFeatures(detector, start, end, listener); @@ -434,7 +434,7 @@ private void setupSearchFeatureDaoForGetCurrentFeatures( AtomicBoolean isPreQuery = new AtomicBoolean(true); doAnswer(invocation -> { - ActionListener>> daoListener = invocation.getArgument(3); + ActionListener>> daoListener = invocation.getArgument(4); if (isPreQuery.get()) { isPreQuery.set(false); daoListener.onResponse(preQueryResponse); @@ -448,7 +448,7 @@ private void setupSearchFeatureDaoForGetCurrentFeatures( return null; }) .when(searchFeatureDao) - .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), eq(true), any(ActionListener.class)); } private Object[] getCurrentFeaturesTestData_whenAfterQueryResultsFormFullShingle() { @@ -487,7 +487,7 @@ public void getCurrentFeatures_returnExpectedProcessedFeatures_whenAfterQueryRes // Start test Optional listenerResponse = getCurrentFeatures(detector, testStartTime, testEndTime); verify(searchFeatureDao, times(expectedNumQueriesToSearchFeatureDao)) - .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), eq(true), any(ActionListener.class)); assertTrue(listenerResponse.isPresent()); double[] actualProcessedFeatures = listenerResponse.get(); @@ -519,7 +519,7 @@ public void getCurrentFeatures_returnExpectedProcessedFeatures_IOException( // Start test Exception listenerResponse = getCurrentFeaturesOnFailure(detector, start, end); verify(searchFeatureDao, times(expectedNumQueriesToSearchFeatureDao)) - .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), eq(true), any(ActionListener.class)); assertTrue(listenerResponse instanceof IOException); } @@ -565,7 +565,7 @@ public void getCurrentFeatures_returnExpectedProcessedFeatures_whenAfterQueryRes // Start test Optional listenerResponse = getCurrentFeatures(detector, testStartTime, testEndTime); verify(searchFeatureDao, times(expectedNumQueriesToSearchFeatureDao)) - .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), eq(true), any(ActionListener.class)); assertTrue(listenerResponse.isPresent()); } @@ -605,7 +605,7 @@ public void getCurrentFeatures_returnNoProcessedOrUnprocessedFeatures_whenMissin // Start test Optional listenerResponse = getCurrentFeatures(detector, testStartTime, testEndTime); verify(searchFeatureDao, times(expectedNumQueriesToSearchFeatureDao)) - .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), eq(true), any(ActionListener.class)); assertFalse(listenerResponse.isPresent()); } @@ -635,7 +635,7 @@ public void getCurrentFeatures_returnNoProcessedFeatures_whenAfterQueryResultsCa // Start test Optional listenerResponse = getCurrentFeatures(detector, testStartTime, testEndTime); verify(searchFeatureDao, times(expectedNumQueriesToSearchFeatureDao)) - .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), eq(true), any(ActionListener.class)); assertTrue(listenerResponse.isPresent()); } @@ -665,7 +665,7 @@ public void getCurrentFeatures_returnExceptionToListener_whenQueryThrowsIOExcept ActionListener> listener = mock(ActionListener.class); featureManager.getCurrentFeatures(detector, testStartTime, testEndTime, AnalysisType.AD, listener); verify(searchFeatureDao, times(expectedNumQueriesToSearchFeatureDao)) - .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), eq(true), any(ActionListener.class)); verify(listener).onFailure(any(IOException.class)); } @@ -693,12 +693,24 @@ public void getCurrentFeatures_returnExpectedFeatures_cacheMissingData( // first call to cache missing points featureManager.getCurrentFeatures(detector, firstStartTime, firstEndTime, AnalysisType.AD, mock(ActionListener.class)); verify(searchFeatureDao, times(1)) - .getFeatureSamplesForPeriods(eq(detector), argThat(list -> list.size() == 1), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods( + eq(detector), + argThat(list -> list.size() == 1), + eq(AnalysisType.AD), + eq(true), + any(ActionListener.class) + ); // second call should only fetch current point even if previous points missing Optional listenerResponse = getCurrentFeatures(detector, secondStartTime, secondEndTime); verify(searchFeatureDao, times(2)) - .getFeatureSamplesForPeriods(eq(detector), argThat(list -> list.size() == 1), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods( + eq(detector), + argThat(list -> list.size() == 1), + eq(AnalysisType.AD), + eq(true), + any(ActionListener.class) + ); assertTrue(listenerResponse.isPresent()); } @@ -759,7 +771,7 @@ public void getCurrentFeatures_returnExpectedFeatures_withTimeJitterUpToHalfInte // Start test Optional listenerResponse = getCurrentFeatures(detector, testStartTime, testEndTime); verify(searchFeatureDao, times(expectedNumQueriesToSearchFeatureDao)) - .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), any(ActionListener.class)); + .getFeatureSamplesForPeriods(eq(detector), any(List.class), eq(AnalysisType.AD), eq(true), any(ActionListener.class)); assertTrue(listenerResponse.isPresent()); } diff --git a/src/test/java/org/opensearch/ad/indices/CustomIndexTests.java b/src/test/java/org/opensearch/ad/indices/CustomIndexTests.java index 637733067..983d3a8b8 100644 --- a/src/test/java/org/opensearch/ad/indices/CustomIndexTests.java +++ b/src/test/java/org/opensearch/ad/indices/CustomIndexTests.java @@ -224,6 +224,14 @@ private Map createMapping() { roles_mapping.put("fields", Collections.singletonMap("keyword", Collections.singletonMap("type", "keyword"))); user_nested_mapping.put("roles", roles_mapping); mappings.put(CommonName.USER_FIELD, user_mapping); + + Map imputed_mapping = new HashMap<>(); + imputed_mapping.put("type", "nested"); + mappings.put(AnomalyResult.FEATURE_IMPUTED, imputed_mapping); + Map imputed_nested_mapping = new HashMap<>(); + imputed_mapping.put(CommonName.PROPERTIES, imputed_nested_mapping); + imputed_nested_mapping.put("feature_id", Collections.singletonMap("type", "keyword")); + imputed_nested_mapping.put("imputed", Collections.singletonMap("type", "boolean")); return mappings; } diff --git a/src/test/java/org/opensearch/ad/ml/ModelManagerTests.java b/src/test/java/org/opensearch/ad/ml/ModelManagerTests.java index af90fc16b..cc70f6e34 100644 --- a/src/test/java/org/opensearch/ad/ml/ModelManagerTests.java +++ b/src/test/java/org/opensearch/ad/ml/ModelManagerTests.java @@ -27,6 +27,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -197,7 +198,7 @@ public void setup() { descriptor.setAttribution(attributionVec); descriptor.setTotalUpdates(numSamples); descriptor.setRelevantAttribution(new double[] { 0, 0, 0, 0, 0 }); - when(trcf.process(any(), anyLong())).thenReturn(descriptor); + when(trcf.process(any(), anyLong(), any())).thenReturn(descriptor); ExecutorService executorService = mock(ExecutorService.class); when(threadPool.executor(TimeSeriesAnalyticsPlugin.AD_THREAD_POOL_NAME)).thenReturn(executorService); @@ -363,7 +364,9 @@ public void getRcfResult_returnExpectedToListener() { expectedValuesList, likelihood, threshold, - numTrees + numTrees, + point, + null ); verify(listener).onResponse(eq(expected)); @@ -802,7 +805,7 @@ public void maintenance_returnExpectedToListener_doNothing() { } @Test - public void getPreviewResults_returnNoAnomalies_forNoAnomalies() { + public void getPreviewResults_returnNoAnomalies_forNoAnomalies() throws IOException { int numPoints = 1000; double[][] points = Stream.generate(() -> new double[] { 0 }).limit(numPoints).toArray(double[][]::new); List> timeRanges = IntStream @@ -811,14 +814,17 @@ public void getPreviewResults_returnNoAnomalies_forNoAnomalies() { .collect(Collectors.toList()); Features features = new Features(timeRanges, points); - List results = modelManager.getPreviewResults(features, shingleSize, 0.0001); + AnomalyDetector detector = mock(AnomalyDetector.class); + when(detector.getShingleSize()).thenReturn(shingleSize); + when(detector.getRecencyEmphasis()).thenReturn(10000); + List results = modelManager.getPreviewResults(features, detector); assertEquals(numPoints, results.size()); assertTrue(results.stream().noneMatch(r -> r.getGrade() > 0)); } @Test - public void getPreviewResults_returnAnomalies_forLastAnomaly() { + public void getPreviewResults_returnAnomalies_forLastAnomaly() throws IOException { int numPoints = 1000; double[][] points = Stream.generate(() -> new double[] { 0 }).limit(numPoints).toArray(double[][]::new); points[points.length - 1] = new double[] { 1. }; @@ -828,7 +834,10 @@ public void getPreviewResults_returnAnomalies_forLastAnomaly() { .collect(Collectors.toList()); Features features = new Features(timeRanges, points); - List results = modelManager.getPreviewResults(features, shingleSize, 0.0001); + AnomalyDetector detector = mock(AnomalyDetector.class); + when(detector.getShingleSize()).thenReturn(shingleSize); + when(detector.getRecencyEmphasis()).thenReturn(10000); + List results = modelManager.getPreviewResults(features, detector); assertEquals(numPoints, results.size()); assertTrue(results.stream().limit(numPoints - 1).noneMatch(r -> r.getGrade() > 0)); @@ -836,9 +845,13 @@ public void getPreviewResults_returnAnomalies_forLastAnomaly() { } @Test(expected = IllegalArgumentException.class) - public void getPreviewResults_throwIllegalArgument_forInvalidInput() { + public void getPreviewResults_throwIllegalArgument_forInvalidInput() throws IOException { Features features = new Features(new ArrayList>(), new double[0][0]); - modelManager.getPreviewResults(features, shingleSize, 0.0001); + + AnomalyDetector detector = mock(AnomalyDetector.class); + when(detector.getShingleSize()).thenReturn(shingleSize); + when(detector.getRecencyEmphasis()).thenReturn(10000); + modelManager.getPreviewResults(features, detector); } @Test @@ -998,7 +1011,9 @@ public void score_with_trcf() { descriptor.getExpectedValuesList(), descriptor.getLikelihoodOfValues(), descriptor.getThreshold(), - numTrees + numTrees, + this.point, + null ), result ); @@ -1015,7 +1030,7 @@ public void score_throw() { when(rcf.getShingleSize()).thenReturn(8); when(rcf.getDimensions()).thenReturn(40); when(this.trcf.getForest()).thenReturn(rcf); - doThrow(new IllegalArgumentException()).when(trcf).process(any(), anyLong()); + doThrow(new IllegalArgumentException()).when(trcf).process(any(), anyLong(), any()); when(this.modelState.getSamples()) .thenReturn(new ArrayDeque<>(Arrays.asList(new Sample(this.point, Instant.now(), Instant.now())))); modelManager.score(new Sample(this.point, Instant.now(), Instant.now()), this.modelId, this.modelState, anomalyDetector); diff --git a/src/test/java/org/opensearch/ad/model/AnomalyDetectorTests.java b/src/test/java/org/opensearch/ad/model/AnomalyDetectorTests.java index 422c1be94..62d5c8f97 100644 --- a/src/test/java/org/opensearch/ad/model/AnomalyDetectorTests.java +++ b/src/test/java/org/opensearch/ad/model/AnomalyDetectorTests.java @@ -311,6 +311,7 @@ public void testParseAnomalyDetectorWithEmptyUiMetadata() throws IOException { public void testInvalidShingleSize() throws Exception { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); TestHelpers .assertFailWith( ValidationException.class, @@ -321,7 +322,7 @@ public void testInvalidShingleSize() throws Exception { randomAlphaOfLength(5), randomAlphaOfLength(5), ImmutableList.of(randomAlphaOfLength(5)), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), TestHelpers.randomIntervalTimeConfiguration(), TestHelpers.randomIntervalTimeConfiguration(), @@ -332,7 +333,7 @@ public void testInvalidShingleSize() throws Exception { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -346,6 +347,7 @@ public void testInvalidShingleSize() throws Exception { public void testNullDetectorName() throws Exception { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); TestHelpers .assertFailWith( ValidationException.class, @@ -356,7 +358,7 @@ public void testNullDetectorName() throws Exception { randomAlphaOfLength(5), randomAlphaOfLength(5), ImmutableList.of(randomAlphaOfLength(5)), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), TestHelpers.randomIntervalTimeConfiguration(), TestHelpers.randomIntervalTimeConfiguration(), @@ -367,7 +369,7 @@ public void testNullDetectorName() throws Exception { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -381,6 +383,7 @@ public void testNullDetectorName() throws Exception { public void testBlankDetectorName() throws Exception { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); TestHelpers .assertFailWith( ValidationException.class, @@ -391,7 +394,7 @@ public void testBlankDetectorName() throws Exception { randomAlphaOfLength(5), randomAlphaOfLength(5), ImmutableList.of(randomAlphaOfLength(5)), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), TestHelpers.randomIntervalTimeConfiguration(), TestHelpers.randomIntervalTimeConfiguration(), @@ -402,7 +405,7 @@ public void testBlankDetectorName() throws Exception { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -416,6 +419,7 @@ public void testBlankDetectorName() throws Exception { public void testNullTimeField() throws Exception { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); TestHelpers .assertFailWith( ValidationException.class, @@ -426,7 +430,7 @@ public void testNullTimeField() throws Exception { randomAlphaOfLength(5), null, ImmutableList.of(randomAlphaOfLength(5)), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), TestHelpers.randomIntervalTimeConfiguration(), TestHelpers.randomIntervalTimeConfiguration(), @@ -437,7 +441,7 @@ public void testNullTimeField() throws Exception { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -451,6 +455,7 @@ public void testNullTimeField() throws Exception { public void testNullIndices() throws Exception { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); TestHelpers .assertFailWith( ValidationException.class, @@ -461,7 +466,7 @@ public void testNullIndices() throws Exception { randomAlphaOfLength(5), randomAlphaOfLength(5), null, - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), TestHelpers.randomIntervalTimeConfiguration(), TestHelpers.randomIntervalTimeConfiguration(), @@ -472,7 +477,7 @@ public void testNullIndices() throws Exception { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -486,6 +491,7 @@ public void testNullIndices() throws Exception { public void testEmptyIndices() throws Exception { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); TestHelpers .assertFailWith( ValidationException.class, @@ -496,7 +502,7 @@ public void testEmptyIndices() throws Exception { randomAlphaOfLength(5), randomAlphaOfLength(5), ImmutableList.of(), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), TestHelpers.randomIntervalTimeConfiguration(), TestHelpers.randomIntervalTimeConfiguration(), @@ -507,7 +513,7 @@ public void testEmptyIndices() throws Exception { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -521,6 +527,7 @@ public void testEmptyIndices() throws Exception { public void testNullDetectionInterval() throws Exception { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); TestHelpers .assertFailWith( ValidationException.class, @@ -531,7 +538,7 @@ public void testNullDetectionInterval() throws Exception { randomAlphaOfLength(5), randomAlphaOfLength(5), ImmutableList.of(randomAlphaOfLength(5)), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), null, TestHelpers.randomIntervalTimeConfiguration(), @@ -542,7 +549,7 @@ public void testNullDetectionInterval() throws Exception { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -556,6 +563,7 @@ public void testNullDetectionInterval() throws Exception { public void testInvalidRecency() { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); ValidationException exception = expectThrows( ValidationException.class, () -> new AnomalyDetector( @@ -565,7 +573,7 @@ public void testInvalidRecency() { randomAlphaOfLength(30), randomAlphaOfLength(5), ImmutableList.of(randomAlphaOfLength(10).toLowerCase(Locale.ROOT)), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), new IntervalTimeConfiguration(0, ChronoUnit.MINUTES), TestHelpers.randomIntervalTimeConfiguration(), @@ -576,7 +584,7 @@ public void testInvalidRecency() { null, null, null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), -1, randomIntBetween(1, 256), randomIntBetween(1, 1000), @@ -591,6 +599,7 @@ public void testInvalidRecency() { public void testInvalidDetectionInterval() { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); ValidationException exception = expectThrows( ValidationException.class, () -> new AnomalyDetector( @@ -600,7 +609,7 @@ public void testInvalidDetectionInterval() { randomAlphaOfLength(30), randomAlphaOfLength(5), ImmutableList.of(randomAlphaOfLength(10).toLowerCase(Locale.ROOT)), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), new IntervalTimeConfiguration(0, ChronoUnit.MINUTES), TestHelpers.randomIntervalTimeConfiguration(), @@ -611,7 +620,7 @@ public void testInvalidDetectionInterval() { null, null, null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), null, // emphasis is not customized randomIntBetween(1, 256), randomIntBetween(1, 1000), @@ -626,6 +635,7 @@ public void testInvalidDetectionInterval() { public void testInvalidWindowDelay() { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); IllegalArgumentException exception = expectThrows( IllegalArgumentException.class, () -> new AnomalyDetector( @@ -635,7 +645,7 @@ public void testInvalidWindowDelay() { randomAlphaOfLength(30), randomAlphaOfLength(5), ImmutableList.of(randomAlphaOfLength(10).toLowerCase(Locale.ROOT)), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), new IntervalTimeConfiguration(1, ChronoUnit.MINUTES), new IntervalTimeConfiguration(-1, ChronoUnit.MINUTES), @@ -646,7 +656,7 @@ public void testInvalidWindowDelay() { null, null, null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), null, // emphasis is not customized randomIntBetween(1, 256), randomIntBetween(1, 1000), @@ -676,6 +686,7 @@ public void testEmptyFeatures() throws IOException { public void testGetShingleSize() throws IOException { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); Config anomalyDetector = new AnomalyDetector( randomAlphaOfLength(5), randomLong(), @@ -683,7 +694,7 @@ public void testGetShingleSize() throws IOException { randomAlphaOfLength(5), randomAlphaOfLength(5), ImmutableList.of(randomAlphaOfLength(5)), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), TestHelpers.randomIntervalTimeConfiguration(), TestHelpers.randomIntervalTimeConfiguration(), @@ -694,7 +705,7 @@ public void testGetShingleSize() throws IOException { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -709,6 +720,7 @@ public void testGetShingleSize() throws IOException { public void testGetShingleSizeReturnsDefaultValue() throws IOException { int seasonalityIntervals = randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2); Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); Config anomalyDetector = new AnomalyDetector( randomAlphaOfLength(5), randomLong(), @@ -727,7 +739,7 @@ public void testGetShingleSizeReturnsDefaultValue() throws IOException { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), seasonalityIntervals, randomIntBetween(1, 1000), @@ -757,7 +769,7 @@ public void testGetShingleSizeReturnsDefaultValue() throws IOException { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), null, null, randomIntBetween(1, 1000), @@ -789,7 +801,7 @@ public void testNullFeatureAttributes() throws IOException { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(0), + null, randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -821,7 +833,7 @@ public void testValidateResultIndex() throws IOException { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(0), + null, randomIntBetween(1, 10000), randomIntBetween(1, TimeSeriesSettings.MAX_SHINGLE_SIZE * TimeSeriesSettings.SEASONALITY_TO_SHINGLE_RATIO), randomIntBetween(1, 1000), @@ -864,11 +876,11 @@ public void testParseAnomalyDetector_withCustomIndex_withCustomResultIndexMinSiz + "\"time_field\":\"HmdFH\",\"indices\":[\"ffsBF\"],\"filter_query\":{\"bool\":{\"filter\":[{\"exists\":" + "{\"field\":\"value\",\"boost\":1}}],\"adjust_pure_negative\":true,\"boost\":1}},\"window_delay\":" + "{\"period\":{\"interval\":2,\"unit\":\"Minutes\"}},\"shingle_size\":8,\"schema_version\":-512063255," - + "\"feature_attributes\":[{\"feature_id\":\"OTYJs\",\"feature_name\":\"eYYCM\",\"feature_enabled\":false," + + "\"feature_attributes\":[{\"feature_id\":\"OTYJs\",\"feature_name\":\"eYYCM\",\"feature_enabled\":true," + "\"aggregation_query\":{\"XzewX\":{\"value_count\":{\"field\":\"ok\"}}}}],\"recency_emphasis\":3342," + "\"history\":62,\"last_update_time\":1717192049845,\"category_field\":[\"Tcqcb\"],\"result_index\":" + "\"opensearch-ad-plugin-result-test\",\"imputation_option\":{\"method\":\"FIXED_VALUES\",\"defaultFill\"" - + ":[],\"integerSensitive\":false},\"suggested_seasonality\":64,\"detection_interval\":{\"period\":" + + ":[{\"feature_name\":\"eYYCM\", \"data\": 3}]},\"suggested_seasonality\":64,\"detection_interval\":{\"period\":" + "{\"interval\":5,\"unit\":\"Minutes\"}},\"detector_type\":\"MULTI_ENTITY\",\"rules\":[],\"result_index_min_size\":1500}"; AnomalyDetector parsedDetector = AnomalyDetector.parse(TestHelpers.parser(detectorString), "id", 1L, null, null); assertEquals(1500, (int) parsedDetector.getCustomResultIndexMinSize()); @@ -891,11 +903,11 @@ public void testParseAnomalyDetector_withCustomIndex_withCustomResultIndexMinAge + "\"time_field\":\"HmdFH\",\"indices\":[\"ffsBF\"],\"filter_query\":{\"bool\":{\"filter\":[{\"exists\":" + "{\"field\":\"value\",\"boost\":1}}],\"adjust_pure_negative\":true,\"boost\":1}},\"window_delay\":" + "{\"period\":{\"interval\":2,\"unit\":\"Minutes\"}},\"shingle_size\":8,\"schema_version\":-512063255," - + "\"feature_attributes\":[{\"feature_id\":\"OTYJs\",\"feature_name\":\"eYYCM\",\"feature_enabled\":false," + + "\"feature_attributes\":[{\"feature_id\":\"OTYJs\",\"feature_name\":\"eYYCM\",\"feature_enabled\":true," + "\"aggregation_query\":{\"XzewX\":{\"value_count\":{\"field\":\"ok\"}}}}],\"recency_emphasis\":3342," + "\"history\":62,\"last_update_time\":1717192049845,\"category_field\":[\"Tcqcb\"],\"result_index\":" + "\"opensearch-ad-plugin-result-test\",\"imputation_option\":{\"method\":\"FIXED_VALUES\",\"defaultFill\"" - + ":[],\"integerSensitive\":false},\"suggested_seasonality\":64,\"detection_interval\":{\"period\":" + + ":[{\"feature_name\":\"eYYCM\", \"data\": 3}]},\"suggested_seasonality\":64,\"detection_interval\":{\"period\":" + "{\"interval\":5,\"unit\":\"Minutes\"}},\"detector_type\":\"MULTI_ENTITY\",\"rules\":[],\"result_index_min_age\":7}"; AnomalyDetector parsedDetector = AnomalyDetector.parse(TestHelpers.parser(detectorString), "id", 1L, null, null); assertEquals(7, (int) parsedDetector.getCustomResultIndexMinAge()); @@ -906,11 +918,11 @@ public void testParseAnomalyDetector_withCustomIndex_withCustomResultIndexTTL() + "\"time_field\":\"HmdFH\",\"indices\":[\"ffsBF\"],\"filter_query\":{\"bool\":{\"filter\":[{\"exists\":" + "{\"field\":\"value\",\"boost\":1}}],\"adjust_pure_negative\":true,\"boost\":1}},\"window_delay\":" + "{\"period\":{\"interval\":2,\"unit\":\"Minutes\"}},\"shingle_size\":8,\"schema_version\":-512063255," - + "\"feature_attributes\":[{\"feature_id\":\"OTYJs\",\"feature_name\":\"eYYCM\",\"feature_enabled\":false," + + "\"feature_attributes\":[{\"feature_id\":\"OTYJs\",\"feature_name\":\"eYYCM\",\"feature_enabled\":true," + "\"aggregation_query\":{\"XzewX\":{\"value_count\":{\"field\":\"ok\"}}}}],\"recency_emphasis\":3342," + "\"history\":62,\"last_update_time\":1717192049845,\"category_field\":[\"Tcqcb\"],\"result_index\":" + "\"opensearch-ad-plugin-result-test\",\"imputation_option\":{\"method\":\"FIXED_VALUES\",\"defaultFill\"" - + ":[],\"integerSensitive\":false},\"suggested_seasonality\":64,\"detection_interval\":{\"period\":" + + ":[{\"feature_name\":\"eYYCM\", \"data\": 3}]},\"suggested_seasonality\":64,\"detection_interval\":{\"period\":" + "{\"interval\":5,\"unit\":\"Minutes\"}},\"detector_type\":\"MULTI_ENTITY\",\"rules\":[],\"result_index_ttl\":30}"; AnomalyDetector parsedDetector = AnomalyDetector.parse(TestHelpers.parser(detectorString), "id", 1L, null, null); assertEquals(30, (int) parsedDetector.getCustomResultIndexTTL()); diff --git a/src/test/java/org/opensearch/ad/model/FeatureImputedTests.java b/src/test/java/org/opensearch/ad/model/FeatureImputedTests.java new file mode 100644 index 000000000..f90f73510 --- /dev/null +++ b/src/test/java/org/opensearch/ad/model/FeatureImputedTests.java @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.ad.model; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +import java.io.IOException; + +import org.hamcrest.MatcherAssert; +import org.junit.Before; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.timeseries.TestHelpers; + +public class FeatureImputedTests extends OpenSearchTestCase { + + private FeatureImputed featureImputed; + private String featureId = "feature_1"; + private Boolean imputed = true; + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + featureImputed = new FeatureImputed(featureId, imputed); + } + + public void testParseFeatureImputed() throws IOException { + String jsonString = TestHelpers.xContentBuilderToString(featureImputed.toXContent(TestHelpers.builder(), ToXContent.EMPTY_PARAMS)); + + // Parse the JSON content + XContentParser parser = JsonXContent.jsonXContent + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, jsonString); + parser.nextToken(); // move to the first token + + // Call the parse method + FeatureImputed parsedFeatureImputed = FeatureImputed.parse(parser); + + // Verify the parsed object + assertNotNull("Parsed FeatureImputed should not be null", parsedFeatureImputed); + assertEquals("Feature ID should match", featureId, parsedFeatureImputed.getFeatureId()); + assertEquals("Imputed value should match", imputed, parsedFeatureImputed.isImputed()); + } + + public void testWriteToAndReadFrom() throws IOException { + // Serialize the object + BytesStreamOutput output = new BytesStreamOutput(); + featureImputed.writeTo(output); + + // Deserialize the object + StreamInput streamInput = output.bytes().streamInput(); + FeatureImputed readFeatureImputed = new FeatureImputed(streamInput); + + // Verify the deserialized object + MatcherAssert.assertThat("Feature ID should match", readFeatureImputed.getFeatureId(), equalTo(featureId)); + MatcherAssert.assertThat("Imputed value should match", readFeatureImputed.isImputed(), equalTo(imputed)); + + // verify equals/hashCode + MatcherAssert.assertThat("FeatureImputed should match", featureImputed, equalTo(featureImputed)); + MatcherAssert.assertThat("FeatureImputed should match", featureImputed, not(equalTo(streamInput))); + MatcherAssert.assertThat("FeatureImputed should match", readFeatureImputed, equalTo(featureImputed)); + MatcherAssert.assertThat("FeatureImputed should match", readFeatureImputed.hashCode(), equalTo(featureImputed.hashCode())); + } +} diff --git a/src/test/java/org/opensearch/ad/ratelimit/CheckpointReadWorkerTests.java b/src/test/java/org/opensearch/ad/ratelimit/CheckpointReadWorkerTests.java index 115c41093..d4dd1878c 100644 --- a/src/test/java/org/opensearch/ad/ratelimit/CheckpointReadWorkerTests.java +++ b/src/test/java/org/opensearch/ad/ratelimit/CheckpointReadWorkerTests.java @@ -48,6 +48,7 @@ import org.opensearch.ad.constant.ADCommonName; import org.opensearch.ad.indices.ADIndexManagement; import org.opensearch.ad.ml.ADCheckpointDao; +import org.opensearch.ad.ml.ADInferencer; import org.opensearch.ad.ml.ADModelManager; import org.opensearch.ad.ml.ThresholdingResult; import org.opensearch.ad.model.AnomalyDetector; @@ -102,6 +103,7 @@ public class CheckpointReadWorkerTests extends AbstractRateLimitingTest { FeatureRequest request, request2, request3; ClusterSettings clusterSettings; ADStats adStats; + ADInferencer inferencer; @Override public void setUp() throws Exception { @@ -150,6 +152,7 @@ public void setUp() throws Exception { }; adStats = new ADStats(statsMap); + inferencer = new ADInferencer(modelManager, adStats, checkpoint, coldstartQueue, resultWriteStrategy, cacheProvider, threadPool); // Integer.MAX_VALUE makes a huge heap worker = new ADCheckpointReadWorker( @@ -171,12 +174,10 @@ public void setUp() throws Exception { checkpoint, coldstartQueue, nodeStateManager, - anomalyDetectionIndices, cacheProvider, TimeSeriesSettings.HOURLY_MAINTENANCE, checkpointWriteQueue, - adStats, - resultWriteStrategy + inferencer ); request = new FeatureRequest(Integer.MAX_VALUE, detectorId, RequestPriority.MEDIUM, new double[] { 0 }, 0, entity, null); @@ -247,14 +248,14 @@ private void regularTestSetUp(RegularSetUpConfig config) { public void testRegular() { regularTestSetUp(new RegularSetUpConfig.Builder().build()); - verify(resultWriteStrategy, times(1)).saveResult(any(), any(), any(), anyString()); + verify(resultWriteStrategy, times(1)).saveResult(any(), any(), any(), any(), anyString(), any(), any(), any()); verify(checkpointWriteQueue, never()).write(any(), anyBoolean(), any()); } public void testCannotLoadModel() { regularTestSetUp(new RegularSetUpConfig.Builder().canHostModel(false).build()); - verify(resultWriteStrategy, times(1)).saveResult(any(), any(), any(), anyString()); + verify(resultWriteStrategy, times(1)).saveResult(any(), any(), any(), any(), anyString(), any(), any(), any()); verify(checkpointWriteQueue, times(1)).write(any(), anyBoolean(), any()); } @@ -262,7 +263,7 @@ public void testNoFullModel() { regularTestSetUp(new RegularSetUpConfig.Builder().fullModel(false).build()); // even though saveResult is called, the actual won't happen as the rcf score is 0 // we have the guard condition at the beginning of saveResult method. - verify(resultWriteStrategy, times(1)).saveResult(any(), any(), any(), anyString()); + verify(resultWriteStrategy, times(1)).saveResult(any(), any(), any(), any(), anyString(), any(), any(), any()); verify(checkpointWriteQueue, never()).write(any(), anyBoolean(), any()); } @@ -552,12 +553,10 @@ public void testRemoveUnusedQueues() { checkpoint, coldstartQueue, nodeStateManager, - anomalyDetectionIndices, cacheProvider, TimeSeriesSettings.HOURLY_MAINTENANCE, checkpointWriteQueue, - adStats, - resultWriteStrategy + inferencer ); regularTestSetUp(new RegularSetUpConfig.Builder().build()); @@ -604,12 +603,10 @@ public void testSettingUpdatable() { checkpoint, coldstartQueue, nodeStateManager, - anomalyDetectionIndices, cacheProvider, TimeSeriesSettings.HOURLY_MAINTENANCE, checkpointWriteQueue, - adStats, - resultWriteStrategy + inferencer ); List requests = new ArrayList<>(); @@ -657,12 +654,10 @@ public void testOpenCircuitBreaker() { checkpoint, coldstartQueue, nodeStateManager, - anomalyDetectionIndices, cacheProvider, TimeSeriesSettings.HOURLY_MAINTENANCE, checkpointWriteQueue, - adStats, - resultWriteStrategy + inferencer ); List requests = new ArrayList<>(); @@ -805,7 +800,7 @@ public void testFailToScore() { state = MLUtil.randomModelState(new RandomModelStateConfig.Builder().fullModel(true).build()); when(checkpoint.processHCGetResponse(any(), anyString(), anyString())).thenReturn(state); - // anyString won't match null. That's why we use any() at position 4 instead of anyString. + // anyString won't match null. That's why we use any() at position 2 instead of anyString. doThrow(new IllegalArgumentException()).when(modelManager).getResult(any(), any(), anyString(), any(), any()); List requests = new ArrayList<>(); @@ -813,7 +808,7 @@ public void testFailToScore() { worker.putAll(requests); verify(modelManager, times(1)).getResult(any(), any(), anyString(), any(), any()); - verify(resultWriteStrategy, never()).saveResult(any(), any(), any(), anyString()); + verify(resultWriteStrategy, never()).saveResult(any(), any(), any(), any(), anyString(), any(), any(), any()); verify(checkpointWriteQueue, never()).write(any(), anyBoolean(), any()); verify(coldstartQueue, times(1)).put(any()); Object val = adStats.getStat(StatNames.AD_MODEL_CORRUTPION_COUNT.getName()).getValue(); diff --git a/src/test/java/org/opensearch/ad/rest/ADRestTestUtils.java b/src/test/java/org/opensearch/ad/rest/ADRestTestUtils.java index 933436368..264b19660 100644 --- a/src/test/java/org/opensearch/ad/rest/ADRestTestUtils.java +++ b/src/test/java/org/opensearch/ad/rest/ADRestTestUtils.java @@ -47,6 +47,7 @@ import org.opensearch.timeseries.TaskProfile; import org.opensearch.timeseries.TestHelpers; import org.opensearch.timeseries.model.DateRange; +import org.opensearch.timeseries.model.Feature; import org.opensearch.timeseries.model.IntervalTimeConfiguration; import org.opensearch.timeseries.model.Job; import org.opensearch.timeseries.model.TimeSeriesTask; @@ -196,6 +197,8 @@ public static Response createAnomalyDetector( boolean historical ) throws Exception { Instant now = Instant.now(); + List featureList = ImmutableList + .of(TestHelpers.randomFeature(randomAlphaOfLength(5), valueField, aggregationMethod, true)); AnomalyDetector detector = new AnomalyDetector( randomAlphaOfLength(10), randomLong(), @@ -204,7 +207,7 @@ public static Response createAnomalyDetector( randomAlphaOfLength(30), timeField, ImmutableList.of(indexName), - ImmutableList.of(TestHelpers.randomFeature(randomAlphaOfLength(5), valueField, aggregationMethod, true)), + featureList, filterQuery == null ? TestHelpers.randomQuery("{\"match_all\":{\"boost\":1}}") : TestHelpers.randomQuery(filterQuery), new IntervalTimeConfiguration(detectionIntervalInMinutes, ChronoUnit.MINUTES), new IntervalTimeConfiguration(windowDelayIntervalInMinutes, ChronoUnit.MINUTES), @@ -215,7 +218,7 @@ public static Response createAnomalyDetector( categoryFields, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(1), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), diff --git a/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java b/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java index ea3d448b0..85db01ad8 100644 --- a/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java +++ b/src/test/java/org/opensearch/ad/rest/AnomalyDetectorRestApiIT.java @@ -132,6 +132,7 @@ private AnomalyDetector createIndexAndGetAnomalyDetector(String indexName, List< public void testCreateAnomalyDetectorWithDuplicateName() throws Exception { AnomalyDetector detector = createIndexAndGetAnomalyDetector(INDEX_NAME); Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); AnomalyDetector detectorDuplicateName = new AnomalyDetector( AnomalyDetector.NO_ID, randomLong(), @@ -139,7 +140,7 @@ public void testCreateAnomalyDetectorWithDuplicateName() throws Exception { randomAlphaOfLength(5), randomAlphaOfLength(5), detector.getIndices(), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), TestHelpers.randomIntervalTimeConfiguration(), TestHelpers.randomIntervalTimeConfiguration(), @@ -150,7 +151,7 @@ public void testCreateAnomalyDetectorWithDuplicateName() throws Exception { null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -261,7 +262,7 @@ public void testUpdateAnomalyDetectorCategoryField() throws Exception { ImmutableList.of(randomAlphaOfLength(5)), detector.getUser(), null, - TestHelpers.randomImputationOption((int) expectedFeatures), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -327,7 +328,7 @@ public void testUpdateAnomalyDetector() throws Exception { null, detector.getUser(), null, - TestHelpers.randomImputationOption((int) expectedFeatures), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -398,7 +399,7 @@ public void testUpdateAnomalyDetectorNameToExisting() throws Exception { null, detector1.getUser(), null, - TestHelpers.randomImputationOption((int) expectedFeatures), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -446,7 +447,7 @@ public void testUpdateAnomalyDetectorNameToNew() throws Exception { null, detector.getUser(), null, - TestHelpers.randomImputationOption((int) expectedFeatures), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -500,7 +501,7 @@ public void testUpdateAnomalyDetectorWithNotExistingIndex() throws Exception { null, detector.getUser(), null, - TestHelpers.randomImputationOption((int) expectedFeatures), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -871,7 +872,7 @@ public void testUpdateAnomalyDetectorWithRunningAdJob() throws Exception { null, detector.getUser(), null, - TestHelpers.randomImputationOption((int) expectedFeatures), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), diff --git a/src/test/java/org/opensearch/ad/rest/HistoricalAnalysisRestApiIT.java b/src/test/java/org/opensearch/ad/rest/HistoricalAnalysisRestApiIT.java index 0e1078968..1e60e0b4a 100644 --- a/src/test/java/org/opensearch/ad/rest/HistoricalAnalysisRestApiIT.java +++ b/src/test/java/org/opensearch/ad/rest/HistoricalAnalysisRestApiIT.java @@ -134,9 +134,6 @@ private List startHistoricalAnalysis(int categoryFieldSize, String resul if (!TaskState.RUNNING.name().equals(adTaskProfile.getTask().getState())) { adTaskProfile = (ADTaskProfile) waitUntilTaskReachState(detectorId, ImmutableSet.of(TaskState.RUNNING.name())).get(0); } - // if (adTaskProfile.getTotalEntitiesCount() == null) { - // adTaskProfile = (ADTaskProfile) waitUntilEntityCountAvailable(detectorId).get(0); - // } assertEquals((int) Math.pow(categoryFieldDocCount, categoryFieldSize), adTaskProfile.getTotalEntitiesCount().intValue()); assertTrue(adTaskProfile.getPendingEntitiesCount() > 0); assertTrue(adTaskProfile.getRunningEntitiesCount() > 0); diff --git a/src/test/java/org/opensearch/ad/settings/AnomalyDetectorSettingsTests.java b/src/test/java/org/opensearch/ad/settings/AnomalyDetectorSettingsTests.java index 46cd0e619..867514e6a 100644 --- a/src/test/java/org/opensearch/ad/settings/AnomalyDetectorSettingsTests.java +++ b/src/test/java/org/opensearch/ad/settings/AnomalyDetectorSettingsTests.java @@ -215,7 +215,7 @@ public void testAllLegacyOpenDistroSettingsFallback() { public void testSettingsGetValue() { Settings settings = Settings.builder().put("plugins.anomaly_detection.request_timeout", "42s").build(); assertEquals(AnomalyDetectorSettings.AD_REQUEST_TIMEOUT.get(settings), TimeValue.timeValueSeconds(42)); - assertEquals(LegacyOpenDistroAnomalyDetectorSettings.REQUEST_TIMEOUT.get(settings), TimeValue.timeValueSeconds(10)); + assertEquals(LegacyOpenDistroAnomalyDetectorSettings.REQUEST_TIMEOUT.get(settings), TimeValue.timeValueSeconds(60)); settings = Settings.builder().put("plugins.anomaly_detection.max_anomaly_detectors", 99).build(); assertEquals(AnomalyDetectorSettings.AD_MAX_SINGLE_ENTITY_ANOMALY_DETECTORS.get(settings), Integer.valueOf(99)); diff --git a/src/test/java/org/opensearch/ad/task/ADTaskManagerTests.java b/src/test/java/org/opensearch/ad/task/ADTaskManagerTests.java index 3d489747b..0bac122d6 100644 --- a/src/test/java/org/opensearch/ad/task/ADTaskManagerTests.java +++ b/src/test/java/org/opensearch/ad/task/ADTaskManagerTests.java @@ -911,7 +911,7 @@ public void testMaintainRunningRealtimeTasks() { @SuppressWarnings("unchecked") public void testStartHistoricalAnalysisWithNoOwningNode() throws IOException { - AnomalyDetector detector = TestHelpers.randomAnomalyDetector(ImmutableList.of()); + AnomalyDetector detector = TestHelpers.randomAnomalyDetector(ImmutableList.of(randomFeature(true))); DateRange detectionDateRange = TestHelpers.randomDetectionDateRange(); User user = null; int availableTaskSlots = randomIntBetween(1, 10); diff --git a/src/test/java/org/opensearch/ad/transport/AnomalyResultTests.java b/src/test/java/org/opensearch/ad/transport/AnomalyResultTests.java index 6878f8d53..99762c5b1 100644 --- a/src/test/java/org/opensearch/ad/transport/AnomalyResultTests.java +++ b/src/test/java/org/opensearch/ad/transport/AnomalyResultTests.java @@ -69,7 +69,8 @@ import org.opensearch.ad.common.exception.JsonPathNotFoundException; import org.opensearch.ad.constant.ADCommonMessages; import org.opensearch.ad.constant.ADCommonName; -import org.opensearch.ad.indices.ADIndexManagement; +import org.opensearch.ad.ml.ADCheckpointDao; +import org.opensearch.ad.ml.ADInferencer; import org.opensearch.ad.ml.ADModelManager; import org.opensearch.ad.ml.ThresholdingResult; import org.opensearch.ad.model.AnomalyDetector; @@ -78,6 +79,7 @@ import org.opensearch.ad.ratelimit.ADColdStartWorker; import org.opensearch.ad.ratelimit.ADResultWriteRequest; import org.opensearch.ad.ratelimit.ADResultWriteWorker; +import org.opensearch.ad.ratelimit.ADSaveResultStrategy; import org.opensearch.ad.settings.AnomalyDetectorSettings; import org.opensearch.ad.stats.ADStats; import org.opensearch.ad.task.ADTaskManager; @@ -93,7 +95,6 @@ import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.core.action.ActionListener; -import org.opensearch.core.common.io.stream.NotSerializableExceptionWrapper; import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.common.transport.TransportAddress; import org.opensearch.core.index.Index; @@ -121,6 +122,7 @@ import org.opensearch.timeseries.ml.ModelState; import org.opensearch.timeseries.ml.SingleStreamModelIdMapper; import org.opensearch.timeseries.model.FeatureData; +import org.opensearch.timeseries.ratelimit.FeatureRequest; import org.opensearch.timeseries.stats.StatNames; import org.opensearch.timeseries.stats.TimeSeriesStat; import org.opensearch.timeseries.stats.suppliers.CounterSupplier; @@ -164,6 +166,8 @@ public class AnomalyResultTests extends AbstractTimeSeriesTest { private ADTaskManager adTaskManager; private ADCheckpointReadWorker checkpointReadQueue; private ADCacheProvider cacheProvider; + private ADInferencer inferencer; + private ADColdStartWorker coldStartWorker; @BeforeClass public static void setUpBeforeClass() { @@ -251,7 +255,9 @@ public void setUp() throws Exception { expectedValuesList, likelihood, threshold, - 30 + 30, + new double[2], + null ) ); return null; @@ -309,6 +315,7 @@ public void setUp() throws Exception { put(StatNames.AD_EXECUTE_FAIL_COUNT.getName(), new TimeSeriesStat<>(false, new CounterSupplier())); put(StatNames.AD_HC_EXECUTE_REQUEST_COUNT.getName(), new TimeSeriesStat<>(false, new CounterSupplier())); put(StatNames.AD_HC_EXECUTE_FAIL_COUNT.getName(), new TimeSeriesStat<>(false, new CounterSupplier())); + put(StatNames.AD_MODEL_CORRUTPION_COUNT.getName(), new TimeSeriesStat<>(false, new CounterSupplier())); } }; @@ -347,6 +354,17 @@ public void setUp() throws Exception { cacheProvider = mock(ADCacheProvider.class); when(cacheProvider.get()).thenReturn(mock(ADPriorityCache.class)); + + coldStartWorker = mock(ADColdStartWorker.class); + inferencer = new ADInferencer( + normalModelManager, + adStats, + mock(ADCheckpointDao.class), + coldStartWorker, + mock(ADSaveResultStrategy.class), + cacheProvider, + threadPool + ); } @Override @@ -385,11 +403,8 @@ public void testNormal() throws IOException, InterruptedException { cacheProvider, stateManager, mock(ADCheckpointReadWorker.class), - normalModelManager, - mock(ADIndexManagement.class), - resultWriteWorker, - adStats, - mock(ADColdStartWorker.class) + inferencer, + threadPool ); new ThresholdResultTransportAction(new ActionFilters(Collections.emptySet()), transportService, normalModelManager); @@ -504,14 +519,6 @@ public void sendRequest( when(hashRing.getOwningNodeWithSameLocalVersionForRealtime(any(String.class))).thenReturn(discoveryNode); when(hashRing.getNodeByAddress(any(TransportAddress.class))).thenReturn(discoveryNode); // register handler on testNodes[1] - // new RCFResultTransportAction( - // new ActionFilters(Collections.emptySet()), - // testNodes[1].transportService, - // normalModelManager, - // adCircuitBreakerService, - // hashRing, - // adStats - // ); new ADSingleStreamResultTransportAction( testNodes[1].transportService, new ActionFilters(Collections.emptySet()), @@ -519,11 +526,8 @@ public void sendRequest( mock(ADCacheProvider.class), stateManager, mock(ADCheckpointReadWorker.class), - normalModelManager, - mock(ADIndexManagement.class), - mock(ADResultWriteWorker.class), - adStats, - mock(ADColdStartWorker.class) + inferencer, + threadPool ); TransportService realTransportService = testNodes[0].transportService; @@ -571,14 +575,6 @@ public void testInsufficientCapacityExceptionDuringColdStart() { .thenReturn(Optional.of(new LimitExceededException(adID, CommonMessages.MEMORY_LIMIT_EXCEEDED_ERR_MSG))); // These constructors register handler in transport service - // new RCFResultTransportAction( - // new ActionFilters(Collections.emptySet()), - // transportService, - // rcfManager, - // adCircuitBreakerService, - // hashRing, - // adStats - // ); new ADSingleStreamResultTransportAction( transportService, new ActionFilters(Collections.emptySet()), @@ -586,11 +582,8 @@ public void testInsufficientCapacityExceptionDuringColdStart() { mock(ADCacheProvider.class), stateManager, mock(ADCheckpointReadWorker.class), - rcfManager, - mock(ADIndexManagement.class), - mock(ADResultWriteWorker.class), - adStats, - mock(ADColdStartWorker.class) + inferencer, + threadPool ); new ThresholdResultTransportAction(new ActionFilters(Collections.emptySet()), transportService, normalModelManager); @@ -620,17 +613,29 @@ public void testInsufficientCapacityExceptionDuringColdStart() { } @SuppressWarnings("unchecked") - public void testInsufficientCapacityExceptionDuringRestoringModel() { + public void testInsufficientCapacityExceptionDuringRestoringModel() throws InterruptedException { + ADModelManager badModelManager = mock(ADModelManager.class); + doThrow(new NullPointerException()).when(badModelManager).getResult(any(), any(), any(), any(), any()); - ADModelManager rcfManager = mock(ADModelManager.class); + inferencer = new ADInferencer( + badModelManager, + adStats, + mock(ADCheckpointDao.class), + coldStartWorker, + mock(ADSaveResultStrategy.class), + cacheProvider, + threadPool + ); ADPriorityCache adPriorityCache = mock(ADPriorityCache.class); when(cacheProvider.get()).thenReturn(adPriorityCache); when(adPriorityCache.get(anyString(), any())).thenReturn(mock(ModelState.class)); - doThrow(new NotSerializableExceptionWrapper(new LimitExceededException(adID, CommonMessages.MEMORY_LIMIT_EXCEEDED_ERR_MSG))) - .when(rcfManager) - .getResult(any(), any(), any(), any(), any()); + CountDownLatch inProgress = new CountDownLatch(1); + doAnswer(invocation -> { + inProgress.countDown(); + return null; + }).when(coldStartWorker).put(any(FeatureRequest.class)); // These constructors register handler in transport service new ADSingleStreamResultTransportAction( @@ -640,11 +645,8 @@ public void testInsufficientCapacityExceptionDuringRestoringModel() { cacheProvider, stateManager, mock(ADCheckpointReadWorker.class), - rcfManager, - mock(ADIndexManagement.class), - mock(ADResultWriteWorker.class), - adStats, - mock(ADColdStartWorker.class) + inferencer, + threadPool ); new ThresholdResultTransportAction(new ActionFilters(Collections.emptySet()), transportService, normalModelManager); @@ -670,7 +672,9 @@ public void testInsufficientCapacityExceptionDuringRestoringModel() { PlainActionFuture listener = new PlainActionFuture<>(); action.doExecute(null, request, listener); - verify(stateManager, times(1)).setException(eq(adID), any(LimitExceededException.class)); + inProgress.await(30, TimeUnit.SECONDS); + // null pointer exception caused re-cold start + verify(coldStartWorker, times(1)).put(any(FeatureRequest.class)); } private TransportResponseHandler rcfResponseHandler(TransportResponseHandler handler) { @@ -761,14 +765,6 @@ public void sendRequest( when(hashRing.getNodeByAddress(any(TransportAddress.class))).thenReturn(discoveryNode); // register handlers on testNodes[1] ActionFilters actionFilters = new ActionFilters(Collections.emptySet()); - // new RCFResultTransportAction( - // actionFilters, - // testNodes[1].transportService, - // normalModelManager, - // adCircuitBreakerService, - // hashRing, - // adStats - // ); new ADSingleStreamResultTransportAction( testNodes[1].transportService, actionFilters, @@ -776,11 +772,8 @@ public void sendRequest( mock(ADCacheProvider.class), stateManager, mock(ADCheckpointReadWorker.class), - normalModelManager, - mock(ADIndexManagement.class), - mock(ADResultWriteWorker.class), - adStats, - mock(ADColdStartWorker.class) + inferencer, + threadPool ); new ThresholdResultTransportAction(actionFilters, testNodes[1].transportService, normalModelManager); @@ -819,14 +812,6 @@ public void testCircuitBreaker() { when(breakerService.isOpen()).thenReturn(true); // These constructors register handler in transport service - // new RCFResultTransportAction( - // new ActionFilters(Collections.emptySet()), - // transportService, - // normalModelManager, - // breakerService, - // hashRing, - // adStats - // ); new ADSingleStreamResultTransportAction( transportService, new ActionFilters(Collections.emptySet()), @@ -834,11 +819,8 @@ public void testCircuitBreaker() { mock(ADCacheProvider.class), stateManager, mock(ADCheckpointReadWorker.class), - normalModelManager, - mock(ADIndexManagement.class), - mock(ADResultWriteWorker.class), - adStats, - mock(ADColdStartWorker.class) + inferencer, + threadPool ); new ThresholdResultTransportAction(new ActionFilters(Collections.emptySet()), transportService, normalModelManager); @@ -916,11 +898,8 @@ private void nodeNotConnectedExceptionTemplate(boolean isRCF, boolean temporary, mock(ADCacheProvider.class), stateManager, mock(ADCheckpointReadWorker.class), - normalModelManager, - mock(ADIndexManagement.class), - mock(ADResultWriteWorker.class), - adStats, - mock(ADColdStartWorker.class) + inferencer, + threadPool ); AnomalyResultTransportAction action = new AnomalyResultTransportAction( @@ -1003,14 +982,6 @@ public void testMute() { public void alertingRequestTemplate(boolean anomalyResultIndexExists) throws IOException { // These constructors register handler in transport service - // new RCFResultTransportAction( - // new ActionFilters(Collections.emptySet()), - // transportService, - // normalModelManager, - // adCircuitBreakerService, - // hashRing, - // adStats - // ); new ADSingleStreamResultTransportAction( transportService, new ActionFilters(Collections.emptySet()), @@ -1018,11 +989,8 @@ public void alertingRequestTemplate(boolean anomalyResultIndexExists) throws IOE mock(ADCacheProvider.class), stateManager, mock(ADCheckpointReadWorker.class), - normalModelManager, - mock(ADIndexManagement.class), - mock(ADResultWriteWorker.class), - adStats, - mock(ADColdStartWorker.class) + inferencer, + threadPool ); Optional localNode = Optional.of(clusterService.state().nodes().getLocalNode()); @@ -1239,14 +1207,6 @@ public ColdStartConfig build() { @SuppressWarnings("unchecked") private void setUpColdStart(ThreadPool mockThreadPool, ColdStartConfig config) { - doAnswer(invocation -> { - ActionListener> listener = invocation.getArgument(4); - listener.onResponse(Optional.empty()); - return null; - }) - .when(featureQuery) - .getCurrentFeatures(any(AnomalyDetector.class), anyLong(), anyLong(), eq(AnalysisType.AD), any(ActionListener.class)); - doAnswer(invocation -> { ActionListener listener = invocation.getArgument(1); if (config.getCheckpointException == null) { @@ -1271,7 +1231,9 @@ private void setUpColdStart(ThreadPool mockThreadPool, ColdStartConfig config) { .getCurrentFeatures(any(AnomalyDetector.class), anyLong(), anyLong(), eq(AnalysisType.AD), any(ActionListener.class)); ADCacheProvider cacheProvider = mock(ADCacheProvider.class); - when(cacheProvider.get()).thenReturn(mock(ADPriorityCache.class)); + ADPriorityCache priorityCache = mock(ADPriorityCache.class); + when(cacheProvider.get()).thenReturn(priorityCache); + when(priorityCache.get(any(), any())).thenReturn(null); // register action handler new ADSingleStreamResultTransportAction( @@ -1281,11 +1243,8 @@ private void setUpColdStart(ThreadPool mockThreadPool, ColdStartConfig config) { cacheProvider, stateManager, checkpointReadQueue, - normalModelManager, - mock(ADIndexManagement.class), - mock(ADResultWriteWorker.class), - adStats, - mock(ADColdStartWorker.class) + inferencer, + threadPool ); } @@ -1430,14 +1389,6 @@ private void globalBlockTemplate(BlockType type, String errLogMsg, Settings inde when(hackedClusterService.state()).thenReturn(blockedClusterState); // These constructors register handler in transport service - // new RCFResultTransportAction( - // new ActionFilters(Collections.emptySet()), - // transportService, - // normalModelManager, - // adCircuitBreakerService, - // hashRing, - // adStats - // ); new ADSingleStreamResultTransportAction( transportService, new ActionFilters(Collections.emptySet()), @@ -1445,11 +1396,8 @@ private void globalBlockTemplate(BlockType type, String errLogMsg, Settings inde mock(ADCacheProvider.class), stateManager, mock(ADCheckpointReadWorker.class), - normalModelManager, - mock(ADIndexManagement.class), - mock(ADResultWriteWorker.class), - adStats, - mock(ADColdStartWorker.class) + inferencer, + threadPool ); new ThresholdResultTransportAction(new ActionFilters(Collections.emptySet()), transportService, normalModelManager); @@ -1617,13 +1565,17 @@ public void testColdStartEndRunExceptionNow() { verify(featureQuery, never()).getColdStartData(any(AnomalyDetector.class), any(ActionListener.class)); } - @SuppressWarnings({ "unchecked" }) - public void testColdStartBecauseFailtoGetCheckpoint() { + public void testColdStartBecauseFailtoGetCheckpoint() throws InterruptedException { ThreadPool mockThreadPool = mock(ThreadPool.class); setUpColdStart( mockThreadPool, new ColdStartConfig.Builder().getCheckpointException(new IndexNotFoundException(ADCommonName.CHECKPOINT_INDEX_NAME)).build() ); + CountDownLatch inProgress = new CountDownLatch(1); + doAnswer(invocation -> { + inProgress.countDown(); + return null; + }).when(checkpointReadQueue).put(any()); AnomalyResultRequest request = new AnomalyResultRequest(adID, 100, 200); PlainActionFuture listener = new PlainActionFuture<>(); @@ -1648,6 +1600,8 @@ public void testColdStartBecauseFailtoGetCheckpoint() { action.doExecute(null, request, listener); AnomalyResultResponse response = listener.actionGet(10000L); + + inProgress.await(30, TimeUnit.SECONDS); assertEquals(Double.NaN, response.getAnomalyGrade(), 0.001); verify(checkpointReadQueue, times(1)).put(any()); } diff --git a/src/test/java/org/opensearch/ad/transport/AnomalyResultTransportActionTests.java b/src/test/java/org/opensearch/ad/transport/AnomalyResultTransportActionTests.java index f8208215f..b1deb5025 100644 --- a/src/test/java/org/opensearch/ad/transport/AnomalyResultTransportActionTests.java +++ b/src/test/java/org/opensearch/ad/transport/AnomalyResultTransportActionTests.java @@ -219,7 +219,7 @@ private AnomalyDetector randomDetector(List indices, List featu null, null, null, - TestHelpers.randomImputationOption((int) features.stream().filter(Feature::getEnabled).count()), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -249,7 +249,7 @@ private AnomalyDetector randomHCDetector(List indices, List fea ImmutableList.of(categoryField), null, null, - TestHelpers.randomImputationOption((int) features.stream().filter(Feature::getEnabled).count()), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), diff --git a/src/test/java/org/opensearch/ad/transport/EntityResultTransportActionTests.java b/src/test/java/org/opensearch/ad/transport/EntityResultTransportActionTests.java index e2b211a8d..e35e85c87 100644 --- a/src/test/java/org/opensearch/ad/transport/EntityResultTransportActionTests.java +++ b/src/test/java/org/opensearch/ad/transport/EntityResultTransportActionTests.java @@ -55,6 +55,7 @@ import org.opensearch.ad.indices.ADIndexManagement; import org.opensearch.ad.ml.ADCheckpointDao; import org.opensearch.ad.ml.ADColdStart; +import org.opensearch.ad.ml.ADInferencer; import org.opensearch.ad.ml.ADModelManager; import org.opensearch.ad.model.AnomalyDetector; import org.opensearch.ad.ratelimit.ADCheckpointReadWorker; @@ -135,6 +136,7 @@ public class EntityResultTransportActionTests extends AbstractTimeSeriesTest { ClusterService clusterService; ADStats adStats; ADSaveResultStrategy resultSaver; + ADInferencer inferencer; @BeforeClass public static void setUpBeforeClass() { @@ -261,10 +263,11 @@ public void setUp() throws Exception { adStats = new ADStats(statsMap); resultSaver = new ADSaveResultStrategy(1, resultWriteQueue); + inferencer = new ADInferencer(manager, adStats, checkpointDao, entityColdStartQueue, resultSaver, provider, threadPool); + entityResult = new EntityADResultTransportAction( actionFilters, transportService, - manager, adCircuitBreakerService, provider, stateManager, @@ -272,9 +275,7 @@ public void setUp() throws Exception { checkpointReadQueue, coldEntityQueue, threadPool, - entityColdStartQueue, - adStats, - resultSaver + inferencer ); // timeout in 60 seconds @@ -388,10 +389,10 @@ public void testJsonResponse() throws IOException, JsonPathNotFoundException { public void testFailToScore() { ADModelManager spyModelManager = spy(manager); doThrow(new IllegalArgumentException()).when(spyModelManager).getResult(any(), any(), anyString(), any(), any()); + inferencer = new ADInferencer(spyModelManager, adStats, checkpointDao, entityColdStartQueue, resultSaver, provider, threadPool); entityResult = new EntityADResultTransportAction( actionFilters, transportService, - spyModelManager, adCircuitBreakerService, provider, stateManager, @@ -399,9 +400,7 @@ public void testFailToScore() { checkpointReadQueue, coldEntityQueue, threadPool, - entityColdStartQueue, - adStats, - resultSaver + inferencer ); PlainActionFuture future = PlainActionFuture.newFuture(); diff --git a/src/test/java/org/opensearch/ad/transport/ForwardADTaskRequestTests.java b/src/test/java/org/opensearch/ad/transport/ForwardADTaskRequestTests.java index 6e93ef01a..913fc64c0 100644 --- a/src/test/java/org/opensearch/ad/transport/ForwardADTaskRequestTests.java +++ b/src/test/java/org/opensearch/ad/transport/ForwardADTaskRequestTests.java @@ -78,7 +78,7 @@ public void testNullDetectorIdAndTaskAction() throws IOException { null, randomUser(), null, - TestHelpers.randomImputationOption(0), + TestHelpers.randomImputationOption(null), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), diff --git a/src/test/java/org/opensearch/ad/transport/MultiEntityResultTests.java b/src/test/java/org/opensearch/ad/transport/MultiEntityResultTests.java index 931f3f42d..81903b375 100644 --- a/src/test/java/org/opensearch/ad/transport/MultiEntityResultTests.java +++ b/src/test/java/org/opensearch/ad/transport/MultiEntityResultTests.java @@ -68,6 +68,8 @@ import org.opensearch.ad.caching.ADCacheProvider; import org.opensearch.ad.caching.ADPriorityCache; import org.opensearch.ad.indices.ADIndexManagement; +import org.opensearch.ad.ml.ADCheckpointDao; +import org.opensearch.ad.ml.ADInferencer; import org.opensearch.ad.ml.ADModelManager; import org.opensearch.ad.ml.ThresholdingResult; import org.opensearch.ad.model.AnomalyDetector; @@ -175,6 +177,7 @@ public class MultiEntityResultTests extends AbstractTimeSeriesTest { private ADPriorityCache entityCache; private ADTaskManager adTaskManager; private ADSaveResultStrategy resultSaver; + private ADInferencer inferencer; @BeforeClass public static void setUpBeforeClass() { @@ -319,6 +322,16 @@ public void setUp() throws Exception { attrs3 = new HashMap<>(); attrs3.put(serviceField, app0); attrs3.put(hostField, server3); + + inferencer = new ADInferencer( + normalModelManager, + adStats, + mock(ADCheckpointDao.class), + entityColdStartQueue, + resultSaver, + provider, + threadPool + ); } @Override @@ -413,7 +426,6 @@ private void setUpEntityResult(int nodeIndex, NodeStateManager nodeStateManager) new ActionFilters(Collections.emptySet()), // since we send requests to testNodes[1] testNodes[nodeIndex].transportService, - normalModelManager, adCircuitBreakerService, provider, nodeStateManager, @@ -421,9 +433,7 @@ private void setUpEntityResult(int nodeIndex, NodeStateManager nodeStateManager) checkpointReadQueue, coldEntityQueue, threadPool, - entityColdStartQueue, - adStats, - resultSaver + inferencer ); when(normalModelManager.getResult(any(), any(), any(), any(), any())).thenReturn(new ThresholdingResult(0, 1, 1)); @@ -782,7 +792,6 @@ public void testCircuitBreakerOpen() throws InterruptedException, IOException { new ActionFilters(Collections.emptySet()), // since we send requests to testNodes[1] testNodes[1].transportService, - normalModelManager, openBreaker, provider, spyStateManager, @@ -790,9 +799,7 @@ public void testCircuitBreakerOpen() throws InterruptedException, IOException { checkpointReadQueue, coldEntityQueue, threadPool, - entityColdStartQueue, - adStats, - resultSaver + inferencer ); CountDownLatch inProgress = new CountDownLatch(1); @@ -966,7 +973,6 @@ public void testCacheSelection() throws IOException, InterruptedException { new ActionFilters(Collections.emptySet()), // since we send requests to testNodes[1] testNodes[1].transportService, - normalModelManager, adCircuitBreakerService, provider, stateManager, @@ -974,9 +980,7 @@ public void testCacheSelection() throws IOException, InterruptedException { checkpointReadQueue, coldEntityQueue, threadPool, - entityColdStartQueue, - adStats, - resultSaver + inferencer ); CountDownLatch modelNodeInProgress = new CountDownLatch(1); diff --git a/src/test/java/org/opensearch/ad/transport/PreviewAnomalyDetectorTransportActionTests.java b/src/test/java/org/opensearch/ad/transport/PreviewAnomalyDetectorTransportActionTests.java index eee68c4ae..d32c3222f 100644 --- a/src/test/java/org/opensearch/ad/transport/PreviewAnomalyDetectorTransportActionTests.java +++ b/src/test/java/org/opensearch/ad/transport/PreviewAnomalyDetectorTransportActionTests.java @@ -12,7 +12,6 @@ package org.opensearch.ad.transport; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; @@ -173,7 +172,7 @@ public void onFailure(Exception e) { } }; - doReturn(TestHelpers.randomThresholdingResults()).when(modelManager).getPreviewResults(any(), anyInt(), anyInt()); + doReturn(TestHelpers.randomThresholdingResults()).when(modelManager).getPreviewResults(any(), any()); doAnswer(responseMock -> { Long startTime = responseMock.getArgument(1); @@ -373,7 +372,7 @@ public void onFailure(Exception e) { Assert.assertTrue(false); } }; - doReturn(TestHelpers.randomThresholdingResults()).when(modelManager).getPreviewResults(any(), anyInt(), anyInt()); + doReturn(TestHelpers.randomThresholdingResults()).when(modelManager).getPreviewResults(any(), any()); doAnswer(responseMock -> { Long startTime = responseMock.getArgument(1); diff --git a/src/test/java/org/opensearch/ad/transport/RCFResultTests.java b/src/test/java/org/opensearch/ad/transport/RCFResultTests.java index a3ed6ee7c..82b9723ac 100644 --- a/src/test/java/org/opensearch/ad/transport/RCFResultTests.java +++ b/src/test/java/org/opensearch/ad/transport/RCFResultTests.java @@ -139,7 +139,9 @@ public void testNormal() { expectedValuesList, likelihood, threshold, - forestSize + forestSize, + new double[] { 0 }, + null ) ); return null; @@ -312,7 +314,9 @@ public void testCircuitBreaker() { expectedValuesList, likelihood, threshold, - 30 + 30, + new double[] { 0 }, + null ) ); return null; diff --git a/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorTransportActionTests.java b/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorTransportActionTests.java index 7ba4680e7..80cef5a15 100644 --- a/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorTransportActionTests.java +++ b/src/test/java/org/opensearch/ad/transport/ValidateAnomalyDetectorTransportActionTests.java @@ -15,6 +15,7 @@ import java.net.URL; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.List; import java.util.Locale; import org.junit.Test; @@ -377,6 +378,7 @@ private void testValidateAnomalyDetectorWithCustomResultIndex(boolean resultInde @Test public void testValidateAnomalyDetectorWithInvalidDetectorName() throws IOException { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); AnomalyDetector anomalyDetector = new AnomalyDetector( randomAlphaOfLength(5), randomLong(), @@ -384,7 +386,7 @@ public void testValidateAnomalyDetectorWithInvalidDetectorName() throws IOExcept randomAlphaOfLength(5), timeField, ImmutableList.of(randomAlphaOfLength(5).toLowerCase(Locale.ROOT)), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), TestHelpers.randomIntervalTimeConfiguration(), TestHelpers.randomIntervalTimeConfiguration(), @@ -395,7 +397,7 @@ public void testValidateAnomalyDetectorWithInvalidDetectorName() throws IOExcept null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -424,6 +426,7 @@ public void testValidateAnomalyDetectorWithInvalidDetectorName() throws IOExcept @Test public void testValidateAnomalyDetectorWithDetectorNameTooLong() throws IOException { Feature feature = TestHelpers.randomFeature(); + List featureList = ImmutableList.of(feature); AnomalyDetector anomalyDetector = new AnomalyDetector( randomAlphaOfLength(5), randomLong(), @@ -431,7 +434,7 @@ public void testValidateAnomalyDetectorWithDetectorNameTooLong() throws IOExcept randomAlphaOfLength(5), timeField, ImmutableList.of(randomAlphaOfLength(5).toLowerCase(Locale.ROOT)), - ImmutableList.of(feature), + featureList, TestHelpers.randomQuery(), TestHelpers.randomIntervalTimeConfiguration(), TestHelpers.randomIntervalTimeConfiguration(), @@ -442,7 +445,7 @@ public void testValidateAnomalyDetectorWithDetectorNameTooLong() throws IOExcept null, TestHelpers.randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), diff --git a/src/test/java/org/opensearch/ad/transport/handler/AnomalyResultBulkIndexHandlerTests.java b/src/test/java/org/opensearch/ad/transport/handler/AnomalyResultBulkIndexHandlerTests.java index 32e9e14e9..98daeb1d9 100644 --- a/src/test/java/org/opensearch/ad/transport/handler/AnomalyResultBulkIndexHandlerTests.java +++ b/src/test/java/org/opensearch/ad/transport/handler/AnomalyResultBulkIndexHandlerTests.java @@ -228,7 +228,8 @@ private AnomalyResult wrongAnomalyResult() { null, null, null, - randomDoubleBetween(1.1, 10.0, true) + randomDoubleBetween(1.1, 10.0, true), + null ); } } diff --git a/src/test/java/org/opensearch/forecast/model/ForecasterTests.java b/src/test/java/org/opensearch/forecast/model/ForecasterTests.java index 66af227ee..dd31136ad 100644 --- a/src/test/java/org/opensearch/forecast/model/ForecasterTests.java +++ b/src/test/java/org/opensearch/forecast/model/ForecasterTests.java @@ -61,7 +61,7 @@ public class ForecasterTests extends AbstractTimeSeriesTest { Integer customResultIndexTTL = null; public void testForecasterConstructor() { - ImputationOption imputationOption = TestHelpers.randomImputationOption(0); + ImputationOption imputationOption = TestHelpers.randomImputationOption(features); Forecaster forecaster = new Forecaster( forecasterId, @@ -135,7 +135,7 @@ public void testForecasterConstructorWithNullForecastInterval() { user, resultIndex, horizon, - TestHelpers.randomImputationOption(0), + TestHelpers.randomImputationOption(features), recencyEmphasis, seasonality, randomIntBetween(1, 1000), @@ -173,7 +173,7 @@ public void testNegativeInterval() { user, resultIndex, horizon, - TestHelpers.randomImputationOption(0), + TestHelpers.randomImputationOption(features), recencyEmphasis, seasonality, randomIntBetween(1, 1000), @@ -211,7 +211,7 @@ public void testMaxCategoryFieldsLimits() { user, resultIndex, horizon, - TestHelpers.randomImputationOption(0), + TestHelpers.randomImputationOption(features), recencyEmphasis, seasonality, randomIntBetween(1, 1000), @@ -249,7 +249,7 @@ public void testBlankName() { user, resultIndex, horizon, - TestHelpers.randomImputationOption(0), + TestHelpers.randomImputationOption(features), recencyEmphasis, seasonality, randomIntBetween(1, 1000), @@ -287,7 +287,7 @@ public void testInvalidCustomResultIndex() { user, resultIndex, horizon, - TestHelpers.randomImputationOption(0), + TestHelpers.randomImputationOption(features), recencyEmphasis, seasonality, randomIntBetween(1, 1000), @@ -324,7 +324,7 @@ public void testValidCustomResultIndex() { user, resultIndex, horizon, - TestHelpers.randomImputationOption(0), + TestHelpers.randomImputationOption(features), recencyEmphasis, seasonality, randomIntBetween(1, 1000), @@ -359,7 +359,7 @@ public void testInvalidHorizon() { user, resultIndex, horizon, - TestHelpers.randomImputationOption(0), + TestHelpers.randomImputationOption(features), recencyEmphasis, seasonality, randomIntBetween(1, 1000), diff --git a/src/test/java/org/opensearch/forecast/rest/ForecastRestApiIT.java b/src/test/java/org/opensearch/forecast/rest/ForecastRestApiIT.java index 43e011dba..5e5dee049 100644 --- a/src/test/java/org/opensearch/forecast/rest/ForecastRestApiIT.java +++ b/src/test/java/org/opensearch/forecast/rest/ForecastRestApiIT.java @@ -325,6 +325,49 @@ public void testSuggestOneMinute() throws Exception { int historySuggestions = ((Integer) responseMap.get("history")); assertEquals(37, historySuggestions); + + // case 4: no feature is ok + forecasterDef = "{\n" + + " \"name\": \"Second-Test-Detector-4\",\n" + + " \"description\": \"ok rate\",\n" + + " \"time_field\": \"timestamp\",\n" + + " \"indices\": [\n" + + " \"%s\"\n" + + " ],\n" + + " \"window_delay\": {\n" + + " \"period\": {\n" + + " \"interval\": 20,\n" + + " \"unit\": \"SECONDS\"\n" + + " }\n" + + " },\n" + + " \"ui_metadata\": {\n" + + " \"aabb\": {\n" + + " \"ab\": \"bb\"\n" + + " }\n" + + " },\n" + + " \"schema_version\": 2,\n" + + " \"horizon\": 24,\n" + + " \"forecast_interval\": {\n" + + " \"period\": {\n" + + " \"interval\": 4,\n" + + " \"unit\": \"MINUTES\"\n" + + " }\n" + + " }\n" + + "}"; + formattedForecaster = String.format(Locale.ROOT, forecasterDef, SYNTHETIC_DATASET_NAME); + response = TestHelpers + .makeRequest( + client(), + "POST", + String.format(Locale.ROOT, SUGGEST_INTERVAL_URI), + ImmutableMap.of(), + TestHelpers.toHttpEntity(formattedForecaster), + null + ); + responseMap = entityAsMap(response); + suggestions = (Map) ((Map) responseMap.get("interval")).get("period"); + assertEquals(1, (int) suggestions.get("interval")); + assertEquals("Minutes", suggestions.get("unit")); } public void testSuggestTenMinute() throws Exception { @@ -515,6 +558,134 @@ public void testSuggestSparseData() throws Exception { assertEquals(0, responseMap.size()); } + /** + * Test data interval is larger than 1 hr and we fail to suggest + */ + public void testFailToSuggest() throws Exception { + int trainTestSplit = 100; + String categoricalField = "componentName"; + GenData dataGenerated = genUniformSingleFeatureData( + 70, + trainTestSplit, + 1, + categoricalField, + MISSING_MODE.NO_MISSING_DATA, + -1, + -1, + 50 + ); + ingestUniformSingleFeatureData(trainTestSplit, dataGenerated.data, UNIFORM_DATASET_NAME, categoricalField); + + // case 1: IntervalCalculation.findMinimumInterval cannot find any data point in the last 40 points and return 1 minute instead. + // We keep searching and find nothing below 1 hr and then return. + String forecasterDef = "{\n" + + " \"name\": \"Second-Test-Forecaster-4\",\n" + + " \"description\": \"ok rate\",\n" + + " \"time_field\": \"timestamp\",\n" + + " \"indices\": [\n" + + " \"%s\"\n" + + " ],\n" + + " \"feature_attributes\": [\n" + + " {\n" + + " \"feature_id\": \"sum1\",\n" + + " \"feature_name\": \"sum1\",\n" + + " \"feature_enabled\": true,\n" + + " \"importance\": 1,\n" + + " \"aggregation_query\": {\n" + + " \"sum1\": {\n" + + " \"sum\": {\n" + + " \"field\": \"data\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " ],\n" + + " \"window_delay\": {\n" + + " \"period\": {\n" + + " \"interval\": 20,\n" + + " \"unit\": \"SECONDS\"\n" + + " }\n" + + " },\n" + + " \"ui_metadata\": {\n" + + " \"aabb\": {\n" + + " \"ab\": \"bb\"\n" + + " }\n" + + " },\n" + + " \"schema_version\": 2,\n" + + " \"horizon\": 24\n" + + "}"; + + String formattedForecaster = String.format(Locale.ROOT, forecasterDef, UNIFORM_DATASET_NAME); + + Response response = TestHelpers + .makeRequest( + client(), + "POST", + String.format(Locale.ROOT, SUGGEST_INTERVAL_URI), + ImmutableMap.of(), + TestHelpers.toHttpEntity(formattedForecaster), + null + ); + assertEquals("Suggest forecaster interval failed", RestStatus.OK, TestHelpers.restStatus(response)); + Map responseMap = entityAsMap(response); + assertEquals(0, responseMap.size()); + + // case 2: IntervalCalculation.findMinimumInterval find an interval larger than 1 hr by going through the last 240 points. + // findMinimumInterval returns null and we stop searching further. + forecasterDef = "{\n" + + " \"name\": \"Second-Test-Forecaster-4\",\n" + + " \"description\": \"ok rate\",\n" + + " \"time_field\": \"timestamp\",\n" + + " \"indices\": [\n" + + " \"%s\"\n" + + " ],\n" + + " \"feature_attributes\": [\n" + + " {\n" + + " \"feature_id\": \"sum1\",\n" + + " \"feature_name\": \"sum1\",\n" + + " \"feature_enabled\": true,\n" + + " \"importance\": 1,\n" + + " \"aggregation_query\": {\n" + + " \"sum1\": {\n" + + " \"sum\": {\n" + + " \"field\": \"data\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " ],\n" + + " \"window_delay\": {\n" + + " \"period\": {\n" + + " \"interval\": 20,\n" + + " \"unit\": \"SECONDS\"\n" + + " }\n" + + " },\n" + + " \"ui_metadata\": {\n" + + " \"aabb\": {\n" + + " \"ab\": \"bb\"\n" + + " }\n" + + " },\n" + + " \"schema_version\": 2,\n" + + " \"horizon\": 24,\n" + + " \"history\": 240\n" + + "}"; + + formattedForecaster = String.format(Locale.ROOT, forecasterDef, UNIFORM_DATASET_NAME); + + response = TestHelpers + .makeRequest( + client(), + "POST", + String.format(Locale.ROOT, SUGGEST_INTERVAL_URI), + ImmutableMap.of(), + TestHelpers.toHttpEntity(formattedForecaster), + null + ); + assertEquals("Suggest forecaster interval failed", RestStatus.OK, TestHelpers.restStatus(response)); + responseMap = entityAsMap(response); + assertEquals(0, responseMap.size()); + } + public void testValidate() throws Exception { loadSyntheticData(200); // case 1: forecaster interval is not set diff --git a/src/test/java/org/opensearch/timeseries/AbstractSyntheticDataTest.java b/src/test/java/org/opensearch/timeseries/AbstractSyntheticDataTest.java index c10ada512..405176b44 100644 --- a/src/test/java/org/opensearch/timeseries/AbstractSyntheticDataTest.java +++ b/src/test/java/org/opensearch/timeseries/AbstractSyntheticDataTest.java @@ -17,7 +17,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.NavigableSet; +import java.util.Random; +import java.util.TreeSet; +import org.apache.commons.lang3.tuple.Pair; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.message.BasicHeader; import org.apache.logging.log4j.LogManager; @@ -29,6 +33,7 @@ import org.opensearch.client.WarningsHandler; import org.opensearch.common.xcontent.json.JsonXContent; import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.timeseries.AbstractSyntheticDataTest.MISSING_MODE; import org.opensearch.timeseries.settings.TimeSeriesSettings; import com.google.common.collect.ImmutableList; @@ -39,6 +44,35 @@ import com.google.gson.stream.JsonReader; public class AbstractSyntheticDataTest extends ODFERestTestCase { + public enum MISSING_MODE { + MISSING_TIMESTAMP, // missing all entities in a timestamps + MISSING_ENTITY, // missing single entity, + NO_MISSING_DATA, // no missing data + CONTINUOUS_IMPUTE, // vs random missing as above + } + + public static class GenData { + public List data; + // record missing entities and its timestamp in test data + public NavigableSet> missingEntities; + // record missing timestamps in test data + public NavigableSet missingTimestamps; + public long testStartTime; + + public GenData( + List data, + NavigableSet> missingEntities, + NavigableSet missingTimestamps, + long testStartTime + ) { + super(); + this.data = data; + this.missingEntities = missingEntities; + this.missingTimestamps = missingTimestamps; + this.testStartTime = testStartTime; + } + } + public static final Logger LOG = (Logger) LogManager.getLogger(AbstractSyntheticDataTest.class); public static final String SYNTHETIC_DATA_MAPPING = "{ \"mappings\": { \"properties\": { \"timestamp\": { \"type\": \"date\"}," + " \"Feature1\": { \"type\": \"double\" }, \"Feature2\": { \"type\": \"double\" } } } }"; @@ -49,6 +83,8 @@ public class AbstractSyntheticDataTest extends ODFERestTestCase { + "\"componentName\": { \"type\": \"keyword\"} } } }"; public static final String SYNTHETIC_DATASET_NAME = "synthetic"; public static final String RULE_DATASET_NAME = "rule"; + public static final String UNIFORM_DATASET_NAME = "uniform"; + public static int batchSize = 1000; /** * In real time AD, we mute a node for a detector if that node keeps returning @@ -101,9 +137,7 @@ public static void waitAllSyncheticDataIngested(int expectedSize, String dataset // Make sure all of the test data has been ingested JsonArray hits = getHits(client, request); LOG.info("Latest synthetic data:" + hits); - if (hits != null - && hits.size() == 1 - && expectedSize - 1 == hits.get(0).getAsJsonObject().getAsJsonPrimitive("_id").getAsLong()) { + if (hits != null && hits.size() == 1 && isIdExpected(expectedSize, hits)) { break; } else { request = new Request("POST", String.format(Locale.ROOT, "/%s/_refresh", datasetName)); @@ -113,6 +147,17 @@ public static void waitAllSyncheticDataIngested(int expectedSize, String dataset } while (maxWaitCycles-- >= 0); } + private static boolean isIdExpected(int expectedSize, JsonArray hits) { + // we won't have more than 3 entities with the same timestamp to make the test fast + int delta = 3; + for (int i = 0; i < hits.size(); i++) { + if (expectedSize - 1 <= hits.get(0).getAsJsonObject().getAsJsonPrimitive("_id").getAsLong() + delta) { + return true; + } + } + return false; + } + public static JsonArray getHits(RestClient client, Request request) throws IOException { Response response = client.performRequest(request); return parseHits(response); @@ -247,4 +292,213 @@ public static boolean canBeParsedAsLong(String str) { } } + public static List generateUniformRandomDoubles(int size, double min, double max) { + List randomDoubles = new ArrayList<>(size); + Random random = new Random(0); + + for (int i = 0; i < size; i++) { + double randomValue = min + (max - min) * random.nextDouble(); + randomDoubles.add(randomValue); + } + + return randomDoubles; + } + + protected JsonObject createJsonObject(long timestamp, String component, double dataValue, String categoricalField) { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("timestamp", timestamp); + jsonObject.addProperty(categoricalField, component); + jsonObject.addProperty("data", dataValue); + return jsonObject; + } + + public GenData genUniformSingleFeatureData( + int intervalMinutes, + int trainTestSplit, + int numberOfEntities, + String categoricalField, + MISSING_MODE missingMode, + int continuousImputeStartIndex, + int continuousImputeEndIndex, + List randomDoubles + ) { + List data = new ArrayList<>(); + long currentTime = System.currentTimeMillis(); + long intervalMillis = intervalMinutes * 60000L; + long timestampMillis = currentTime - intervalMillis * trainTestSplit / numberOfEntities; + LOG.info("begin timestamp: {}", timestampMillis); + int entityIndex = 0; + NavigableSet> missingEntities = new TreeSet<>(); + NavigableSet missingTimestamps = new TreeSet<>(); + long testStartTime = 0; + Random random = new Random(); + + for (int i = 0; i < randomDoubles.size();) { + // we won't miss the train time (the first point triggering cold start) + if (timestampMillis > currentTime && testStartTime == 0) { + LOG.info("test start time {}, index {}, current time {}", timestampMillis, data.size(), currentTime); + testStartTime = timestampMillis; + + for (int j = 0; j < numberOfEntities; j++) { + JsonObject jsonObject = createJsonObject( + timestampMillis, + "entity" + entityIndex, + randomDoubles.get(i++), + categoricalField + ); + entityIndex = (entityIndex + 1) % numberOfEntities; + data.add(jsonObject); + } + timestampMillis += intervalMillis; + + continue; + } + + if (shouldSkipDataPoint( + missingMode, + entityIndex, + testStartTime, + timestampMillis, + random, + intervalMillis, + continuousImputeStartIndex, + continuousImputeEndIndex + )) { + if (timestampMillis > currentTime) { + if (missingMode == MISSING_MODE.MISSING_TIMESTAMP || missingMode == MISSING_MODE.CONTINUOUS_IMPUTE) { + missingTimestamps.add(timestampMillis); + } else if (missingMode == MISSING_MODE.MISSING_ENTITY) { + missingEntities.add(Pair.of(timestampMillis, "entity" + entityIndex)); + entityIndex = (entityIndex + 1) % numberOfEntities; + if (entityIndex == 0) { + timestampMillis += intervalMillis; + } + } + } + + if (missingMode == MISSING_MODE.MISSING_TIMESTAMP || missingMode == MISSING_MODE.CONTINUOUS_IMPUTE) { + timestampMillis += intervalMillis; + } + } else { + JsonObject jsonObject = createJsonObject(timestampMillis, "entity" + entityIndex, randomDoubles.get(i), categoricalField); + data.add(jsonObject); + entityIndex = (entityIndex + 1) % numberOfEntities; + if (entityIndex == 0) { + timestampMillis += intervalMillis; + } + } + + i++; + } + LOG + .info( + "begin timestamp: {}, end timestamp: {}", + data.get(0).get("timestamp").getAsLong(), + data.get(data.size() - 1).get("timestamp").getAsLong() + ); + return new GenData(data, missingEntities, missingTimestamps, testStartTime); + } + + public GenData genUniformSingleFeatureData( + int intervalMinutes, + int trainTestSplit, + int numberOfEntities, + String categoricalField, + MISSING_MODE missingMode, + int continuousImputeStartIndex, + int continuousImputeEndIndex, + int dataSize + ) { + List randomDoubles = generateUniformRandomDoubles(dataSize, 200, 300); + + return genUniformSingleFeatureData( + intervalMinutes, + trainTestSplit, + numberOfEntities, + categoricalField, + missingMode, + continuousImputeStartIndex, + continuousImputeEndIndex, + randomDoubles + ); + } + + protected boolean shouldSkipDataPoint( + AbstractSyntheticDataTest.MISSING_MODE missingMode, + int entityIndex, + long testStartTime, + long currentTime, + Random random, + long intervalMillis, + int continuousImputeStartIndex, + int continuousImputeEndIndex + ) { + if (testStartTime == 0 || missingMode == AbstractSyntheticDataTest.MISSING_MODE.NO_MISSING_DATA) { + return false; + } + if (missingMode == AbstractSyntheticDataTest.MISSING_MODE.MISSING_TIMESTAMP && entityIndex == 0) { + return random.nextDouble() > 0.5; + } else if (missingMode == AbstractSyntheticDataTest.MISSING_MODE.MISSING_ENTITY) { + return random.nextDouble() > 0.5; + } else if (missingMode == AbstractSyntheticDataTest.MISSING_MODE.CONTINUOUS_IMPUTE && entityIndex == 0) { + long delta = (currentTime - testStartTime) / intervalMillis; + // start missing in a range + return delta >= continuousImputeStartIndex && delta <= continuousImputeEndIndex; + } + return false; + } + + protected void bulkIndexData(List data, String datasetName, RestClient client, String mapping, int ingestDataSize) + throws Exception { + createIndex(datasetName, client, mapping); + StringBuilder bulkRequestBuilder = new StringBuilder(); + LOG.info("data size {}", data.size()); + int count = 0; + int pickedIngestSize = Math.min(ingestDataSize, data.size()); + LOG.info("ingest size {}", pickedIngestSize); + for (int i = 0; i < pickedIngestSize; i++) { + bulkRequestBuilder.append("{ \"index\" : { \"_index\" : \"" + datasetName + "\", \"_id\" : \"" + i + "\" } }\n"); + bulkRequestBuilder.append(data.get(i).toString()).append("\n"); + count++; + if (count >= batchSize || i == pickedIngestSize - 1) { + count = 0; + TestHelpers + .makeRequest( + client, + "POST", + "_bulk?refresh=true", + null, + toHttpEntity(bulkRequestBuilder.toString()), + ImmutableList.of(new BasicHeader(HttpHeaders.USER_AGENT, "Kibana")) + ); + Thread.sleep(1_000); + } + } + + waitAllSyncheticDataIngested(data.size(), datasetName, client); + LOG.info("data ingestion complete"); + } + + protected void ingestUniformSingleFeatureData(int ingestDataSize, List data, String datasetName, String categoricalField) + throws Exception { + + RestClient client = client(); + + String mapping = String + .format( + Locale.ROOT, + "{ \"mappings\": { \"properties\": { \"timestamp\": { \"type\":" + + "\"date\"" + + "}," + + " \"data\": { \"type\": \"double\" }," + + "\"%s\": { \"type\": \"keyword\"} } } }", + categoricalField + ); + + if (ingestDataSize <= 0) { + bulkIndexData(data, datasetName, client, mapping, data.size()); + } else { + bulkIndexData(data, datasetName, client, mapping, ingestDataSize); + } + } } diff --git a/src/test/java/org/opensearch/timeseries/ODFERestTestCase.java b/src/test/java/org/opensearch/timeseries/ODFERestTestCase.java index eccc2ee53..092ca210d 100644 --- a/src/test/java/org/opensearch/timeseries/ODFERestTestCase.java +++ b/src/test/java/org/opensearch/timeseries/ODFERestTestCase.java @@ -80,6 +80,7 @@ * ODFE integration test base class to support both security disabled and enabled ODFE cluster. */ public abstract class ODFERestTestCase extends OpenSearchRestTestCase { + private static final Logger LOG = (Logger) LogManager.getLogger(ODFERestTestCase.class); protected boolean isHttps() { diff --git a/src/test/java/org/opensearch/timeseries/TestHelpers.java b/src/test/java/org/opensearch/timeseries/TestHelpers.java index e633f2734..88bc10942 100644 --- a/src/test/java/org/opensearch/timeseries/TestHelpers.java +++ b/src/test/java/org/opensearch/timeseries/TestHelpers.java @@ -45,7 +45,6 @@ import java.util.Set; import java.util.concurrent.Callable; import java.util.function.Consumer; -import java.util.stream.DoubleStream; import java.util.stream.IntStream; import org.apache.hc.core5.http.ContentType; @@ -136,7 +135,6 @@ import org.opensearch.search.profile.SearchProfileShardResults; import org.opensearch.search.suggest.Suggest; import org.opensearch.test.ClusterServiceUtils; -import org.opensearch.test.OpenSearchTestCase; import org.opensearch.test.rest.OpenSearchRestTestCase; import org.opensearch.threadpool.ThreadPool; import org.opensearch.timeseries.constant.CommonMessages; @@ -332,7 +330,7 @@ public static AnomalyDetector randomAnomalyDetector( categoryFields, user, null, - TestHelpers.randomImputationOption(features == null ? 0 : (int) features.stream().filter(Feature::getEnabled).count()), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomIntBetween(1, TimeSeriesSettings.MAX_SHINGLE_SIZE * 2), randomIntBetween(1, 1000), @@ -384,7 +382,7 @@ public static AnomalyDetector randomDetector( categoryFields, null, resultIndex, - TestHelpers.randomImputationOption((int) features.stream().filter(Feature::getEnabled).count()), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -428,6 +426,7 @@ public static AnomalyDetector randomAnomalyDetectorUsingCategoryFields( List categoryFields, String resultIndex ) throws IOException { + List features = ImmutableList.of(randomFeature(true)); return new AnomalyDetector( detectorId, randomLong(), @@ -435,7 +434,7 @@ public static AnomalyDetector randomAnomalyDetectorUsingCategoryFields( randomAlphaOfLength(30), timeField, indices, - ImmutableList.of(randomFeature(true)), + features, randomQuery(), randomIntervalTimeConfiguration(), new IntervalTimeConfiguration(0, ChronoUnit.MINUTES), @@ -446,7 +445,7 @@ public static AnomalyDetector randomAnomalyDetectorUsingCategoryFields( categoryFields, randomUser(), resultIndex, - TestHelpers.randomImputationOption(1), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -484,7 +483,7 @@ public static AnomalyDetector randomAnomalyDetector(String timefield, String ind null, randomUser(), null, - TestHelpers.randomImputationOption(features == null ? 0 : (int) features.stream().filter(Feature::getEnabled).count()), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -514,7 +513,7 @@ public static AnomalyDetector randomAnomalyDetectorWithEmptyFeature() throws IOE null, randomUser(), null, - TestHelpers.randomImputationOption(0), + null, randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -532,6 +531,7 @@ public static AnomalyDetector randomAnomalyDetectorWithInterval(TimeConfiguratio public static AnomalyDetector randomAnomalyDetectorWithInterval(TimeConfiguration interval, boolean hcDetector) throws IOException { List categoryField = hcDetector ? ImmutableList.of(randomAlphaOfLength(5)) : null; Feature feature = randomFeature(); + List featureList = ImmutableList.of(feature); return new AnomalyDetector( randomAlphaOfLength(10), randomLong(), @@ -539,7 +539,7 @@ public static AnomalyDetector randomAnomalyDetectorWithInterval(TimeConfiguratio randomAlphaOfLength(30), randomAlphaOfLength(5), ImmutableList.of(randomAlphaOfLength(10).toLowerCase(Locale.ROOT)), - ImmutableList.of(feature), + featureList, randomQuery(), interval, randomIntervalTimeConfiguration(), @@ -550,7 +550,7 @@ public static AnomalyDetector randomAnomalyDetectorWithInterval(TimeConfiguratio categoryField, randomUser(), null, - TestHelpers.randomImputationOption(feature.getEnabled() ? 1 : 0), + TestHelpers.randomImputationOption(featureList), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -588,6 +588,9 @@ public static class AnomalyDetectorBuilder { private ImputationOption imputationOption = null; private List rules = null; + // transform decay (reverse of recencyEmphasis) has to be [0, 1). So we cannot use 1. + private int recencyEmphasis = randomIntBetween(2, 10000); + public static AnomalyDetectorBuilder newInstance(int numberOfFeatures) throws IOException { return new AnomalyDetectorBuilder(numberOfFeatures); } @@ -686,8 +689,8 @@ public AnomalyDetectorBuilder setResultIndex(String resultIndex) { return this; } - public AnomalyDetectorBuilder setImputationOption(ImputationMethod method, Optional defaultFill, boolean integerSentive) { - this.imputationOption = new ImputationOption(method, defaultFill, integerSentive); + public AnomalyDetectorBuilder setImputationOption(ImputationMethod method, Map defaultFill) { + this.imputationOption = new ImputationOption(method, defaultFill); return this; } @@ -696,6 +699,11 @@ public AnomalyDetectorBuilder setRules(List rules) { return this; } + public AnomalyDetectorBuilder setRecencyEmphasis(int recencyEmphasis) { + this.recencyEmphasis = recencyEmphasis; + return this; + } + public AnomalyDetector build() { return new AnomalyDetector( detectorId, @@ -716,8 +724,7 @@ public AnomalyDetector build() { user, resultIndex, imputationOption, - // transform decay has to be [0, 1). So we cannot use 1. - randomIntBetween(2, 10000), + recencyEmphasis, randomIntBetween(1, TimeSeriesSettings.MAX_SHINGLE_SIZE * 2), // make history intervals at least TimeSeriesSettings.NUM_MIN_SAMPLES. // Otherwise, tests like EntityColdStarterTests.testTwoSegments may fail @@ -735,6 +742,7 @@ public AnomalyDetector build() { public static AnomalyDetector randomAnomalyDetectorWithInterval(TimeConfiguration interval, boolean hcDetector, boolean featureEnabled) throws IOException { List categoryField = hcDetector ? ImmutableList.of(randomAlphaOfLength(5)) : null; + List features = ImmutableList.of(randomFeature(featureEnabled)); return new AnomalyDetector( randomAlphaOfLength(10), randomLong(), @@ -742,7 +750,7 @@ public static AnomalyDetector randomAnomalyDetectorWithInterval(TimeConfiguratio randomAlphaOfLength(30), randomAlphaOfLength(5), ImmutableList.of(randomAlphaOfLength(10).toLowerCase(Locale.ROOT)), - ImmutableList.of(randomFeature(featureEnabled)), + features, randomQuery(), interval, randomIntervalTimeConfiguration(), @@ -753,7 +761,7 @@ public static AnomalyDetector randomAnomalyDetectorWithInterval(TimeConfiguratio categoryField, randomUser(), null, - TestHelpers.randomImputationOption(featureEnabled ? 1 : 0), + TestHelpers.randomImputationOption(features), randomIntBetween(1, 10000), randomInt(TimeSeriesSettings.MAX_SHINGLE_SIZE / 2), randomIntBetween(1, 1000), @@ -963,7 +971,8 @@ public static AnomalyResult randomAnomalyDetectResult(double score, String error relavantAttribution, pastValues, expectedValuesList, - randomDoubleBetween(1.1, 10.0, true) + randomDoubleBetween(1.1, 10.0, true), + null ); } @@ -1044,7 +1053,8 @@ public static AnomalyResult randomHCADAnomalyDetectResult( relavantAttribution, pastValues, expectedValuesList, - randomDoubleBetween(1.1, 10.0, true) + randomDoubleBetween(1.1, 10.0, true), + null ); } @@ -1683,15 +1693,34 @@ public static ClusterState createClusterState() { return clusterState; } - public static ImputationOption randomImputationOption(int featureSize) { - double[] defaultFill = DoubleStream.generate(OpenSearchTestCase::randomDouble).limit(featureSize).toArray(); - ImputationOption fixedValue = new ImputationOption(ImputationMethod.FIXED_VALUES, Optional.of(defaultFill), false); - ImputationOption linear = new ImputationOption(ImputationMethod.LINEAR, Optional.of(defaultFill), false); - ImputationOption linearIntSensitive = new ImputationOption(ImputationMethod.LINEAR, Optional.of(defaultFill), true); - ImputationOption zero = new ImputationOption(ImputationMethod.ZERO); - ImputationOption previous = new ImputationOption(ImputationMethod.PREVIOUS); + public static Map randomFixedValue(List features) { + Map map = new HashMap<>(); + if (features == null) { + return map; + } + + Random random = new Random(); + + for (int i = 0; i < features.size(); i++) { + if (features.get(i).getEnabled()) { + double randomValue = random.nextDouble(); // generate a random double value + map.put(features.get(i).getName(), randomValue); + } + } + + return map; + } + + public static ImputationOption randomImputationOption(List features) { + Map randomFixedValue = randomFixedValue(features); + + List options = new ArrayList<>(); + if (randomFixedValue.size() != 0) { + options.add(new ImputationOption(ImputationMethod.FIXED_VALUES, randomFixedValue)); + } - List options = List.of(fixedValue, linear, linearIntSensitive, zero, previous); + options.add(new ImputationOption(ImputationMethod.ZERO)); + options.add(new ImputationOption(ImputationMethod.PREVIOUS)); // Select a random option int randomIndex = Randomness.get().nextInt(options.size()); @@ -1729,7 +1758,7 @@ public static class ForecasterBuilder { description = randomAlphaOfLength(20); timeField = randomAlphaOfLength(5); indices = ImmutableList.of(randomAlphaOfLength(10)); - features = ImmutableList.of(randomFeature()); + features = ImmutableList.of(randomFeature(true)); filterQuery = randomQuery(); forecastInterval = randomIntervalTimeConfiguration(); windowDelay = randomIntervalTimeConfiguration(); @@ -1741,7 +1770,7 @@ public static class ForecasterBuilder { user = randomUser(); resultIndex = null; horizon = randomIntBetween(1, 20); - imputationOption = randomImputationOption((int) features.stream().filter(Feature::getEnabled).count()); + imputationOption = randomImputationOption(features); customResultIndexMinSize = null; customResultIndexMinAge = null; customResultIndexTTL = null; @@ -1894,6 +1923,7 @@ public Forecaster build() { public static Forecaster randomForecaster() throws IOException { Feature feature = randomFeature(); + List featureList = ImmutableList.of(feature); return new Forecaster( randomAlphaOfLength(10), randomLong(), @@ -1901,7 +1931,7 @@ public static Forecaster randomForecaster() throws IOException { randomAlphaOfLength(20), randomAlphaOfLength(5), ImmutableList.of(randomAlphaOfLength(10)), - ImmutableList.of(feature), + featureList, randomQuery(), randomIntervalTimeConfiguration(), randomIntervalTimeConfiguration(), @@ -1913,7 +1943,7 @@ public static Forecaster randomForecaster() throws IOException { randomUser(), null, randomIntBetween(1, 20), - randomImputationOption(feature.getEnabled() ? 1 : 0), + randomImputationOption(featureList), randomIntBetween(1, 1000), randomIntBetween(1, 128), randomIntBetween(1, 1000), diff --git a/src/test/java/org/opensearch/timeseries/dataprocessor/ImputationOptionTests.java b/src/test/java/org/opensearch/timeseries/dataprocessor/ImputationOptionTests.java index 9adb57ed9..df1d4a2e9 100644 --- a/src/test/java/org/opensearch/timeseries/dataprocessor/ImputationOptionTests.java +++ b/src/test/java/org/opensearch/timeseries/dataprocessor/ImputationOptionTests.java @@ -6,8 +6,11 @@ package org.opensearch.timeseries.dataprocessor; import java.io.IOException; -import java.util.Optional; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.junit.BeforeClass; import org.opensearch.common.io.stream.BytesStreamOutput; import org.opensearch.common.xcontent.json.JsonXContent; import org.opensearch.core.common.bytes.BytesReference; @@ -19,14 +22,46 @@ import org.opensearch.core.xcontent.XContentParser; import org.opensearch.test.OpenSearchTestCase; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + public class ImputationOptionTests extends OpenSearchTestCase { + private static ObjectMapper mapper; + private static Map map; + private static String xContent; + + @BeforeClass + public static void setUpOnce() { + mapper = new ObjectMapper(); + double[] defaultFill = { 1.0, 2.0, 3.0 }; + map = new HashMap<>(); + map.put("a", defaultFill[0]); + map.put("b", defaultFill[1]); + map.put("c", defaultFill[2]); + + xContent = "{" + + "\"method\":\"FIXED_VALUES\"," + + "\"defaultFill\":[{\"feature_name\":\"a\", \"data\":1.0},{\"feature_name\":\"b\", \"data\":2.0},{\"feature_name\":\"c\", \"data\":3.0}]}"; + } + + private Map randomMap(double[] defaultFill) { + Map map = new HashMap<>(); + + for (int i = 0; i < defaultFill.length; i++) { + String randomKey = UUID.randomUUID().toString(); // generate a random UUID string as the key + map.put(randomKey, defaultFill[i]); + } + + return map; + } public void testStreamInputAndOutput() throws IOException { // Prepare the data to be read by the StreamInput object. ImputationMethod method = ImputationMethod.PREVIOUS; double[] defaultFill = { 1.0, 2.0, 3.0 }; + Map map1 = randomMap(defaultFill); - ImputationOption option = new ImputationOption(method, Optional.of(defaultFill), false); + ImputationOption option = new ImputationOption(method, map1); // Write the ImputationOption to the StreamOutput. BytesStreamOutput out = new BytesStreamOutput(); @@ -39,26 +74,25 @@ public void testStreamInputAndOutput() throws IOException { // Check that the created ImputationOption has the correct values. assertEquals(method, inOption.getMethod()); - assertArrayEquals(defaultFill, inOption.getDefaultFill().get(), 1e-6); + assertEquals(map1, inOption.getDefaultFill()); } public void testToXContent() throws IOException { - double[] defaultFill = { 1.0, 2.0, 3.0 }; - ImputationOption imputationOption = new ImputationOption(ImputationMethod.FIXED_VALUES, Optional.of(defaultFill), false); - String xContent = "{" + "\"method\":\"FIXED_VALUES\"," + "\"defaultFill\":[1.0,2.0,3.0],\"integerSensitive\":false" + "}"; + ImputationOption imputationOption = new ImputationOption(ImputationMethod.FIXED_VALUES, map); XContentBuilder builder = imputationOption.toXContent(JsonXContent.contentBuilder(), ToXContent.EMPTY_PARAMS); String actualJson = BytesReference.bytes(builder).utf8ToString(); - assertEquals(xContent, actualJson); + JsonNode expectedTree = mapper.readTree(xContent); + JsonNode actualTree = mapper.readTree(actualJson); + + assertEquals(expectedTree, actualTree); } public void testParse() throws IOException { - String xContent = "{" + "\"method\":\"FIXED_VALUES\"," + "\"defaultFill\":[1.0,2.0,3.0],\"integerSensitive\":false" + "}"; - double[] defaultFill = { 1.0, 2.0, 3.0 }; - ImputationOption imputationOption = new ImputationOption(ImputationMethod.FIXED_VALUES, Optional.of(defaultFill), false); + ImputationOption imputationOption = new ImputationOption(ImputationMethod.FIXED_VALUES, map); try ( XContentParser parser = JsonXContent.jsonXContent @@ -73,22 +107,24 @@ public void testParse() throws IOException { ImputationOption parsedOption = ImputationOption.parse(parser); assertEquals(imputationOption.getMethod(), parsedOption.getMethod()); - assertTrue(imputationOption.getDefaultFill().isPresent()); - assertTrue(parsedOption.getDefaultFill().isPresent()); - assertEquals(imputationOption.getDefaultFill().get().length, parsedOption.getDefaultFill().get().length); - for (int i = 0; i < imputationOption.getDefaultFill().get().length; i++) { - assertEquals(imputationOption.getDefaultFill().get()[i], parsedOption.getDefaultFill().get()[i], 0); - } + assertTrue(imputationOption.getDefaultFill().size() > 0); + assertTrue(parsedOption.getDefaultFill().size() > 0); + + // The assertEquals method checks if the two maps are equal. The Map interface's equals method ensures that + // the maps are considered equal if they contain the same key-value pairs, regardless of the order in which + // they were inserted. + assertEquals(imputationOption.getDefaultFill(), parsedOption.getDefaultFill()); } } public void testEqualsAndHashCode() { double[] defaultFill1 = { 1.0, 2.0, 3.0 }; - double[] defaultFill2 = { 4.0, 5.0, 6.0 }; - ImputationOption option1 = new ImputationOption(ImputationMethod.FIXED_VALUES, Optional.of(defaultFill1), false); - ImputationOption option2 = new ImputationOption(ImputationMethod.FIXED_VALUES, Optional.of(defaultFill1), false); - ImputationOption option3 = new ImputationOption(ImputationMethod.LINEAR, Optional.of(defaultFill2), false); + Map map1 = randomMap(defaultFill1); + + ImputationOption option1 = new ImputationOption(ImputationMethod.FIXED_VALUES, map1); + ImputationOption option2 = new ImputationOption(ImputationMethod.FIXED_VALUES, map1); + ImputationOption option3 = new ImputationOption(ImputationMethod.PREVIOUS); // Test reflexivity assertTrue(option1.equals(option1)); @@ -98,7 +134,7 @@ public void testEqualsAndHashCode() { assertTrue(option2.equals(option1)); // Test transitivity - ImputationOption option2Clone = new ImputationOption(ImputationMethod.FIXED_VALUES, Optional.of(defaultFill1), false); + ImputationOption option2Clone = new ImputationOption(ImputationMethod.FIXED_VALUES, map1); assertTrue(option1.equals(option2)); assertTrue(option2.equals(option2Clone)); assertTrue(option1.equals(option2Clone)); diff --git a/src/test/java/org/opensearch/timeseries/feature/NoPowermockSearchFeatureDaoTests.java b/src/test/java/org/opensearch/timeseries/feature/NoPowermockSearchFeatureDaoTests.java index 47118ca8a..836f0eafa 100644 --- a/src/test/java/org/opensearch/timeseries/feature/NoPowermockSearchFeatureDaoTests.java +++ b/src/test/java/org/opensearch/timeseries/feature/NoPowermockSearchFeatureDaoTests.java @@ -643,7 +643,7 @@ public void testParseBuckets() throws InstantiationException, true ); - Optional parsedResult = searchFeatureDao.parseBucket(bucket, Arrays.asList(featureId)); + Optional parsedResult = searchFeatureDao.parseBucket(bucket, Arrays.asList(featureId), false); assertTrue(parsedResult.isPresent()); double[] parsedCardinality = parsedResult.get(); diff --git a/src/test/java/org/opensearch/timeseries/feature/SearchFeatureDaoParamTests.java b/src/test/java/org/opensearch/timeseries/feature/SearchFeatureDaoParamTests.java index 6868a3b9f..c9cdd44cb 100644 --- a/src/test/java/org/opensearch/timeseries/feature/SearchFeatureDaoParamTests.java +++ b/src/test/java/org/opensearch/timeseries/feature/SearchFeatureDaoParamTests.java @@ -267,11 +267,15 @@ private Object[] getFeaturesForPeriodData() { return new Object[] { new Object[] { asList(max), asList(maxName), new double[] { maxValue }, }, new Object[] { asList(percentiles), asList(percentileName), new double[] { percentileValue } }, - new Object[] { asList(missing), asList(missingName), null }, - new Object[] { asList(infinity), asList(infinityName), null }, + // we keep missing data + new Object[] { asList(missing), asList(missingName), new double[] { Double.NaN } }, + new Object[] { asList(infinity), asList(infinityName), new double[] { Double.NaN } }, new Object[] { asList(max, percentiles), asList(maxName, percentileName), new double[] { maxValue, percentileValue } }, new Object[] { asList(max, percentiles), asList(percentileName, maxName), new double[] { percentileValue, maxValue } }, - new Object[] { asList(max, percentiles, missing), asList(maxName, percentileName, missingName), null }, }; + new Object[] { + asList(max, percentiles, missing), + asList(maxName, percentileName, missingName), + new double[] { maxValue, percentileValue, Double.NaN } }, }; } private Object[] getFeaturesForSampledPeriodsData() { diff --git a/src/test/java/org/opensearch/timeseries/indices/rest/handler/IntervalCalculationTests.java b/src/test/java/org/opensearch/timeseries/indices/rest/handler/IntervalCalculationTests.java new file mode 100644 index 000000000..96ed15438 --- /dev/null +++ b/src/test/java/org/opensearch/timeseries/indices/rest/handler/IntervalCalculationTests.java @@ -0,0 +1,143 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.timeseries.indices.rest.handler; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.Map; + +import org.apache.lucene.search.TotalHits; +import org.junit.Before; +import org.mockito.ArgumentCaptor; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.search.SearchResponseSections; +import org.opensearch.action.search.ShardSearchFailure; +import org.opensearch.client.Client; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.commons.authuser.User; +import org.opensearch.core.action.ActionListener; +import org.opensearch.search.SearchHit; +import org.opensearch.search.SearchHits; +import org.opensearch.search.aggregations.Aggregations; +import org.opensearch.search.aggregations.bucket.histogram.Histogram; +import org.opensearch.search.aggregations.bucket.histogram.LongBounds; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.timeseries.AnalysisType; +import org.opensearch.timeseries.TestHelpers; +import org.opensearch.timeseries.common.exception.ValidationException; +import org.opensearch.timeseries.constant.CommonMessages; +import org.opensearch.timeseries.feature.SearchFeatureDao; +import org.opensearch.timeseries.model.Config; +import org.opensearch.timeseries.model.IntervalTimeConfiguration; +import org.opensearch.timeseries.model.ValidationAspect; +import org.opensearch.timeseries.model.ValidationIssueType; +import org.opensearch.timeseries.rest.handler.AggregationPrep; +import org.opensearch.timeseries.rest.handler.IntervalCalculation; +import org.opensearch.timeseries.rest.handler.IntervalCalculation.IntervalRecommendationListener; +import org.opensearch.timeseries.util.SecurityClientUtil; + +public class IntervalCalculationTests extends OpenSearchTestCase { + + private IntervalCalculation intervalCalculation; + private Clock clock; + private ActionListener mockIntervalListener; + private AggregationPrep mockAggregationPrep; + private Client mockClient; + private SecurityClientUtil mockClientUtil; + private User user; + private Map mockTopEntity; + private IntervalTimeConfiguration mockIntervalConfig; + private LongBounds mockLongBounds; + private Config mockConfig; + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()); + mockIntervalListener = mock(ActionListener.class); + mockAggregationPrep = mock(AggregationPrep.class); + mockClient = mock(Client.class); + mockClientUtil = mock(SecurityClientUtil.class); + user = TestHelpers.randomUser(); + mockTopEntity = mock(Map.class); + mockIntervalConfig = mock(IntervalTimeConfiguration.class); + mockLongBounds = mock(LongBounds.class); + mockConfig = mock(Config.class); + + intervalCalculation = new IntervalCalculation( + mockConfig, + mock(TimeValue.class), + mockClient, + mockClientUtil, + user, + AnalysisType.AD, + clock, + mock(SearchFeatureDao.class), + System.currentTimeMillis(), + mockTopEntity + ); + } + + public void testOnResponseExpirationEpochMsPassed() { + long expirationEpochMs = clock.millis() - 1000; // Expired 1 second ago + + IntervalRecommendationListener listener = intervalCalculation.new IntervalRecommendationListener( + mockIntervalListener, new SearchSourceBuilder(), mockIntervalConfig, expirationEpochMs, mockLongBounds + ); + + Histogram histogram = mock(Histogram.class); + when(histogram.getName()).thenReturn(AggregationPrep.AGGREGATION); + Aggregations aggs = new Aggregations(Arrays.asList(histogram)); + SearchResponseSections sections = new SearchResponseSections( + new SearchHits(new SearchHit[0], new TotalHits(0, TotalHits.Relation.EQUAL_TO), 0), + aggs, + null, + false, + null, + null, + 1 + ); + listener.onResponse(new SearchResponse(sections, null, 0, 0, 0, 0L, ShardSearchFailure.EMPTY_ARRAY, SearchResponse.Clusters.EMPTY)); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ValidationException.class); + verify(mockIntervalListener).onFailure(argumentCaptor.capture()); + ValidationException validationException = argumentCaptor.getValue(); + assertEquals(CommonMessages.TIMEOUT_ON_INTERVAL_REC, validationException.getMessage()); + assertEquals(ValidationIssueType.TIMEOUT, validationException.getType()); + assertEquals(ValidationAspect.MODEL, validationException.getAspect()); + } + + /** + * AggregationPrep.validateAndRetrieveHistogramAggregation throws ValidationException because + * response.getAggregations() returns null. + */ + public void testOnFailure() { + long expirationEpochMs = clock.millis() - 1000; // Expired 1 second ago + SearchResponse mockResponse = mock(SearchResponse.class); + + when(mockConfig.getHistoryIntervals()).thenReturn(40); + + IntervalRecommendationListener listener = intervalCalculation.new IntervalRecommendationListener( + mockIntervalListener, new SearchSourceBuilder(), mockIntervalConfig, expirationEpochMs, mockLongBounds + ); + + listener.onResponse(mockResponse); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(ValidationException.class); + verify(mockIntervalListener).onFailure(argumentCaptor.capture()); + ValidationException validationException = argumentCaptor.getValue(); + assertEquals(CommonMessages.MODEL_VALIDATION_FAILED_UNEXPECTEDLY, validationException.getMessage()); + assertEquals(ValidationIssueType.AGGREGATION, validationException.getType()); + assertEquals(ValidationAspect.MODEL, validationException.getAspect()); + } +} diff --git a/src/test/java/org/opensearch/timeseries/transport/AnomalyDetectorJobTransportActionTests.java b/src/test/java/org/opensearch/timeseries/transport/AnomalyDetectorJobTransportActionTests.java index 126d18492..3ea8d0fec 100644 --- a/src/test/java/org/opensearch/timeseries/transport/AnomalyDetectorJobTransportActionTests.java +++ b/src/test/java/org/opensearch/timeseries/transport/AnomalyDetectorJobTransportActionTests.java @@ -205,7 +205,7 @@ public void testStartHistoricalAnalysisForMultiCategoryHCWithUser() throws IOExc waitUntil(() -> { try { ADTask task = getADTask(taskId); - return !TestHelpers.HISTORICAL_ANALYSIS_RUNNING_STATS.contains(task.getState()); + return HISTORICAL_ANALYSIS_FINISHED_FAILED_STATS.contains(task.getState()); } catch (IOException e) { return false; } diff --git a/src/test/java/org/opensearch/timeseries/transport/CronTransportActionTests.java b/src/test/java/org/opensearch/timeseries/transport/CronTransportActionTests.java index dd6e66ab9..7939e522c 100644 --- a/src/test/java/org/opensearch/timeseries/transport/CronTransportActionTests.java +++ b/src/test/java/org/opensearch/timeseries/transport/CronTransportActionTests.java @@ -90,7 +90,6 @@ public void setUp() throws Exception { actionFilters, tarnsportStatemanager, modelManager, - featureManager, cacheProvider, forecastCacheProvider, entityColdStarter,