From ca1044fcbb94a729d0df16d412ae657db7fc13bf Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 14 Aug 2024 22:07:27 -0600 Subject: [PATCH] initial commit --- .gitignore | 1 + Cargo.lock | 563 ++++++++++++++++++++++++++++++ Cargo.toml | 26 ++ example.json | 113 ++++++ example.yaml | 205 +++++++++++ src/lib.rs | 4 + src/main.rs | 203 +++++++++++ src/schedule.rs | 221 ++++++++++++ src/schedule_loader.rs | 227 ++++++++++++ src/simulation.rs | 262 ++++++++++++++ src/task.rs | 149 ++++++++ tests/effort_is_correct.rs | 57 +++ tests/invalid_schedules.rs | 91 +++++ tests/no_dependencies_schedule.rs | 97 +++++ tests/simulation_tests.rs | 91 +++++ 15 files changed, 2310 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 example.json create mode 100644 example.yaml create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/schedule.rs create mode 100644 src/schedule_loader.rs create mode 100644 src/simulation.rs create mode 100644 src/task.rs create mode 100644 tests/effort_is_correct.rs create mode 100644 tests/invalid_schedules.rs create mode 100644 tests/no_dependencies_schedule.rs create mode 100644 tests/simulation_tests.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..923f8b9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,563 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d8838454fda655dafd3accb2b6e2bea645b9e4078abe84a22ceb947235c5cc" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" + +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "indexmap" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "mcps" +version = "0.1.0" +dependencies = [ + "clap", + "rand", + "rand_distr", + "rayon", + "serde", + "serde_json", + "serde_yaml", + "tempfile", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.207" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.207" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.124" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66ad62847a56b3dba58cc891acd13884b9c61138d330c0d7b6181713d4fce38d" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..871e960 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "mcps" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +name = "mcps" +path = "src/lib.rs" + +[[bin]] +name = "mcps" +path = "src/main.rs" + +[dependencies] +clap = "4.5.15" +rand = "0.8.5" +rand_distr = "0.4.3" +rayon = "1.10.0" +serde = { version = "1.0.207", features = ["derive"] } +serde_json = "1.0.124" +serde_yaml = "0.9.34" + +[dev-dependencies] +tempfile = "3.12.0" diff --git a/example.json b/example.json new file mode 100644 index 0000000..7783bb1 --- /dev/null +++ b/example.json @@ -0,0 +1,113 @@ +{ + "num_workers": 8, + "estimate_confidence": 0.8, + "tasks": [ + { + "id": "RequirementsGathering", + "min_time": 10.0, + "max_time": 30.0, + "dependencies": [] + }, + { + "id": "SystemDesign", + "min_time": 15.0, + "max_time": 45.0, + "dependencies": [ + "RequirementsGathering" + ] + }, + { + "id": "DatabaseDesign", + "min_time": 10.0, + "max_time": 35.0, + "dependencies": [ + "SystemDesign" + ] + }, + { + "id": "UIUXDesign", + "min_time": 20.0, + "max_time": 50.0, + "dependencies": [ + "RequirementsGathering" + ] + }, + { + "id": "FrontendDevelopment", + "min_time": 25.0, + "max_time": 60.0, + "dependencies": [ + "UIUXDesign" + ] + }, + { + "id": "BackendDevelopment", + "min_time": 30.0, + "max_time": 80.0, + "dependencies": [ + "SystemDesign", + "DatabaseDesign" + ] + }, + { + "id": "APIDevelopment", + "min_time": 20.0, + "max_time": 60.0, + "dependencies": [ + "BackendDevelopment" + ] + }, + { + "id": "IntegrationTesting", + "min_time": 15.0, + "max_time": 45.0, + "dependencies": [ + "FrontendDevelopment", + "BackendDevelopment", + "APIDevelopment" + ] + }, + { + "id": "UserAcceptanceTesting", + "min_time": 10.0, + "max_time": 30.0, + "dependencies": [ + "IntegrationTesting" + ] + }, + { + "id": "PerformanceOptimization", + "min_time": 10.0, + "max_time": 40.0, + "dependencies": [ + "IntegrationTesting" + ] + }, + { + "id": "SecurityAudit", + "min_time": 10.0, + "max_time": 30.0, + "dependencies": [ + "IntegrationTesting" + ] + }, + { + "id": "FinalDeployment", + "min_time": 5.0, + "max_time": 15.0, + "dependencies": [ + "UserAcceptanceTesting", + "PerformanceOptimization", + "SecurityAudit" + ] + }, + { + "id": "ProjectRetrospective", + "min_time": 3.0, + "max_time": 10.0, + "dependencies": [ + "FinalDeployment" + ] + } + ] +} diff --git a/example.yaml b/example.yaml new file mode 100644 index 0000000..1ae2917 --- /dev/null +++ b/example.yaml @@ -0,0 +1,205 @@ +num_workers: 5 +estimate_confidence: 0.80 +tasks: + # Project Initiation and Planning + - id: project_kickoff + min_time: 2 + max_time: 3 + dependencies: [] + + - id: requirements_gathering + min_time: 10 + max_time: 15 + dependencies: [project_kickoff] + + - id: system_architecture_design + min_time: 15 + max_time: 20 + dependencies: [requirements_gathering] + + - id: database_design + min_time: 5 + max_time: 10 + dependencies: [system_architecture_design] + + # Frontend Development + - id: ui_ux_design + min_time: 20 + max_time: 30 + dependencies: [requirements_gathering] + + - id: frontend_setup + min_time: 3 + max_time: 5 + dependencies: [ui_ux_design] + + - id: user_authentication_frontend + min_time: 5 + max_time: 10 + dependencies: [frontend_setup] + + - id: product_catalog_frontend + min_time: 15 + max_time: 25 + dependencies: [frontend_setup] + + - id: shopping_cart_frontend + min_time: 10 + max_time: 15 + dependencies: [frontend_setup] + + - id: checkout_process_frontend + min_time: 15 + max_time: 20 + dependencies: [shopping_cart_frontend] + + - id: user_profile_frontend + min_time: 5 + max_time: 10 + dependencies: [user_authentication_frontend] + + - id: search_functionality_frontend + min_time: 10 + max_time: 15 + dependencies: [product_catalog_frontend] + + # Backend Development + - id: backend_setup + min_time: 5 + max_time: 10 + dependencies: [system_architecture_design, database_design] + + - id: user_authentication_backend + min_time: 10 + max_time: 15 + dependencies: [backend_setup] + + - id: product_catalog_backend + min_time: 20 + max_time: 30 + dependencies: [backend_setup] + + - id: shopping_cart_backend + min_time: 15 + max_time: 20 + dependencies: [backend_setup] + + - id: checkout_process_backend + min_time: 20 + max_time: 25 + dependencies: [shopping_cart_backend] + + - id: user_profile_backend + min_time: 10 + max_time: 15 + dependencies: [user_authentication_backend] + + - id: search_functionality_backend + min_time: 15 + max_time: 20 + dependencies: [product_catalog_backend] + + - id: payment_gateway_integration + min_time: 10 + max_time: 15 + dependencies: [checkout_process_backend] + + # API Development and Integration + - id: api_design + min_time: 5 + max_time: 10 + dependencies: [system_architecture_design] + + - id: api_development + min_time: 25 + max_time: 35 + dependencies: [api_design, backend_setup] + + - id: api_documentation + min_time: 5 + max_time: 10 + dependencies: [api_development] + + # Testing + - id: unit_testing + min_time: 20 + max_time: 30 + dependencies: + [ + user_authentication_backend, + product_catalog_backend, + shopping_cart_backend, + checkout_process_backend, + user_profile_backend, + search_functionality_backend, + ] + + - id: integration_testing + min_time: 15 + max_time: 20 + dependencies: [unit_testing, api_development] + + - id: user_acceptance_testing + min_time: 10 + max_time: 15 + dependencies: [integration_testing] + + # Security and Performance + - id: security_audit + min_time: 10 + max_time: 15 + dependencies: [integration_testing] + + - id: performance_optimization + min_time: 15 + max_time: 20 + dependencies: [integration_testing] + + # Deployment and Launch + - id: deployment_planning + min_time: 5 + max_time: 10 + dependencies: [system_architecture_design] + + - id: staging_environment_setup + min_time: 5 + max_time: 10 + dependencies: [deployment_planning] + + - id: production_environment_setup + min_time: 10 + max_time: 15 + dependencies: [staging_environment_setup] + + - id: data_migration + min_time: 10 + max_time: 20 + dependencies: [production_environment_setup] + + - id: user_training + min_time: 5 + max_time: 10 + dependencies: [user_acceptance_testing] + + - id: final_deployment + min_time: 5 + max_time: 10 + dependencies: + [ + user_acceptance_testing, + security_audit, + performance_optimization, + data_migration, + user_training, + ] + + # Post-launch + - id: post_launch_support + min_time: 10 + max_time: 15 + dependencies: [final_deployment] + + - id: project_retrospective + min_time: 2 + max_time: 3 + dependencies: [post_launch_support] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2aa0534 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +pub mod schedule; +pub mod schedule_loader; +pub mod simulation; +pub mod task; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5a4fe0f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,203 @@ +use mcps::{schedule::Schedule, simulation::run_multiple_simulations}; + +use clap::{Arg, Command}; + +use std::time::Duration; + +const AFTER_HELP_TEXT: &str = "\ +Examples of supported file formats: + + YAML Format: + + num_workers: 5 + estimate_confidence: 0.80 + tasks: + - id: DesignPhase + min_time: 1.5 # 1.5 days + max_time: 3.5 # 3.5 days + dependencies: [] + - id: ImplementationPhase + min_time: 2.25 # 2.25 days + max_time: 4.75 # 4.75 days + dependencies: [DesignPhase] + + JSON Format: + + { + \"num_workers\": 5, + \"estimate_confidence\": 0.80, + \"tasks\": [ + { + \"id\": \"DesignPhase\", + \"min_time\": 1.5, + \"max_time\": 3.5, + \"dependencies\": [] + }, + { + \"id\": \"ImplementationPhase\", + \"min_time\": 2.25, + \"max_time\": 4.75, + \"dependencies\": [\"DesignPhase\"] + } + ] + } + + Note: `estimate_confidence` is the confidence (0.0,1.0) that the + actual values fall in the task estimate ranges. For example, + 0.8 means you believe that the actual time it will take to + complete a task falls in your [min_time,max_time] estimate + range for that task 80% of the time."; + +fn main() -> Result<(), Box> { + let matches = Command::new("Monte Carlo Project Scheduler") + .version("1.0") + .author("Stephen Waits ") + .about("Runs Monte Carlo simulations on project schedules") + .arg( + Arg::new("filename") + .help("Path to the schedule file (.yaml or .json).") + .required(true) + .index(1), + ) + .arg( + Arg::new("iterations") + .short('i') + .long("iterations") + .help("Number of iterations to run") + .default_value("50000"), + ) + .arg( + Arg::new("workers") + .short('w') + .long("workers") + .help("Override number of workers specified in schedule file") + .value_name("num_workers"), + ) + .after_help(AFTER_HELP_TEXT) + .get_matches(); + + let schedule_path = matches.get_one::("filename").unwrap(); + let num_simulations: usize = matches.get_one::("iterations").unwrap().parse()?; + + // Ensure iterations is >= 100 + if num_simulations < 100 { + return Err("Iterations must be at least 100.".into()); + } + + // Load the schedule + let mut schedule = Schedule::from_file(schedule_path)?; + + // Check if workers are overridden by command-line argument + if let Some(workers_str) = matches.get_one::("workers") { + let workers: usize = workers_str.parse()?; + if workers < 1 { + return Err("Invalid number of workers, must be 1 or more".into()); + } + schedule.num_workers = workers; + } + + // Monte Carlo simulation + let (project_durations, effort_times) = run_multiple_simulations(&schedule, num_simulations); + + // Results output + print_ascii_cdf( + &project_durations, + "Project Completion Time (by probability)", + "Duration", + ); + println!(); + print_ascii_cdf( + &effort_times, + "Total Work Effort (by probability)", + "Effort", + ); + + Ok(()) +} + +fn print_ascii_cdf(data: &[Duration], title: &str, units: &str) { + let mut sorted_data = data.to_vec(); + sorted_data.sort_unstable(); + let min = sorted_data[0]; + let max = *sorted_data.last().unwrap(); + + let width = 60; // Total width of the field + + // Calculate padding + let padding = (width - title.len()) / 2; + + // Create the centered title with padding on both sides + let centered_title = format!( + "{:padding_left$}{}{:padding_right$}", + "", + title, + "", + padding_left = padding, + padding_right = width - padding - title.len() + ); + + println!("────┬────────────────────────────────────────────────────────────┬──────────"); + println!("%ile│{}│{:>10}", centered_title, units); + println!("────┼────────────────────────────────────────────────────────────┼──────────"); + + let bar_width = 60; + + for i in 0..=20 { + let percentile = 100 - i * 5; + let lower_percentile = if i == 20 { + 0.0000000001 + } else { + (95 - i * 5) as f64 / 100.0 + }; + let upper_percentile = if i == 0 { + 0.9999999999 + } else { + (100 - i * 5) as f64 / 100.0 + }; + let lower_index = (lower_percentile * (sorted_data.len() - 1) as f64).round() as usize; + let upper_index = (upper_percentile * (sorted_data.len() - 1) as f64).round() as usize; + let mid_duration = (sorted_data[lower_index] + sorted_data[upper_index]) / 2; + let days = mid_duration.as_secs_f64() / 86400.0; + let normalized_position = (days - min.as_secs_f64() / 86400.0) + / (max.as_secs_f64() / 86400.0 - min.as_secs_f64() / 86400.0); + let bar_position = (normalized_position * bar_width as f64).round() as usize; + + let (fg, bg) = if i % 2 == 0 { + ('░', '▓') + } else { + ('▒', '█') + }; + + let color_code = match percentile { + 0..=50 => "\x1b[31m", // Red + 55..=75 => "\x1b[33m", // Orange + 80..=90 => "\x1b[32m", // Green + 95..=95 => "\x1b[33m", // Orange + 100..=100 => "\x1b[31m", // Red + _ => "\x1b[0m", // Default (shouldn't happen) + }; + let reset_code = "\x1b[0m"; + + let bar_with_divider: String = (0..bar_width) + .map(|j| match j { + _ if j == bar_position => '▮', + _ if j < bar_position => fg, + _ => bg, + }) + .collect(); + + println!( + "{}{:>4}{}│{}{}{}│{}{:5.0} days{}", + color_code, + format!("p{}", percentile), + reset_code, + color_code, + bar_with_divider, + reset_code, + color_code, + days, + reset_code + ); + } + println!("────┴────────────────────────────────────────────────────────────┴──────────"); +} diff --git a/src/schedule.rs b/src/schedule.rs new file mode 100644 index 0000000..47ac688 --- /dev/null +++ b/src/schedule.rs @@ -0,0 +1,221 @@ +use crate::task::Task; + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +#[derive(Debug)] +pub struct Schedule { + pub tasks: Vec, + pub num_workers: usize, + pub estimate_confidence: f64, +} + +impl Schedule { + pub fn new( + tasks: Vec, + num_workers: usize, + estimate_confidence: f64, + ) -> Result { + let schedule = Schedule { + tasks, + num_workers, + estimate_confidence, + }; + schedule.validate()?; + Ok(schedule) + } + + pub fn validate(&self) -> Result<(), String> { + if self.tasks.is_empty() { + return Err("Empty task list".to_string()); + } + if self.num_workers == 0 { + return Err("Invalid number of workers (must be 1 or more)".to_string()); + } + let all_task_ids: HashSet<&String> = self.tasks.iter().map(|t| &t.id).collect(); + for task in &self.tasks { + if task.min_time > task.max_time { + return Err(format!( + "Minimum duration greater than maximum for task {}", + task.id + )); + } + if task.min_time <= Duration::from_secs(0) || task.max_time <= Duration::from_secs(0) { + return Err(format!("Invalid task duration for task {}", task.id)); + } + for dep in &task.dependencies { + if !all_task_ids.contains(dep) { + return Err(format!("Missing dependency {} for task {}", dep, task.id)); + } + } + } + self.check_cyclic_dependencies() + } + + fn check_cyclic_dependencies(&self) -> Result<(), String> { + let mut visited = HashSet::new(); + let mut stack = HashSet::new(); + let task_map: HashMap<_, _> = self.tasks.iter().map(|t| (&t.id, t)).collect(); + + for task in &self.tasks { + Self::dfs(&task.id, &task_map, &mut visited, &mut stack)?; + } + Ok(()) + } + + fn dfs<'a>( + task_id: &'a str, + task_map: &'a HashMap<&'a String, &'a Task>, + visited: &mut HashSet<&'a str>, + stack: &mut HashSet<&'a str>, + ) -> Result<(), String> { + if stack.contains(task_id) { + return Err(format!( + "Cyclic dependency detected involving task {}", + task_id + )); + } + if visited.contains(task_id) { + return Ok(()); + } + visited.insert(task_id); + stack.insert(task_id); + if let Some(task) = task_map.get(&task_id.to_string()) { + for dep in &task.dependencies { + Self::dfs(dep, task_map, visited, stack)?; + } + } + stack.remove(task_id); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_task(id: &str, min: u64, max: u64, deps: Vec<&str>) -> Task { + Task { + id: id.to_string(), + dependencies: deps.into_iter().map(String::from).collect(), + min_time: Duration::from_secs(min), + max_time: Duration::from_secs(max), + } + } + + #[test] + fn test_valid_schedule() { + let tasks = vec![ + create_task("A", 1, 3, vec![]), + create_task("B", 2, 4, vec!["A"]), + create_task("C", 3, 5, vec!["A"]), + ]; + let schedule = Schedule::new(tasks, 2, 0.8); + assert!(schedule.is_ok()); + } + + #[test] + fn test_empty_task_list() { + let schedule = Schedule::new(vec![], 2, 0.8); + assert!(schedule.is_err()); + assert_eq!(schedule.unwrap_err(), "Empty task list"); + } + + #[test] + fn test_invalid_task_duration() { + let tasks = vec![create_task("A", 3, 1, vec![])]; + let schedule = Schedule::new(tasks, 2, 0.8); + assert!(schedule.is_err()); + assert_eq!( + schedule.unwrap_err(), + "Minimum duration greater than maximum for task A" + ); + } + + #[test] + fn test_zero_duration() { + let tasks = vec![create_task("A", 0, 1, vec![])]; + let schedule = Schedule::new(tasks, 2, 0.8); + assert!(schedule.is_err()); + assert_eq!(schedule.unwrap_err(), "Invalid task duration for task A"); + } + + #[test] + fn test_missing_dependency() { + let tasks = vec![ + create_task("A", 1, 3, vec![]), + create_task("B", 2, 4, vec!["C"]), + ]; + let schedule = Schedule::new(tasks, 2, 0.8); + assert!(schedule.is_err()); + assert_eq!(schedule.unwrap_err(), "Missing dependency C for task B"); + } + + #[test] + fn test_cyclic_dependency() { + let tasks = vec![ + create_task("A", 1, 3, vec!["B"]), + create_task("B", 2, 4, vec!["A"]), + ]; + let schedule = Schedule::new(tasks, 2, 0.8); + assert!(schedule.is_err()); + assert!(schedule + .unwrap_err() + .starts_with("Cyclic dependency detected involving task")); + } + + #[test] + fn test_complex_valid_schedule() { + let tasks = vec![ + create_task("A", 1, 3, vec![]), + create_task("B", 2, 4, vec!["A"]), + create_task("C", 3, 5, vec!["A"]), + create_task("D", 2, 6, vec!["B", "C"]), + create_task("E", 1, 2, vec!["A"]), + create_task("F", 2, 4, vec!["D", "E"]), + ]; + let schedule = Schedule::new(tasks, 3, 0.9); + assert!(schedule.is_ok()); + } + + #[test] + fn test_complex_cyclic_dependency() { + let tasks = vec![ + create_task("A", 1, 3, vec!["F"]), + create_task("B", 2, 4, vec!["A"]), + create_task("C", 3, 5, vec!["B"]), + create_task("D", 2, 6, vec!["C"]), + create_task("E", 1, 2, vec!["D"]), + create_task("F", 2, 4, vec!["E"]), + ]; + let schedule = Schedule::new(tasks, 3, 0.9); + assert!( + schedule.is_err(), + "Expected cyclic dependency error, but got Ok" + ); + if let Err(err) = schedule { + assert!( + err.starts_with("Cyclic dependency detected"), + "Unexpected error message: {}", + err + ); + } + } + + #[test] + fn test_self_dependency() { + let tasks = vec![create_task("A", 1, 3, vec!["A"])]; + let schedule = Schedule::new(tasks, 1, 0.8); + assert!( + schedule.is_err(), + "Expected self-dependency error, but got Ok" + ); + if let Err(err) = schedule { + assert!( + err.starts_with("Cyclic dependency detected"), + "Unexpected error message: {}", + err + ); + } + } +} diff --git a/src/schedule_loader.rs b/src/schedule_loader.rs new file mode 100644 index 0000000..5a7b540 --- /dev/null +++ b/src/schedule_loader.rs @@ -0,0 +1,227 @@ +use crate::{schedule::Schedule, task::Task}; + +use serde::{Deserialize, Serialize}; + +use std::{fs::File, io::Read, path::Path, time::Duration}; + +#[derive(Debug, Deserialize, Serialize)] +struct ScheduleInput { + num_workers: usize, + estimate_confidence: f64, + tasks: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct TaskInput { + id: String, + min_time: f64, + max_time: f64, + dependencies: Vec, +} + +enum FileFormat { + Yaml, + Json, +} + +impl Schedule { + pub fn from_file>(path: P) -> Result> { + let mut file = File::open(&path)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + + let format = Self::detect_format(&path)?; + let input: ScheduleInput = match format { + FileFormat::Yaml => serde_yaml::from_str(&contents)?, + FileFormat::Json => serde_json::from_str(&contents)?, + }; + + let tasks = input + .tasks + .into_iter() + .map(|t| Task { + id: t.id, + min_time: Duration::from_secs_f64(t.min_time * 24.0 * 60.0 * 60.0), + max_time: Duration::from_secs_f64(t.max_time * 24.0 * 60.0 * 60.0), + dependencies: t.dependencies, + }) + .collect(); + + let schedule = Schedule::new(tasks, input.num_workers, input.estimate_confidence)?; + + schedule.validate()?; + + Ok(schedule) + } + + fn detect_format>(path: P) -> Result> { + let extension = path + .as_ref() + .extension() + .and_then(std::ffi::OsStr::to_str) + .ok_or("File has no extension")? + .to_lowercase(); + + match extension.as_str() { + "yaml" | "yml" => Ok(FileFormat::Yaml), + "json" => Ok(FileFormat::Json), + _ => Err("Unsupported file format. Use .yaml, .yml, or .json".into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn create_temp_file(content: &str, extension: &str) -> (NamedTempFile, std::path::PathBuf) { + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{}", content).unwrap(); + let path = temp_file.path().to_owned(); + let new_path = path.with_extension(extension); + std::fs::rename(&path, &new_path).unwrap(); + (temp_file, new_path) + } + + #[test] + fn test_load_from_yaml() { + let yaml_content = r#" +num_workers: 3 +estimate_confidence: 0.9 +tasks: + - id: A + min_time: 1 + max_time: 3 + dependencies: [] + - id: B + min_time: 2 + max_time: 4 + dependencies: [A] +"#; + let (_temp_file, path) = create_temp_file(yaml_content, "yaml"); + let schedule = Schedule::from_file(path).unwrap(); + assert_eq!(schedule.tasks.len(), 2); + assert_eq!(schedule.num_workers, 3); + assert_eq!(schedule.estimate_confidence, 0.9); + } + + #[test] + fn test_load_from_json() { + let json_content = r#" +{ + "num_workers": 3, + "estimate_confidence": 0.9, + "tasks": [ + { + "id": "A", + "min_time": 1, + "max_time": 3, + "dependencies": [] + }, + { + "id": "B", + "min_time": 2, + "max_time": 4, + "dependencies": ["A"] + } + ] +} +"#; + let (_temp_file, path) = create_temp_file(json_content, "json"); + let schedule = Schedule::from_file(path).unwrap(); + assert_eq!(schedule.tasks.len(), 2); + assert_eq!(schedule.num_workers, 3); + assert_eq!(schedule.estimate_confidence, 0.9); + } + + #[test] + fn test_unsupported_format() { + let content = "irrelevant content"; + let (_temp_file, path) = create_temp_file(content, "txt"); + let result = Schedule::from_file(path); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Unsupported file format")); + } + + #[test] + fn test_invalid_schedule() { + let yaml_content = r#" +num_workers: 3 +estimate_confidence: 0.9 +tasks: + - id: A + min_time: 3 + max_time: 1 # Invalid: min_time > max_time + dependencies: [] +"#; + let (_temp_file, path) = create_temp_file(yaml_content, "yaml"); + let result = Schedule::from_file(path); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Minimum duration greater than maximum for task A")); + } + + #[test] + fn test_load_with_floating_point_times_yaml() { + let yaml_content = r#" +num_workers: 3 +estimate_confidence: 0.9 +tasks: + - id: A + min_time: 1.5 # 1.5 days + max_time: 3.5 # 3.5 days + dependencies: [] +"#; + let (_temp_file, path) = create_temp_file(yaml_content, "yaml"); + let schedule = Schedule::from_file(path).unwrap(); + + // Verify that there is one task and it has the correct times + assert_eq!(schedule.tasks.len(), 1); + assert_eq!( + schedule.tasks[0].min_time.as_secs_f64(), + 1.5 * 24.0 * 60.0 * 60.0 + ); + assert_eq!( + schedule.tasks[0].max_time.as_secs_f64(), + 3.5 * 24.0 * 60.0 * 60.0 + ); + } + + #[test] + fn test_load_with_floating_point_times_json() { + let json_content = r#" +{ + "num_workers": 3, + "estimate_confidence": 0.9, + "tasks": [ + { + "id": "A", + "min_time": 1.5, + "max_time": 3.5, + "dependencies": [] + } + ] +} +"#; + let (_temp_file, path) = create_temp_file(json_content, "json"); + let schedule = Schedule::from_file(path).unwrap(); + + // Verify that there is one task and it has the correct times + assert_eq!(schedule.tasks.len(), 1); + assert_eq!( + schedule.tasks[0].min_time.as_secs_f64(), + 1.5 * 24.0 * 60.0 * 60.0 + ); + assert_eq!( + schedule.tasks[0].max_time.as_secs_f64(), + 3.5 * 24.0 * 60.0 * 60.0 + ); + } +} diff --git a/src/simulation.rs b/src/simulation.rs new file mode 100644 index 0000000..4989565 --- /dev/null +++ b/src/simulation.rs @@ -0,0 +1,262 @@ +use crate::schedule::Schedule; + +use rand::prelude::*; +use rand_distr::{Distribution, Normal}; +use rayon::prelude::*; + +use std::{ + collections::{HashMap, HashSet}, + time::Duration, +}; + +pub struct SimulationResult { + pub total_project_duration: Duration, + pub total_effort_time: Duration, +} + +pub fn run_multiple_simulations( + schedule: &Schedule, + num_simulations: usize, +) -> (Vec, Vec) { + (0..num_simulations) + .into_par_iter() + .map(|_| { + let result = run_simulation(schedule); + (result.total_project_duration, result.total_effort_time) + }) + .unzip() +} + +fn run_simulation(schedule: &Schedule) -> SimulationResult { + let mut rng = thread_rng(); + + // Simulate task times + let task_effort_times: HashMap<_, _> = schedule + .tasks + .iter() + .map(|task| { + let task_time = simulate_task_time( + &mut rng, + task.min_time, + task.max_time, + schedule.estimate_confidence, + ); + (&task.id, task_time) + }) + .collect(); + + let total_effort_time: Duration = task_effort_times.values().sum(); + + // Pre-compute task dependencies and reverse dependencies + let mut task_dependencies: HashMap<_, HashSet<_>> = HashMap::new(); + let mut reverse_dependencies: HashMap<_, Vec<_>> = HashMap::new(); + for task in &schedule.tasks { + task_dependencies.insert(&task.id, task.dependencies.iter().collect()); + for dep in &task.dependencies { + reverse_dependencies.entry(dep).or_default().push(&task.id); + } + } + + // Initialize task queue with tasks that have no dependencies + let mut task_queue: Vec<_> = schedule + .tasks + .iter() + .filter(|t| t.dependencies.is_empty()) + .map(|t| &t.id) + .collect(); + + let mut current_time = Duration::default(); + let mut completed_tasks = HashSet::new(); + let mut worker_finish_times = vec![Duration::default(); schedule.num_workers]; + + while !task_queue.is_empty() || completed_tasks.len() < schedule.tasks.len() { + // Find all available workers + let available_workers: Vec<_> = worker_finish_times + .iter() + .enumerate() + .filter(|&(_, &time)| time <= current_time) + .map(|(i, _)| i) + .collect(); + + if !task_queue.is_empty() && !available_workers.is_empty() { + for &worker in &available_workers { + if task_queue.is_empty() { + break; + } + // Randomly choose the next task to assign + let task_index = rng.gen_range(0..task_queue.len()); + let task_id = task_queue.swap_remove(task_index); + + let task_duration = task_effort_times[task_id]; + worker_finish_times[worker] = current_time + task_duration; + completed_tasks.insert(task_id); + + // Add newly available tasks to queue + if let Some(dependent_tasks) = reverse_dependencies.get(task_id) { + for &dep_task in dependent_tasks { + if !completed_tasks.contains(dep_task) + && !task_queue.contains(&dep_task) + && task_dependencies[dep_task] + .iter() + .all(|dep| completed_tasks.contains(dep)) + { + task_queue.push(dep_task); + } + } + } + } + } + + // Move time forward to the next event + current_time = *worker_finish_times.iter().min().unwrap_or(¤t_time); + } + + SimulationResult { + total_project_duration: *worker_finish_times.iter().max().unwrap_or(¤t_time), + total_effort_time, + } +} + +fn simulate_task_time( + rng: &mut impl Rng, + min_time: Duration, + max_time: Duration, + estimate_confidence: f64, +) -> Duration { + let min_secs = min_time.as_secs_f64(); + let max_secs = max_time.as_secs_f64(); + let mean = (min_secs + max_secs) / 2.0; + + let z_score = (1.0 - (1.0 - estimate_confidence) / 2.0).sqrt() * 2.0; + let std_dev = (max_secs - min_secs) / (2.0 * z_score); + + let normal = Normal::new(mean, std_dev).unwrap(); + let sampled_secs = normal.sample(rng).clamp(min_secs, max_secs); + + Duration::from_secs_f64(sampled_secs) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::task::Task; + + #[test] + fn test_simulate_task_time() { + let mut rng = thread_rng(); + let min_time = Duration::from_secs(5); + let max_time = Duration::from_secs(15); + let estimate_confidence = 0.8; + + for _ in 0..1000 { + let time = simulate_task_time(&mut rng, min_time, max_time, estimate_confidence); + assert!( + time >= min_time && time <= max_time, + "Simulated time should be within range" + ); + } + } + + #[test] + fn test_single_task_simulation() { + let task = Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(15)); + let schedule = Schedule::new(vec![task], 1, 0.8).unwrap(); + + let result = run_simulation(&schedule); + + assert_eq!( + result.total_project_duration, result.total_effort_time, + "For a single task, project duration should equal effort time" + ); + assert!( + result.total_project_duration >= Duration::from_secs(5) + && result.total_project_duration <= Duration::from_secs(15), + "Project duration should be within the task's time range" + ); + } + + #[test] + fn test_multiple_independent_tasks() { + let tasks = vec![ + Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(10)), + Task::new("B", vec![], Duration::from_secs(7), Duration::from_secs(12)), + Task::new("C", vec![], Duration::from_secs(3), Duration::from_secs(8)), + ]; + let schedule = Schedule::new(tasks, 2, 0.8).unwrap(); + + let result = run_simulation(&schedule); + + assert!( + result.total_project_duration <= result.total_effort_time, + "Project duration should not exceed total effort time" + ); + assert!( + result.total_project_duration >= Duration::from_secs(7), + "Project duration should be at least the longest minimum task duration" + ); + } + + #[test] + fn test_tasks_with_dependencies() { + let tasks = vec![ + Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(10)), + Task::new( + "B", + vec!["A".to_string()], + Duration::from_secs(7), + Duration::from_secs(12), + ), + Task::new( + "C", + vec!["A".to_string()], + Duration::from_secs(3), + Duration::from_secs(8), + ), + Task::new( + "D", + vec!["B".to_string(), "C".to_string()], + Duration::from_secs(4), + Duration::from_secs(9), + ), + ]; + let schedule = Schedule::new(tasks, 2, 0.8).unwrap(); + + let result = run_simulation(&schedule); + + assert!( + result.total_project_duration >= Duration::from_secs(14), + "Project duration should be at least the minimum critical path duration" + ); + } + + #[test] + fn test_multiple_simulations_consistency() { + let tasks = vec![ + Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(10)), + Task::new( + "B", + vec!["A".to_string()], + Duration::from_secs(7), + Duration::from_secs(12), + ), + ]; + let schedule = Schedule::new(tasks, 1, 0.8).unwrap(); + + let (durations, efforts) = run_multiple_simulations(&schedule, 1000); + + assert_eq!(durations.len(), 1000, "Should run 1000 simulations"); + assert_eq!(efforts.len(), 1000, "Should run 1000 simulations"); + + let avg_duration: Duration = durations.iter().sum::() / 1000; + let avg_effort: Duration = efforts.iter().sum::() / 1000; + + assert!( + avg_duration >= Duration::from_secs(12) && avg_duration <= Duration::from_secs(22), + "Average duration should be within expected range" + ); + assert!( + avg_effort >= Duration::from_secs(12) && avg_effort <= Duration::from_secs(22), + "Average effort should be within expected range" + ); + } +} diff --git a/src/task.rs b/src/task.rs new file mode 100644 index 0000000..e97d895 --- /dev/null +++ b/src/task.rs @@ -0,0 +1,149 @@ +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct Task { + pub id: String, + pub dependencies: Vec, + pub min_time: Duration, + pub max_time: Duration, +} + +impl Task { + pub fn new( + id: &str, + dependencies: Vec, + min_time: Duration, + max_time: Duration, + ) -> Self { + Task { + id: id.to_string(), + dependencies, + min_time, + max_time, + } + } +} + +pub fn days_to_duration(days: f64) -> Duration { + Duration::from_secs_f64(days * 24.0 * 60.0 * 60.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + const EPSILON: f64 = 1e-6; + + fn assert_duration_eq(actual: Duration, expected: Duration) { + let actual_secs = actual.as_secs_f64(); + let expected_secs = expected.as_secs_f64(); + assert!( + (actual_secs - expected_secs).abs() < EPSILON, + "Durations are not equal: actual = {:?}, expected = {:?}", + actual, + expected + ); + } + + #[test] + fn test_task_creation() { + let task = Task::new( + "Task1", + vec!["Dep1".to_string(), "Dep2".to_string()], + Duration::from_secs(1), + Duration::from_secs(5), + ); + + assert_eq!(task.id, "Task1"); + assert_eq!(task.dependencies, vec!["Dep1", "Dep2"]); + assert_eq!(task.min_time, Duration::from_secs(1)); + assert_eq!(task.max_time, Duration::from_secs(5)); + } + + #[test] + fn test_task_creation_empty_dependencies() { + let task = Task::new( + "Task2", + vec![], + Duration::from_secs(2), + Duration::from_secs(10), + ); + + assert_eq!(task.id, "Task2"); + assert!(task.dependencies.is_empty()); + assert_eq!(task.min_time, Duration::from_secs(2)); + assert_eq!(task.max_time, Duration::from_secs(10)); + } + + #[test] + fn test_task_creation_same_min_max_time() { + let task = Task::new( + "Task3", + vec!["Dep3".to_string()], + Duration::from_secs(5), + Duration::from_secs(5), + ); + + assert_eq!(task.id, "Task3"); + assert_eq!(task.dependencies, vec!["Dep3"]); + assert_eq!(task.min_time, task.max_time); + } + + #[test] + fn test_task_clone() { + let task1 = Task::new( + "Task4", + vec!["Dep4".to_string()], + Duration::from_secs(3), + Duration::from_secs(7), + ); + let task2 = task1.clone(); + + assert_eq!(task1.id, task2.id); + assert_eq!(task1.dependencies, task2.dependencies); + assert_eq!(task1.min_time, task2.min_time); + assert_eq!(task1.max_time, task2.max_time); + } + + #[test] + fn test_days_to_duration() { + assert_duration_eq(days_to_duration(1.0), Duration::from_secs(24 * 60 * 60)); + assert_duration_eq(days_to_duration(7.0), Duration::from_secs(7 * 24 * 60 * 60)); + assert_duration_eq(days_to_duration(0.0), Duration::from_secs(0)); + assert_duration_eq( + days_to_duration(365.0), + Duration::from_secs(365 * 24 * 60 * 60), + ); + } + + #[test] + fn test_days_to_duration_fractional() { + assert_duration_eq(days_to_duration(0.5), Duration::from_secs(12 * 60 * 60)); + assert_duration_eq(days_to_duration(1.5), Duration::from_secs(36 * 60 * 60)); + assert_duration_eq(days_to_duration(0.25), Duration::from_secs(6 * 60 * 60)); + assert_duration_eq( + days_to_duration(0.1), + Duration::from_secs_f64(0.1 * 24.0 * 60.0 * 60.0), + ); + } + + #[test] + fn test_days_to_duration_large_number() { + let large_days = 1000.0; + let expected_seconds = large_days * 24.0 * 60.0 * 60.0; + assert_duration_eq( + days_to_duration(large_days), + Duration::from_secs_f64(expected_seconds), + ); + } + + #[test] + fn test_days_to_duration_very_small() { + let small_days = 1e-6; + let expected_seconds = small_days * 24.0 * 60.0 * 60.0; + assert_duration_eq( + days_to_duration(small_days), + Duration::from_secs_f64(expected_seconds), + ); + } +} diff --git a/tests/effort_is_correct.rs b/tests/effort_is_correct.rs new file mode 100644 index 0000000..2149f49 --- /dev/null +++ b/tests/effort_is_correct.rs @@ -0,0 +1,57 @@ +use mcps::{ + schedule::Schedule, + simulation::run_multiple_simulations, + task::{days_to_duration, Task}, +}; + +use std::time::Duration; + +#[test] +fn test_average_effort_calculation() { + let num_tasks = 10; + let min_days = 5.0; + let max_days = 15.0; + let expected_avg_days = 10.0; + let estimate_confidence = 0.8; + let num_simulations = 10000; + let num_workers = 1; // Using 1 worker to simplify effort calculation + + // Create tasks + let tasks: Vec = (0..num_tasks) + .map(|i| { + Task::new( + &format!("Task{}", i), + vec![], + days_to_duration(min_days), + days_to_duration(max_days), + ) + }) + .collect(); + + let schedule = + Schedule::new(tasks, num_workers, estimate_confidence).expect("Failed to create schedule"); + + let (_, effort_times) = run_multiple_simulations(&schedule, num_simulations); + + let total_effort: Duration = effort_times.iter().sum(); + let avg_effort = total_effort / num_simulations as u32; + let avg_effort_days = avg_effort.as_secs_f64() / 86400.0; + + println!( + "Average effort: {:.2} days (expected: {:.2} days)", + avg_effort_days, + expected_avg_days * num_tasks as f64 + ); + + // Check if the average effort is within 1% of the expected value + let expected_total_days = expected_avg_days * num_tasks as f64; + let difference_percentage = + (avg_effort_days - expected_total_days).abs() / expected_total_days * 100.0; + + assert!( + difference_percentage < 1.0, + "Average effort ({:.2} days) deviates more than 1% from expected ({:.2} days)", + avg_effort_days, + expected_total_days + ); +} diff --git a/tests/invalid_schedules.rs b/tests/invalid_schedules.rs new file mode 100644 index 0000000..646286a --- /dev/null +++ b/tests/invalid_schedules.rs @@ -0,0 +1,91 @@ +use mcps::{ + schedule::Schedule, + task::{days_to_duration, Task}, +}; + +fn create_schedule_and_expect_error(tasks: Vec, expected_error: &str) { + let result = Schedule::new(tasks, 1, 0.8); + + assert!( + result.is_err(), + "Expected schedule creation to fail, but it didn't" + ); + if let Err(error_message) = result { + assert!( + error_message.contains(expected_error), + "Expected error message '{}', but got '{}'", + expected_error, + error_message + ); + } +} + +#[test] +fn test_cyclic_dependency() { + let tasks = vec![ + Task::new( + "A", + vec!["B".to_string()], + days_to_duration(1.0), + days_to_duration(2.0), + ), + Task::new( + "B", + vec!["A".to_string()], + days_to_duration(1.0), + days_to_duration(2.0), + ), + ]; + + create_schedule_and_expect_error(tasks, "Cyclic dependency detected"); +} + +#[test] +fn test_missing_dependency() { + let tasks = vec![ + Task::new( + "A", + vec!["C".to_string()], + days_to_duration(1.0), + days_to_duration(2.0), + ), + Task::new( + "B", + vec!["A".to_string()], + days_to_duration(1.0), + days_to_duration(2.0), + ), + ]; + + create_schedule_and_expect_error(tasks, "Missing dependency"); +} + +#[test] +fn test_negative_duration() { + let tasks = vec![Task::new( + "A", + vec![], + days_to_duration(0.0), + days_to_duration(1.0), + )]; + + create_schedule_and_expect_error(tasks, "Invalid task duration"); +} + +#[test] +fn test_min_greater_than_max() { + let tasks = vec![Task::new( + "A", + vec![], + days_to_duration(3.0), + days_to_duration(2.0), + )]; + + create_schedule_and_expect_error(tasks, "Minimum duration greater than maximum"); +} + +#[test] +fn test_empty_task_list() { + let tasks = vec![]; + create_schedule_and_expect_error(tasks, "Empty task list"); +} diff --git a/tests/no_dependencies_schedule.rs b/tests/no_dependencies_schedule.rs new file mode 100644 index 0000000..bcbc59f --- /dev/null +++ b/tests/no_dependencies_schedule.rs @@ -0,0 +1,97 @@ +use mcps::{ + schedule::Schedule, + simulation::run_multiple_simulations, + task::{days_to_duration, Task}, +}; + +use std::time::Duration; + +fn create_independent_tasks(num_tasks: usize) -> Vec { + (0..num_tasks) + .map(|i| { + Task::new( + &format!("Task_{}", i), + vec![], + days_to_duration(1.0), + days_to_duration(3.0), + ) + }) + .collect() +} + +#[test] +fn test_no_dependencies_schedule() { + let num_tasks = 32; + let tasks = create_independent_tasks(num_tasks); + let estimate_confidence = 0.8; + let num_simulations = 1_000; + + let mut prev_avg_effort = 0.0; + + for num_workers in [1, 2, 4, 8] { + let schedule = Schedule::new(tasks.clone(), num_workers, estimate_confidence) + .expect("Failed to create schedule"); + let (project_durations, effort_times) = + run_multiple_simulations(&schedule, num_simulations); + + let avg_duration = project_durations.iter().sum::() / num_simulations as u32; + let avg_effort = effort_times.iter().sum::() / num_simulations as u32; + + let avg_duration_days = avg_duration.as_secs_f64() / 86400.0; + let avg_effort_days = avg_effort.as_secs_f64() / 86400.0; + + println!( + "Workers: {}, Avg Duration: {:.2} days, Avg Effort: {:.2} person-days", + num_workers, avg_duration_days, avg_effort_days + ); + + // Check that effort is consistent across different numbers of workers + if num_workers > 1 { + let effort_difference = (avg_effort_days - prev_avg_effort).abs(); + assert!( + effort_difference < 2.0, + "Effort should be consistent. Previous: {:.2}, Current: {:.2}", + prev_avg_effort, + avg_effort_days + ); + } + prev_avg_effort = avg_effort_days; + + // Check effort/duration ratio + let effort_ratio = avg_effort_days / avg_duration_days; + let expected_ratio = num_workers as f64; + let ratio_error = (effort_ratio - expected_ratio).abs() / expected_ratio; + + println!( + " Effort/Duration Ratio: {:.2} (Expected: {:.2}, Error: {:.2})", + effort_ratio, expected_ratio, ratio_error + ); + + assert!(ratio_error < 0.20, "Expected effort/duration ratio to be close to number of workers. Expected: {}, Actual: {}", expected_ratio, effort_ratio); + + // For 1 worker, duration should be very close to effort + if num_workers == 1 { + let duration_effort_ratio = avg_duration_days / avg_effort_days; + assert!( + (duration_effort_ratio - 1.0).abs() < 0.05, + "For single worker, duration should equal effort. Ratio: {}", + duration_effort_ratio + ); + } + + // Check that duration decreases proportionally to the increase in workers + if num_workers > 1 { + let expected_duration = avg_effort_days / num_workers as f64; + let actual_duration = avg_duration_days; + let duration_ratio = actual_duration / expected_duration; + let duration_error = (duration_ratio - 1.0).abs(); + + println!( + " Duration Ratio: {:.2} (Expected: {:.2}, Actual: {:.2}, Error: {:.2})", + duration_ratio, expected_duration, actual_duration, duration_error + ); + + assert!(duration_error < 0.20, "Expected duration to decrease proportionally with workers. Expected: {:.2}, Actual: {:.2}", expected_duration, actual_duration); + } + } +} diff --git a/tests/simulation_tests.rs b/tests/simulation_tests.rs new file mode 100644 index 0000000..bc52dd2 --- /dev/null +++ b/tests/simulation_tests.rs @@ -0,0 +1,91 @@ +use mcps::{ + schedule::Schedule, + simulation::run_multiple_simulations, + task::{days_to_duration, Task}, +}; + +use std::time::Duration; + +#[test] +fn test_simulation_with_different_worker_counts() { + let tasks = vec![ + Task::new("A", vec![], days_to_duration(1.0), days_to_duration(3.0)), + Task::new( + "B", + vec!["A".to_string()], + days_to_duration(2.0), + days_to_duration(4.0), + ), + Task::new( + "C", + vec!["A".to_string()], + days_to_duration(3.0), + days_to_duration(5.0), + ), + Task::new( + "D", + vec!["B".to_string(), "C".to_string()], + days_to_duration(2.0), + days_to_duration(6.0), + ), + Task::new( + "E", + vec!["B".to_string()], + days_to_duration(1.0), + days_to_duration(2.0), + ), + Task::new( + "F", + vec!["D".to_string(), "E".to_string()], + days_to_duration(2.0), + days_to_duration(4.0), + ), + ]; + + let estimate_confidence = 0.8; + let num_simulations = 1000; + + let mut all_efforts = Vec::new(); + + for num_workers in [1, 2, 4, 8, 16, 32] { + let schedule = Schedule::new(tasks.clone(), num_workers, estimate_confidence) + .expect("Failed to create schedule"); + let (project_durations, effort_times) = + run_multiple_simulations(&schedule, num_simulations); + + let avg_duration = project_durations.iter().sum::() / num_simulations as u32; + let avg_effort = effort_times.iter().sum::() / num_simulations as u32; + + println!( + "Workers: {}, Avg Duration: {:.2} days, Avg Effort: {:.2} person-days", + num_workers, + avg_duration.as_secs_f64() / 86400.0, + avg_effort.as_secs_f64() / 86400.0 + ); + + all_efforts.push(avg_effort); + + // Basic sanity checks + assert!( + avg_duration <= avg_effort, + "Average duration should not exceed average effort" + ); + if num_workers > 1 { + assert!( + avg_duration < avg_effort, + "With multiple workers, average duration should be less than average effort" + ); + } + } + + // Check that effort remains relatively constant regardless of worker count + let max_effort = all_efforts.iter().max().unwrap(); + let min_effort = all_efforts.iter().min().unwrap(); + let effort_difference = (max_effort.as_secs_f64() - min_effort.as_secs_f64()) / 86400.0; + + println!("Maximum effort difference: {:.2} days", effort_difference); + assert!( + effort_difference < 1.0, + "Average effort should not vary significantly with different worker counts" + ); +}