├── .gitattributes ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .gitmodules ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── NOTICE.txt ├── README.md ├── Rakefile ├── ext └── midas │ ├── ext.cpp │ ├── extconf.rb │ └── numo.hpp ├── lib ├── midas-edge.rb └── midas │ ├── detector.rb │ └── version.rb ├── midas-edge.gemspec └── test ├── midas_test.rb └── test_helper.rb /.gitattributes: -------------------------------------------------------------------------------- 1 | ext/midas/numo.hpp linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | os: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | submodules: true 14 | - uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: 3.4 17 | bundler-cache: true 18 | - run: bundle exec rake compile 19 | - run: bundle exec rake test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | *.lock 10 | *.bundle 11 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor/MIDAS"] 2 | path = vendor/MIDAS 3 | url = https://github.com/Stream-AD/MIDAS 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.5.0 (2025-04-03) 2 | 3 | - Dropped support for Ruby < 3.2 4 | 5 | ## 0.4.0 (2023-05-11) 6 | 7 | - Dropped support for Ruby < 3 8 | 9 | ## 0.3.3 (2022-12-28) 10 | 11 | - Fixed installation error on Mac 12 | 13 | ## 0.3.2 (2021-06-08) 14 | 15 | - Fixed installation error 16 | 17 | ## 0.3.1 (2021-05-23) 18 | 19 | - Improved performance 20 | 21 | ## 0.3.0 (2021-05-17) 22 | 23 | - Updated to Rice 4 24 | - Dropped support for Ruby < 2.6 25 | 26 | ## 0.2.3 (2020-11-17) 27 | 28 | - Updated MIDAS to 1.1.2 29 | 30 | ## 0.2.2 (2020-09-23) 31 | 32 | - Updated MIDAS to 1.1.0 33 | 34 | ## 0.2.1 (2020-06-17) 35 | 36 | - Fixed installation (missing header files) 37 | 38 | ## 0.2.0 (2020-06-17) 39 | 40 | - Updated MIDAS to 1.0.0 41 | - Added `threshold` option 42 | - Added `seed` option 43 | - Changed default `alpha` to 0.5 44 | - Fixed reading data from files with `directed: false` 45 | 46 | ## 0.1.1 (2020-02-19) 47 | 48 | - Fixed installation on Linux 49 | 50 | ## 0.1.0 (2020-02-17) 51 | 52 | - First release 53 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "rake-compiler" 7 | gem "minitest", ">= 5" 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2020 Rui Liu (liurui39660) and Siddharth Bhatia (bhatiasiddharth) 2 | Copyright 2020-2023 Andrew Kane 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MIDAS Ruby 2 | 3 | [MIDAS](https://github.com/bhatiasiddharth/MIDAS) - edge stream anomaly detection - for Ruby 4 | 5 | [![Build Status](https://github.com/ankane/midas-ruby/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/midas-ruby/actions) 6 | 7 | ## Installation 8 | 9 | Add this line to your application’s Gemfile: 10 | 11 | ```ruby 12 | gem "midas-edge" 13 | ``` 14 | 15 | ## Getting Started 16 | 17 | Prep your data in the format `[source, destination, time]` (all integers) and sorted by time (ascending) 18 | 19 | ```ruby 20 | data = [ 21 | [2, 3, 1], 22 | [3, 4, 2], 23 | [5, 9, 2], 24 | [2, 3, 3] 25 | ] 26 | ``` 27 | 28 | Get anomaly scores 29 | 30 | ```ruby 31 | midas = Midas.new 32 | scores = midas.fit_predict(data) 33 | ``` 34 | 35 | Higher scores are more anomalous. There is [not currently](https://github.com/bhatiasiddharth/MIDAS/issues/4) a defined threshold for anomalies. 36 | 37 | ## Parameters 38 | 39 | Pass parameters - default values below 40 | 41 | ```ruby 42 | Midas.new( 43 | rows: 2, # number of hash functions 44 | buckets: 769, # number of buckets 45 | alpha: 0.5, # temporal decay factor 46 | threshold: nil, # todo 47 | relations: true, # whether to use MIDAS-R or MIDAS 48 | directed: true, # treat the graph as directed or undirected 49 | seed: 0 # random seed 50 | ) 51 | ``` 52 | 53 | ## Data 54 | 55 | Data can be an array of arrays 56 | 57 | ```ruby 58 | [[1, 2, 3], [4, 5, 6]] 59 | ``` 60 | 61 | Or a Numo array 62 | 63 | ```ruby 64 | Numo::NArray.cast([[1, 2, 3], [4, 5, 6]]) 65 | ``` 66 | 67 | ## Performance 68 | 69 | For large datasets, read data directly from files 70 | 71 | ```ruby 72 | midas.fit_predict("data.csv") 73 | ``` 74 | 75 | ## Resources 76 | 77 | - [MIDAS: Microcluster-Based Detector of Anomalies in Edge Streams](https://www.comp.nus.edu.sg/~sbhatia/assets/pdf/midas.pdf) 78 | 79 | ## History 80 | 81 | View the [changelog](https://github.com/ankane/midas-ruby/blob/master/CHANGELOG.md) 82 | 83 | ## Contributing 84 | 85 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 86 | 87 | - [Report bugs](https://github.com/ankane/midas-ruby/issues) 88 | - Fix bugs and [submit pull requests](https://github.com/ankane/midas-ruby/pulls) 89 | - Write, clarify, or fix documentation 90 | - Suggest or add new features 91 | 92 | To get started with development: 93 | 94 | ```sh 95 | git clone --recursive https://github.com/ankane/midas-ruby.git 96 | cd midas-ruby 97 | bundle install 98 | bundle exec rake compile 99 | bundle exec rake test 100 | ``` 101 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | require "rake/extensiontask" 4 | 5 | task default: :test 6 | Rake::TestTask.new do |t| 7 | t.libs << "test" 8 | t.pattern = "test/**/*_test.rb" 9 | end 10 | 11 | Rake::ExtensionTask.new("midas") do |ext| 12 | ext.name = "ext" 13 | ext.lib_dir = "lib/midas" 14 | end 15 | 16 | task :remove_ext do 17 | path = "lib/midas/ext.bundle" 18 | File.unlink(path) if File.exist?(path) 19 | end 20 | 21 | Rake::Task["build"].enhance [:remove_ext] 22 | -------------------------------------------------------------------------------- /ext/midas/ext.cpp: -------------------------------------------------------------------------------- 1 | // stdlib 2 | #include 3 | #include 4 | 5 | // midas 6 | #include 7 | #include 8 | #include 9 | 10 | // rice 11 | #include 12 | #include 13 | 14 | // numo 15 | #include "numo.hpp" 16 | 17 | void load_array(std::vector& src, std::vector& dst, std::vector& times, numo::Int32 input, bool directed) { 18 | auto shape = input.shape(); 19 | if (input.ndim() != 2 || shape[1] != 3) { 20 | throw Rice::Exception(rb_eArgError, "Bad shape"); 21 | } 22 | 23 | auto input_ptr = input.read_ptr(); 24 | auto n = shape[0]; 25 | auto sz = input.size(); 26 | 27 | if (directed) { 28 | src.reserve(n); 29 | dst.reserve(n); 30 | times.reserve(n); 31 | 32 | for (size_t i = 0; i < sz; i += 3) { 33 | src.push_back(input_ptr[i]); 34 | dst.push_back(input_ptr[i + 1]); 35 | times.push_back(input_ptr[i + 2]); 36 | } 37 | } else { 38 | src.reserve(n * 2); 39 | dst.reserve(n * 2); 40 | times.reserve(n * 2); 41 | 42 | for (size_t i = 0; i < sz; i += 3) { 43 | src.push_back(input_ptr[i]); 44 | dst.push_back(input_ptr[i + 1]); 45 | times.push_back(input_ptr[i + 2]); 46 | 47 | src.push_back(input_ptr[i + 1]); 48 | dst.push_back(input_ptr[i]); 49 | times.push_back(input_ptr[i + 2]); 50 | } 51 | } 52 | } 53 | 54 | // load_data from main.cpp 55 | // modified to throw std::runtime_error when cannot find file 56 | // instead of exiting 57 | void load_file(std::vector& src, std::vector& dst, std::vector& times, Rice::String input_file, bool directed) { 58 | FILE* infile = fopen(input_file.c_str(), "r"); 59 | if (infile == NULL) { 60 | throw std::runtime_error("Could not read file: " + input_file.str()); 61 | } 62 | 63 | int s, d, t; 64 | 65 | if (directed) { 66 | while (fscanf(infile, "%d,%d,%d", &s, &d, &t) == 3) { 67 | src.push_back(s); 68 | dst.push_back(d); 69 | times.push_back(t); 70 | } 71 | } else { 72 | while (fscanf(infile, "%d,%d,%d", &s, &d, &t) == 3) { 73 | src.push_back(s); 74 | dst.push_back(d); 75 | times.push_back(t); 76 | 77 | src.push_back(d); 78 | dst.push_back(s); 79 | times.push_back(t); 80 | } 81 | } 82 | 83 | fclose(infile); 84 | } 85 | 86 | Rice::Object fit_predict(std::vector& src, std::vector& dst, std::vector& times, int num_rows, int num_buckets, float factor, float threshold, bool relations, int seed) { 87 | srand(seed); 88 | size_t n = src.size(); 89 | 90 | auto ary = numo::SFloat({n}); 91 | auto result = ary.write_ptr(); 92 | 93 | if (!std::isnan(threshold)) { 94 | MIDAS::FilteringCore midas(num_rows, num_buckets, threshold, factor); 95 | for (size_t i = 0; i < n; i++) { 96 | result[i] = midas(src[i], dst[i], times[i]); 97 | } 98 | } else if (relations) { 99 | MIDAS::RelationalCore midas(num_rows, num_buckets, factor); 100 | for (size_t i = 0; i < n; i++) { 101 | result[i] = midas(src[i], dst[i], times[i]); 102 | } 103 | } else { 104 | MIDAS::NormalCore midas(num_rows, num_buckets); 105 | for (size_t i = 0; i < n; i++) { 106 | result[i] = midas(src[i], dst[i], times[i]); 107 | } 108 | } 109 | 110 | return ary; 111 | } 112 | 113 | extern "C" 114 | void Init_ext() { 115 | auto rb_mMidas = Rice::define_module("Midas"); 116 | 117 | Rice::define_class_under(rb_mMidas, "Detector") 118 | .define_function( 119 | "_fit_predict", 120 | [](Rice::Object input, int num_rows, int num_buckets, float factor, float threshold, bool relations, bool directed, int seed) { 121 | std::vector src, dst, times; 122 | if (input.is_a(rb_cString)) { 123 | load_file(src, dst, times, input, directed); 124 | } else { 125 | load_array(src, dst, times, input, directed); 126 | } 127 | return fit_predict(src, dst, times, num_rows, num_buckets, factor, threshold, relations, seed); 128 | }); 129 | } 130 | -------------------------------------------------------------------------------- /ext/midas/extconf.rb: -------------------------------------------------------------------------------- 1 | require "mkmf-rice" 2 | require "numo/narray" 3 | 4 | $CXXFLAGS << " -std=c++17 $(optflags)" 5 | 6 | numo = File.join(Gem.loaded_specs["numo-narray"].require_path, "numo") 7 | abort "Numo header not found" unless find_header("numo/narray.h", numo) 8 | abort "Numo library not found" if Gem.win_platform? && !find_library("narray", nil, numo) 9 | 10 | # for https://bugs.ruby-lang.org/issues/19005 11 | $LDFLAGS += " -Wl,-undefined,dynamic_lookup" if RbConfig::CONFIG["host_os"] =~ /darwin/i 12 | 13 | midas = File.expand_path("../../vendor/MIDAS/src", __dir__) 14 | abort "Midas not found" unless find_header("NormalCore.hpp", midas) 15 | 16 | create_makefile("midas/ext") 17 | -------------------------------------------------------------------------------- /ext/midas/numo.hpp: -------------------------------------------------------------------------------- 1 | /*! 2 | * Numo.hpp v0.1.0 3 | * https://github.com/ankane/numo.hpp 4 | * BSD-2-Clause License 5 | */ 6 | 7 | #pragma once 8 | 9 | #include 10 | #include 11 | 12 | namespace numo { 13 | class NArray { 14 | public: 15 | NArray(VALUE v) { 16 | construct_value(this->dtype(), v); 17 | } 18 | 19 | NArray(Rice::Object o) { 20 | construct_value(this->dtype(), o.value()); 21 | } 22 | 23 | VALUE value() const { 24 | return this->_value; 25 | } 26 | 27 | size_t ndim() { 28 | return RNARRAY_NDIM(this->_value); 29 | } 30 | 31 | size_t* shape() { 32 | return RNARRAY_SHAPE(this->_value); 33 | } 34 | 35 | size_t size() { 36 | return RNARRAY_SIZE(this->_value); 37 | } 38 | 39 | bool is_contiguous() { 40 | return nary_check_contiguous(this->_value) == Qtrue; 41 | } 42 | 43 | operator Rice::Object() const { 44 | return Rice::Object(this->_value); 45 | } 46 | 47 | const void* read_ptr() { 48 | if (!is_contiguous()) { 49 | this->_value = nary_dup(this->_value); 50 | } 51 | return nary_get_pointer_for_read(this->_value) + nary_get_offset(this->_value); 52 | } 53 | 54 | void* write_ptr() { 55 | return nary_get_pointer_for_write(this->_value); 56 | } 57 | 58 | protected: 59 | NArray() { } 60 | 61 | void construct_value(VALUE dtype, VALUE v) { 62 | this->_value = rb_funcall(dtype, rb_intern("cast"), 1, v); 63 | } 64 | 65 | void construct_shape(VALUE dtype, std::initializer_list shape) { 66 | // rb_narray_new doesn't modify shape, but not marked as const 67 | this->_value = rb_narray_new(dtype, shape.size(), const_cast(shape.begin())); 68 | } 69 | 70 | VALUE _value; 71 | 72 | private: 73 | VALUE dtype() { 74 | return numo_cNArray; 75 | } 76 | }; 77 | 78 | class SFloat: public NArray { 79 | public: 80 | SFloat(VALUE v) { 81 | construct_value(this->dtype(), v); 82 | } 83 | 84 | SFloat(Rice::Object o) { 85 | construct_value(this->dtype(), o.value()); 86 | } 87 | 88 | SFloat(std::initializer_list shape) { 89 | construct_shape(this->dtype(), shape); 90 | } 91 | 92 | const float* read_ptr() { 93 | return reinterpret_cast(NArray::read_ptr()); 94 | } 95 | 96 | float* write_ptr() { 97 | return reinterpret_cast(NArray::write_ptr()); 98 | } 99 | 100 | private: 101 | VALUE dtype() { 102 | return numo_cSFloat; 103 | } 104 | }; 105 | 106 | class DFloat: public NArray { 107 | public: 108 | DFloat(VALUE v) { 109 | construct_value(this->dtype(), v); 110 | } 111 | 112 | DFloat(Rice::Object o) { 113 | construct_value(this->dtype(), o.value()); 114 | } 115 | 116 | DFloat(std::initializer_list shape) { 117 | construct_shape(this->dtype(), shape); 118 | } 119 | 120 | const double* read_ptr() { 121 | return reinterpret_cast(NArray::read_ptr()); 122 | } 123 | 124 | double* write_ptr() { 125 | return reinterpret_cast(NArray::write_ptr()); 126 | } 127 | 128 | private: 129 | VALUE dtype() { 130 | return numo_cDFloat; 131 | } 132 | }; 133 | 134 | class Int8: public NArray { 135 | public: 136 | Int8(VALUE v) { 137 | construct_value(this->dtype(), v); 138 | } 139 | 140 | Int8(Rice::Object o) { 141 | construct_value(this->dtype(), o.value()); 142 | } 143 | 144 | Int8(std::initializer_list shape) { 145 | construct_shape(this->dtype(), shape); 146 | } 147 | 148 | const int8_t* read_ptr() { 149 | return reinterpret_cast(NArray::read_ptr()); 150 | } 151 | 152 | int8_t* write_ptr() { 153 | return reinterpret_cast(NArray::write_ptr()); 154 | } 155 | 156 | private: 157 | VALUE dtype() { 158 | return numo_cInt8; 159 | } 160 | }; 161 | 162 | class Int16: public NArray { 163 | public: 164 | Int16(VALUE v) { 165 | construct_value(this->dtype(), v); 166 | } 167 | 168 | Int16(Rice::Object o) { 169 | construct_value(this->dtype(), o.value()); 170 | } 171 | 172 | Int16(std::initializer_list shape) { 173 | construct_shape(this->dtype(), shape); 174 | } 175 | 176 | const int16_t* read_ptr() { 177 | return reinterpret_cast(NArray::read_ptr()); 178 | } 179 | 180 | int16_t* write_ptr() { 181 | return reinterpret_cast(NArray::write_ptr()); 182 | } 183 | 184 | private: 185 | VALUE dtype() { 186 | return numo_cInt16; 187 | } 188 | }; 189 | 190 | class Int32: public NArray { 191 | public: 192 | Int32(VALUE v) { 193 | construct_value(this->dtype(), v); 194 | } 195 | 196 | Int32(Rice::Object o) { 197 | construct_value(this->dtype(), o.value()); 198 | } 199 | 200 | Int32(std::initializer_list shape) { 201 | construct_shape(this->dtype(), shape); 202 | } 203 | 204 | const int32_t* read_ptr() { 205 | return reinterpret_cast(NArray::read_ptr()); 206 | } 207 | 208 | int32_t* write_ptr() { 209 | return reinterpret_cast(NArray::write_ptr()); 210 | } 211 | 212 | private: 213 | VALUE dtype() { 214 | return numo_cInt32; 215 | } 216 | }; 217 | 218 | class Int64: public NArray { 219 | public: 220 | Int64(VALUE v) { 221 | construct_value(this->dtype(), v); 222 | } 223 | 224 | Int64(Rice::Object o) { 225 | construct_value(this->dtype(), o.value()); 226 | } 227 | 228 | Int64(std::initializer_list shape) { 229 | construct_shape(this->dtype(), shape); 230 | } 231 | 232 | const int64_t* read_ptr() { 233 | return reinterpret_cast(NArray::read_ptr()); 234 | } 235 | 236 | int64_t* write_ptr() { 237 | return reinterpret_cast(NArray::write_ptr()); 238 | } 239 | 240 | private: 241 | VALUE dtype() { 242 | return numo_cInt64; 243 | } 244 | }; 245 | 246 | class UInt8: public NArray { 247 | public: 248 | UInt8(VALUE v) { 249 | construct_value(this->dtype(), v); 250 | } 251 | 252 | UInt8(Rice::Object o) { 253 | construct_value(this->dtype(), o.value()); 254 | } 255 | 256 | UInt8(std::initializer_list shape) { 257 | construct_shape(this->dtype(), shape); 258 | } 259 | 260 | const uint8_t* read_ptr() { 261 | return reinterpret_cast(NArray::read_ptr()); 262 | } 263 | 264 | uint8_t* write_ptr() { 265 | return reinterpret_cast(NArray::write_ptr()); 266 | } 267 | 268 | private: 269 | VALUE dtype() { 270 | return numo_cUInt8; 271 | } 272 | }; 273 | 274 | class UInt16: public NArray { 275 | public: 276 | UInt16(VALUE v) { 277 | construct_value(this->dtype(), v); 278 | } 279 | 280 | UInt16(Rice::Object o) { 281 | construct_value(this->dtype(), o.value()); 282 | } 283 | 284 | UInt16(std::initializer_list shape) { 285 | construct_shape(this->dtype(), shape); 286 | } 287 | 288 | const uint16_t* read_ptr() { 289 | return reinterpret_cast(NArray::read_ptr()); 290 | } 291 | 292 | uint16_t* write_ptr() { 293 | return reinterpret_cast(NArray::write_ptr()); 294 | } 295 | 296 | private: 297 | VALUE dtype() { 298 | return numo_cUInt16; 299 | } 300 | }; 301 | 302 | class UInt32: public NArray { 303 | public: 304 | UInt32(VALUE v) { 305 | construct_value(this->dtype(), v); 306 | } 307 | 308 | UInt32(Rice::Object o) { 309 | construct_value(this->dtype(), o.value()); 310 | } 311 | 312 | UInt32(std::initializer_list shape) { 313 | construct_shape(this->dtype(), shape); 314 | } 315 | 316 | const uint32_t* read_ptr() { 317 | return reinterpret_cast(NArray::read_ptr()); 318 | } 319 | 320 | uint32_t* write_ptr() { 321 | return reinterpret_cast(NArray::write_ptr()); 322 | } 323 | 324 | private: 325 | VALUE dtype() { 326 | return numo_cUInt32; 327 | } 328 | }; 329 | 330 | class UInt64: public NArray { 331 | public: 332 | UInt64(VALUE v) { 333 | construct_value(this->dtype(), v); 334 | } 335 | 336 | UInt64(Rice::Object o) { 337 | construct_value(this->dtype(), o.value()); 338 | } 339 | 340 | UInt64(std::initializer_list shape) { 341 | construct_shape(this->dtype(), shape); 342 | } 343 | 344 | const uint64_t* read_ptr() { 345 | return reinterpret_cast(NArray::read_ptr()); 346 | } 347 | 348 | uint64_t* write_ptr() { 349 | return reinterpret_cast(NArray::write_ptr()); 350 | } 351 | 352 | private: 353 | VALUE dtype() { 354 | return numo_cUInt64; 355 | } 356 | }; 357 | 358 | class SComplex: public NArray { 359 | public: 360 | SComplex(VALUE v) { 361 | construct_value(this->dtype(), v); 362 | } 363 | 364 | SComplex(Rice::Object o) { 365 | construct_value(this->dtype(), o.value()); 366 | } 367 | 368 | SComplex(std::initializer_list shape) { 369 | construct_shape(this->dtype(), shape); 370 | } 371 | 372 | private: 373 | VALUE dtype() { 374 | return numo_cSComplex; 375 | } 376 | }; 377 | 378 | class DComplex: public NArray { 379 | public: 380 | DComplex(VALUE v) { 381 | construct_value(this->dtype(), v); 382 | } 383 | 384 | DComplex(Rice::Object o) { 385 | construct_value(this->dtype(), o.value()); 386 | } 387 | 388 | DComplex(std::initializer_list shape) { 389 | construct_shape(this->dtype(), shape); 390 | } 391 | 392 | private: 393 | VALUE dtype() { 394 | return numo_cDComplex; 395 | } 396 | }; 397 | 398 | class Bit: public NArray { 399 | public: 400 | Bit(VALUE v) { 401 | construct_value(this->dtype(), v); 402 | } 403 | 404 | Bit(Rice::Object o) { 405 | construct_value(this->dtype(), o.value()); 406 | } 407 | 408 | Bit(std::initializer_list shape) { 409 | construct_shape(this->dtype(), shape); 410 | } 411 | 412 | private: 413 | VALUE dtype() { 414 | return numo_cBit; 415 | } 416 | }; 417 | 418 | class RObject: public NArray { 419 | public: 420 | RObject(VALUE v) { 421 | construct_value(this->dtype(), v); 422 | } 423 | 424 | RObject(Rice::Object o) { 425 | construct_value(this->dtype(), o.value()); 426 | } 427 | 428 | RObject(std::initializer_list shape) { 429 | construct_shape(this->dtype(), shape); 430 | } 431 | 432 | const VALUE* read_ptr() { 433 | return reinterpret_cast(NArray::read_ptr()); 434 | } 435 | 436 | VALUE* write_ptr() { 437 | return reinterpret_cast(NArray::write_ptr()); 438 | } 439 | 440 | private: 441 | VALUE dtype() { 442 | return numo_cRObject; 443 | } 444 | }; 445 | } 446 | 447 | namespace Rice::detail { 448 | template<> 449 | struct Type 450 | { 451 | static bool verify() 452 | { 453 | return true; 454 | } 455 | }; 456 | 457 | template<> 458 | class From_Ruby 459 | { 460 | public: 461 | numo::NArray convert(VALUE x) 462 | { 463 | return numo::NArray(x); 464 | } 465 | }; 466 | 467 | template<> 468 | class To_Ruby 469 | { 470 | public: 471 | VALUE convert(const numo::NArray& x) { 472 | return x.value(); 473 | } 474 | }; 475 | 476 | template<> 477 | struct Type 478 | { 479 | static bool verify() 480 | { 481 | return true; 482 | } 483 | }; 484 | 485 | template<> 486 | class From_Ruby 487 | { 488 | public: 489 | numo::SFloat convert(VALUE x) 490 | { 491 | return numo::SFloat(x); 492 | } 493 | }; 494 | 495 | template<> 496 | class To_Ruby 497 | { 498 | public: 499 | VALUE convert(const numo::SFloat& x) { 500 | return x.value(); 501 | } 502 | }; 503 | 504 | template<> 505 | struct Type 506 | { 507 | static bool verify() 508 | { 509 | return true; 510 | } 511 | }; 512 | 513 | template<> 514 | class From_Ruby 515 | { 516 | public: 517 | numo::DFloat convert(VALUE x) 518 | { 519 | return numo::DFloat(x); 520 | } 521 | }; 522 | 523 | template<> 524 | class To_Ruby 525 | { 526 | public: 527 | VALUE convert(const numo::DFloat& x) { 528 | return x.value(); 529 | } 530 | }; 531 | 532 | template<> 533 | struct Type 534 | { 535 | static bool verify() 536 | { 537 | return true; 538 | } 539 | }; 540 | 541 | template<> 542 | class From_Ruby 543 | { 544 | public: 545 | numo::Int8 convert(VALUE x) 546 | { 547 | return numo::Int8(x); 548 | } 549 | }; 550 | 551 | template<> 552 | class To_Ruby 553 | { 554 | public: 555 | VALUE convert(const numo::Int8& x) { 556 | return x.value(); 557 | } 558 | }; 559 | 560 | template<> 561 | struct Type 562 | { 563 | static bool verify() 564 | { 565 | return true; 566 | } 567 | }; 568 | 569 | template<> 570 | class From_Ruby 571 | { 572 | public: 573 | numo::Int16 convert(VALUE x) 574 | { 575 | return numo::Int16(x); 576 | } 577 | }; 578 | 579 | template<> 580 | class To_Ruby 581 | { 582 | public: 583 | VALUE convert(const numo::Int16& x) { 584 | return x.value(); 585 | } 586 | }; 587 | 588 | template<> 589 | struct Type 590 | { 591 | static bool verify() 592 | { 593 | return true; 594 | } 595 | }; 596 | 597 | template<> 598 | class From_Ruby 599 | { 600 | public: 601 | numo::Int32 convert(VALUE x) 602 | { 603 | return numo::Int32(x); 604 | } 605 | }; 606 | 607 | template<> 608 | class To_Ruby 609 | { 610 | public: 611 | VALUE convert(const numo::Int32& x) { 612 | return x.value(); 613 | } 614 | }; 615 | 616 | template<> 617 | struct Type 618 | { 619 | static bool verify() 620 | { 621 | return true; 622 | } 623 | }; 624 | 625 | template<> 626 | class From_Ruby 627 | { 628 | public: 629 | numo::Int64 convert(VALUE x) 630 | { 631 | return numo::Int64(x); 632 | } 633 | }; 634 | 635 | template<> 636 | class To_Ruby 637 | { 638 | public: 639 | VALUE convert(const numo::Int64& x) { 640 | return x.value(); 641 | } 642 | }; 643 | 644 | template<> 645 | struct Type 646 | { 647 | static bool verify() 648 | { 649 | return true; 650 | } 651 | }; 652 | 653 | template<> 654 | class From_Ruby 655 | { 656 | public: 657 | numo::UInt8 convert(VALUE x) 658 | { 659 | return numo::UInt8(x); 660 | } 661 | }; 662 | 663 | template<> 664 | class To_Ruby 665 | { 666 | public: 667 | VALUE convert(const numo::UInt8& x) { 668 | return x.value(); 669 | } 670 | }; 671 | 672 | template<> 673 | struct Type 674 | { 675 | static bool verify() 676 | { 677 | return true; 678 | } 679 | }; 680 | 681 | template<> 682 | class From_Ruby 683 | { 684 | public: 685 | numo::UInt16 convert(VALUE x) 686 | { 687 | return numo::UInt16(x); 688 | } 689 | }; 690 | 691 | template<> 692 | class To_Ruby 693 | { 694 | public: 695 | VALUE convert(const numo::UInt16& x) { 696 | return x.value(); 697 | } 698 | }; 699 | 700 | template<> 701 | struct Type 702 | { 703 | static bool verify() 704 | { 705 | return true; 706 | } 707 | }; 708 | 709 | template<> 710 | class From_Ruby 711 | { 712 | public: 713 | numo::UInt32 convert(VALUE x) 714 | { 715 | return numo::UInt32(x); 716 | } 717 | }; 718 | 719 | template<> 720 | class To_Ruby 721 | { 722 | public: 723 | VALUE convert(const numo::UInt32& x) { 724 | return x.value(); 725 | } 726 | }; 727 | 728 | template<> 729 | struct Type 730 | { 731 | static bool verify() 732 | { 733 | return true; 734 | } 735 | }; 736 | 737 | template<> 738 | class From_Ruby 739 | { 740 | public: 741 | numo::UInt64 convert(VALUE x) 742 | { 743 | return numo::UInt64(x); 744 | } 745 | }; 746 | 747 | template<> 748 | class To_Ruby 749 | { 750 | public: 751 | VALUE convert(const numo::UInt64& x) { 752 | return x.value(); 753 | } 754 | }; 755 | 756 | template<> 757 | struct Type 758 | { 759 | static bool verify() 760 | { 761 | return true; 762 | } 763 | }; 764 | 765 | template<> 766 | class From_Ruby 767 | { 768 | public: 769 | numo::SComplex convert(VALUE x) 770 | { 771 | return numo::SComplex(x); 772 | } 773 | }; 774 | 775 | template<> 776 | class To_Ruby 777 | { 778 | public: 779 | VALUE convert(const numo::SComplex& x) { 780 | return x.value(); 781 | } 782 | }; 783 | 784 | template<> 785 | struct Type 786 | { 787 | static bool verify() 788 | { 789 | return true; 790 | } 791 | }; 792 | 793 | template<> 794 | class From_Ruby 795 | { 796 | public: 797 | numo::DComplex convert(VALUE x) 798 | { 799 | return numo::DComplex(x); 800 | } 801 | }; 802 | 803 | template<> 804 | class To_Ruby 805 | { 806 | public: 807 | VALUE convert(const numo::DComplex& x) { 808 | return x.value(); 809 | } 810 | }; 811 | 812 | template<> 813 | struct Type 814 | { 815 | static bool verify() 816 | { 817 | return true; 818 | } 819 | }; 820 | 821 | template<> 822 | class From_Ruby 823 | { 824 | public: 825 | numo::Bit convert(VALUE x) 826 | { 827 | return numo::Bit(x); 828 | } 829 | }; 830 | 831 | template<> 832 | class To_Ruby 833 | { 834 | public: 835 | VALUE convert(const numo::Bit& x) { 836 | return x.value(); 837 | } 838 | }; 839 | 840 | template<> 841 | struct Type 842 | { 843 | static bool verify() 844 | { 845 | return true; 846 | } 847 | }; 848 | 849 | template<> 850 | class From_Ruby 851 | { 852 | public: 853 | numo::RObject convert(VALUE x) 854 | { 855 | return numo::RObject(x); 856 | } 857 | }; 858 | 859 | template<> 860 | class To_Ruby 861 | { 862 | public: 863 | VALUE convert(const numo::RObject& x) { 864 | return x.value(); 865 | } 866 | }; 867 | } 868 | -------------------------------------------------------------------------------- /lib/midas-edge.rb: -------------------------------------------------------------------------------- 1 | # dependencies 2 | require "numo/narray" 3 | 4 | # ext 5 | require "midas/ext" 6 | 7 | # modules 8 | require_relative "midas/detector" 9 | require_relative "midas/version" 10 | 11 | module Midas 12 | class Error < StandardError; end 13 | 14 | def self.new(**options) 15 | Detector.new(**options) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/midas/detector.rb: -------------------------------------------------------------------------------- 1 | module Midas 2 | class Detector 3 | def initialize(rows: 2, buckets: 769, alpha: 0.5, threshold: nil, relations: true, directed: true, seed: 0) 4 | @rows = rows 5 | @buckets = buckets 6 | @alpha = alpha 7 | @threshold = threshold 8 | @relations = relations 9 | @directed = directed 10 | @seed = seed 11 | end 12 | 13 | def fit_predict(x) 14 | _fit_predict(x, @rows, @buckets, @alpha, @threshold || Float::NAN, @relations, @directed, @seed) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/midas/version.rb: -------------------------------------------------------------------------------- 1 | module Midas 2 | VERSION = "0.5.0" 3 | end 4 | -------------------------------------------------------------------------------- /midas-edge.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/midas/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "midas-edge" 5 | spec.version = Midas::VERSION 6 | spec.summary = "Edge stream anomaly detection for Ruby" 7 | spec.homepage = "https://github.com/ankane/midas-ruby" 8 | spec.license = "MIT" 9 | 10 | spec.author = "Andrew Kane" 11 | spec.email = "andrew@ankane.org" 12 | 13 | spec.files = Dir["*.{md,txt}", "{ext,lib}/**/*", "vendor/MIDAS/{LICENSE,README.md}", "vendor/MIDAS/src/*.hpp"] 14 | spec.require_path = "lib" 15 | spec.extensions = ["ext/midas/extconf.rb"] 16 | 17 | spec.required_ruby_version = ">= 3.2" 18 | 19 | spec.add_dependency "rice", ">= 4.3.3" 20 | spec.add_dependency "numo-narray" 21 | end 22 | -------------------------------------------------------------------------------- /test/midas_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class MidasTest < Minitest::Test 4 | def test_works 5 | midas = Midas.new(buckets: 1024) 6 | scores = midas.fit_predict(data) 7 | 8 | assert_equal [10000], scores.shape 9 | expected = [0, 0, 1, 2, 2, 4, 2, 2, 3, 6] 10 | assert_elements_in_delta expected, scores[0...10] 11 | 12 | skip "Different values on Linux (seed issue?)" if ci? 13 | 14 | expected = [307.507233, 469.720490, 215.821609, 236.601303, 258.282837] 15 | assert_elements_in_delta expected, scores[-5..-1] 16 | end 17 | 18 | def test_threshold 19 | midas = Midas.new(buckets: 1024, threshold: 1e3) 20 | scores = midas.fit_predict(data) 21 | 22 | assert_equal [10000], scores.shape 23 | expected = [0, 0, 0, 0, 0, 0, 0, 0, 0.005952, 1.005952] 24 | assert_elements_in_delta expected, scores[0...10] 25 | expected = [631.594849, 696.509277, 764.598450, 835.862366, 910.301086] 26 | assert_elements_in_delta expected, scores[-5..-1] 27 | end 28 | 29 | def test_no_relations 30 | midas = Midas.new(buckets: 1024, relations: false) 31 | scores = midas.fit_predict(data) 32 | 33 | assert_equal [10000], scores.shape 34 | expected = [0, 0, 1, 2, 2, 4, 2, 2, 3, 6] 35 | assert_elements_in_delta expected, scores[0...10] 36 | 37 | skip "Different values on Linux (seed issue?)" if ci? 38 | 39 | expected = [307.507233, 469.720490, 2.492458, 12.942609, 31.100597] 40 | assert_elements_in_delta expected, scores[-5..-1] 41 | end 42 | 43 | def test_undirected 44 | midas = Midas.new(directed: false) 45 | scores = midas.fit_predict(data) 46 | 47 | assert_equal [20000], scores.shape 48 | expected = [0, 0, 0, 0, 1, 1, 2, 2, 0.375, 0.375] 49 | assert_elements_in_delta expected, scores[0...10] 50 | end 51 | 52 | def test_file 53 | midas = Midas.new(buckets: 1024) 54 | scores = midas.fit_predict("vendor/MIDAS/data/DARPA/darpa_processed.csv") 55 | 56 | assert_equal [4554344], scores.shape 57 | expected = [0, 0, 1, 2, 2, 4, 2, 2, 3, 6] 58 | assert_elements_in_delta expected, scores[0...10] 59 | 60 | skip "Different values on Linux (seed issue?)" if ci? 61 | 62 | expected = [49.704582, 56.181786, 63.055534, 70.325829, 10.779646] 63 | assert_elements_in_delta expected, scores[-5..-1] 64 | end 65 | 66 | def data 67 | data = [] 68 | File.foreach("vendor/MIDAS/data/DARPA/darpa_processed.csv").with_index do |line, i| 69 | break if i == 10000 70 | data << line.split(",").map(&:to_i) 71 | end 72 | Numo::Int32.cast(data) 73 | end 74 | 75 | def ci? 76 | ENV["CI"] 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | Bundler.require(:default) 3 | require "minitest/autorun" 4 | require "minitest/pride" 5 | 6 | class Minitest::Test 7 | def assert_elements_in_delta(expected, actual) 8 | assert_equal expected.size, actual.size 9 | expected.zip(actual) do |exp, act| 10 | assert_in_delta exp, act 11 | end 12 | end 13 | end 14 | --------------------------------------------------------------------------------