diff --git a/.gitignore b/.gitignore index 6808f71..f00db30 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ debug/ target/ +# cargo-mutants output (`just mutants` / `just mutants-diff`) +mutants.out*/ + # These are backup files generated by rustfmt **/*.rs.bk diff --git a/CHANGELOG.md b/CHANGELOG.md index f6279e1..87a38e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `oath-model` numeric primitives — the root contract's first real content: `Price` + (signed fixed-point `i128`), `Quantity` (unsigned `u128` magnitude), `Side` + (`Buy`/`Sell`), and `ArithmeticError`, with checked `const fn` add/sub that error + rather than wrap (ADR-0023/0027). Dropped `rust_decimal`, `uuid`, and `time` from + `oath-model`; added `proptest` and `serde_json` as dev-dependencies. - Cargo workspace scaffold (initial 10 domain crates; later restructured — see Changed). - Workspace-level lint configuration: `rustc`, `clippy` (all, pedantic, nursery, cargo, and selected restriction-group lints), with test-code exemptions via `.clippy.toml`. diff --git a/Cargo.lock b/Cargo.lock index a627694..db903a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,29 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "autocfg" version = "1.5.1" @@ -32,74 +9,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "bitvec" -version = "1.0.1" +name = "bit-set" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "funty", - "radium", - "tap", - "wyz", + "bit-vec", ] [[package]] -name = "borsh" -version = "1.6.1" +name = "bit-vec" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases", -] +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] -name = "borsh-derive" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" -dependencies = [ - "once_cell", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" +name = "bitflags" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bytes" @@ -114,37 +42,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "cfg_aliases" -version = "0.2.1" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "serde_core", + "libc", + "windows-sys", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "foldhash" -version = "0.1.5" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "futures-core" @@ -158,78 +75,29 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - [[package]] name = "getrandom" -version = "0.2.17" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "wasi", + "r-efi 5.3.0", + "wasip2", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", - "wasip2", - "wasip3", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", + "r-efi 6.0.0", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "http" version = "1.4.2" @@ -250,48 +118,12 @@ dependencies = [ "http", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "js-sys" -version = "0.3.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -299,22 +131,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "log" -version = "0.4.29" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "num-traits" @@ -427,11 +253,10 @@ dependencies = [ name = "oath-model" version = "0.1.0" dependencies = [ - "rust_decimal", + "proptest", "serde", + "serde_json", "thiserror", - "time", - "uuid", ] [[package]] @@ -471,18 +296,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -492,25 +305,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -521,24 +315,29 @@ dependencies = [ ] [[package]] -name = "ptr_meta" -version = "0.1.4" +name = "proptest" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "ptr_meta_derive", + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", ] [[package]] -name = "ptr_meta_derive" -version = "0.1.4" +name = "quick-error" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" @@ -551,32 +350,31 @@ dependencies = [ [[package]] name = "r-efi" -version = "6.0.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "radium" -version = "0.7.0" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "libc", "rand_chacha", "rand_core", ] [[package]] name = "rand_chacha" -version = "0.3.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core", @@ -584,86 +382,53 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.3.4", ] [[package]] -name = "rend" -version = "0.4.2" +name = "rand_xorshift" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "bytecheck", + "rand_core", ] [[package]] -name = "rkyv" -version = "0.7.46" +name = "regex-syntax" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "rkyv_derive" -version = "0.7.46" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] [[package]] -name = "rust_decimal" -version = "1.42.1" +name = "rusty-fork" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", + "fnv", + "quick-error", + "tempfile", + "wait-timeout", ] -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.228" @@ -691,7 +456,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -707,29 +472,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -742,10 +484,17 @@ dependencies = [ ] [[package]] -name = "tap" -version = "1.0.1" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] [[package]] name = "thiserror" @@ -764,83 +513,14 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] -name = "time" -version = "0.3.51" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" +name = "unarray" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow", -] +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" @@ -849,149 +529,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "uuid" -version = "1.23.3" +name = "wait-timeout" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", + "libc", ] -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "serde", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.122" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" -dependencies = [ - "unicode-ident", + "wit-bindgen", ] [[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "winnow" -version = "1.0.3" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", + "windows-link", ] [[package]] @@ -1000,112 +567,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6e35b52..aef5add 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,8 @@ serde = { version = "1", features = ["derive"] } thiserror = "2" tokio = { version = "1", features = ["full"] } tracing = "0.1" +proptest = "1" +serde_json = "1" # --- Lints --- # Centralized lint configuration inherited by all workspace crates via `[lints] workspace = true`. diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 3e8de8f..8a749f5 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -12,13 +12,6 @@ workspace = true serde = { workspace = true } thiserror = { workspace = true } -# Model-specific dependencies — isolated here intentionally. -# Other crates never depend on these directly; they consume newtypes from this crate. -# To swap the time or decimal backend, change only this file and its implementations. -rust_decimal = { version = "1", features = ["serde-with-str"] } -uuid = { version = "1", features = ["v4", "serde"] } -time = { version = "0.3", features = ["serde"] } - -# cargo-machete: deps declared ahead of first use; prune from `ignored` as they are adopted. -[package.metadata.cargo-machete] -ignored = ["rust_decimal", "serde", "thiserror", "time", "uuid"] +[dev-dependencies] +proptest = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/model/src/error.rs b/crates/model/src/error.rs new file mode 100644 index 0000000..8dce280 --- /dev/null +++ b/crates/model/src/error.rs @@ -0,0 +1,30 @@ +//! Error type for checked exact-domain arithmetic. + +use thiserror::Error; + +/// An error from a checked exact-domain arithmetic operation on a `Price` or +/// `Quantity`. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ArithmeticError { + /// The result exceeds the maximum representable value. + #[error("arithmetic overflow: result exceeds the representable maximum")] + Overflow, + /// The result is below the minimum representable value (for example, + /// subtracting a larger magnitude from a smaller `Quantity`). + #[error("arithmetic underflow: result is below the representable minimum")] + Underflow, +} + +#[cfg(test)] +mod tests { + use super::ArithmeticError; + + #[test] + fn variants_are_distinct() { + assert_ne!(ArithmeticError::Overflow, ArithmeticError::Underflow); + assert_ne!( + ArithmeticError::Overflow.to_string(), + ArithmeticError::Underflow.to_string() + ); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 9dc16d2..21c71be 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -1,2 +1,14 @@ -//! Shared domain primitives: `Symbol`, `Price`, `Quantity`, `Timestamp`, `Side`. +//! Root domain contract for OATH: the exact-domain numeric primitives +//! (`Price`, `Quantity`, `Side`) and the `ArithmeticError` their checked +//! operations return. #![forbid(unsafe_code)] + +mod error; +mod price; +mod quantity; +mod side; + +pub use error::ArithmeticError; +pub use price::Price; +pub use quantity::Quantity; +pub use side::Side; diff --git a/crates/model/src/price.rs b/crates/model/src/price.rs new file mode 100644 index 0000000..baa434e --- /dev/null +++ b/crates/model/src/price.rs @@ -0,0 +1,151 @@ +//! Signed fixed-point `Price`. + +use serde::{Deserialize, Serialize}; + +use crate::error::ArithmeticError; + +/// A signed, fixed-point price carried as a raw `i128` scaled integer. +/// +/// `Price` is precision-free: the scale (tick size) is instrument metadata, not +/// stored here. Negative prices are valid (spreads, basis instruments). +/// +/// # Ordering invariant +/// +/// Comparison is meaningful **only** among prices of the same instrument and +/// precision. `Ord` is derived because same-instrument book and limit logic needs +/// it, but comparing prices of different instruments compiles and means nothing. +/// +/// ``` +/// use oath_model::Price; +/// let p = Price::from_raw(12_345); +/// assert_eq!(p.raw(), 12_345); +/// assert_eq!(Price::from_raw(10).checked_add(Price::from_raw(5)), Ok(Price::from_raw(15))); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Price(i128); + +impl Price { + /// Wraps a raw scaled integer as a `Price`. + #[must_use] + pub const fn from_raw(raw: i128) -> Self { + Self(raw) + } + + /// Returns the raw scaled integer. + #[must_use] + pub const fn raw(self) -> i128 { + self.0 + } + + /// Adds two prices, returning an error on overflow or underflow rather than + /// wrapping or panicking. + /// + /// # Errors + /// + /// Returns [`ArithmeticError::Overflow`] or [`ArithmeticError::Underflow`] if + /// the result is out of `i128` range. + pub const fn checked_add(self, rhs: Self) -> Result { + match self.0.checked_add(rhs.0) { + Some(v) => Ok(Self(v)), + None if self.0 > 0 => Err(ArithmeticError::Overflow), + None => Err(ArithmeticError::Underflow), + } + } + + /// Subtracts one price from another, returning an error on overflow or + /// underflow rather than wrapping or panicking. + /// + /// # Errors + /// + /// Returns [`ArithmeticError::Overflow`] or [`ArithmeticError::Underflow`] if + /// the result is out of `i128` range. + pub const fn checked_sub(self, rhs: Self) -> Result { + match self.0.checked_sub(rhs.0) { + Some(v) => Ok(Self(v)), + None if rhs.0 < 0 => Err(ArithmeticError::Overflow), + None => Err(ArithmeticError::Underflow), + } + } +} + +#[cfg(test)] +mod tests { + use super::Price; + use crate::error::ArithmeticError; + use proptest::prelude::*; + + #[test] + fn add_and_sub_happy_path() { + assert_eq!( + Price::from_raw(10).checked_add(Price::from_raw(5)), + Ok(Price::from_raw(15)) + ); + assert_eq!( + Price::from_raw(10).checked_sub(Price::from_raw(5)), + Ok(Price::from_raw(5)) + ); + } + + #[test] + fn add_overflow_and_underflow() { + assert_eq!( + Price::from_raw(i128::MAX).checked_add(Price::from_raw(1)), + Err(ArithmeticError::Overflow) + ); + assert_eq!( + Price::from_raw(i128::MIN).checked_add(Price::from_raw(-1)), + Err(ArithmeticError::Underflow) + ); + } + + #[test] + fn sub_min_boundary() { + assert_eq!( + Price::from_raw(0).checked_sub(Price::from_raw(i128::MIN)), + Err(ArithmeticError::Overflow) + ); + assert_eq!( + Price::from_raw(i128::MIN).checked_sub(Price::from_raw(1)), + Err(ArithmeticError::Underflow) + ); + } + + #[test] + fn ordering_matches_integers() { + assert!(Price::from_raw(-1) < Price::from_raw(0)); + assert!(Price::from_raw(0) < Price::from_raw(1)); + } + + proptest! { + #[test] + fn raw_round_trip(x in any::()) { + prop_assert_eq!(Price::from_raw(x).raw(), x); + } + + #[test] + fn add_sub_inverse(a in any::(), b in any::()) { + let pa = Price::from_raw(a); + let pb = Price::from_raw(b); + if let Ok(sum) = pa.checked_add(pb) { + prop_assert_eq!(sum.checked_sub(pb), Ok(pa)); + } + } + + #[test] + fn add_commutative(a in any::(), b in any::()) { + prop_assert_eq!( + Price::from_raw(a).checked_add(Price::from_raw(b)), + Price::from_raw(b).checked_add(Price::from_raw(a)) + ); + } + + #[test] + fn json_round_trip(x in any::()) { + let p = Price::from_raw(x); + let back = serde_json::to_string(&p) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + prop_assert_eq!(back, Some(p)); + } + } +} diff --git a/crates/model/src/quantity.rs b/crates/model/src/quantity.rs new file mode 100644 index 0000000..b99e842 --- /dev/null +++ b/crates/model/src/quantity.rs @@ -0,0 +1,125 @@ +//! Unsigned fixed-point `Quantity`. + +use serde::{Deserialize, Serialize}; + +use crate::error::ArithmeticError; + +/// An unsigned, fixed-point quantity carried as a raw `u128` scaled integer. +/// +/// A `Quantity` is a **magnitude**: direction lives in [`Side`], and signed +/// exposure is derived in a position, never stored here. The unsigned inner type +/// makes a negative quantity unrepresentable by construction. +/// +/// [`Side`]: crate::Side +/// +/// ``` +/// use oath_model::Quantity; +/// let q = Quantity::from_raw(100); +/// assert_eq!(q.raw(), 100); +/// assert_eq!(Quantity::from_raw(5).checked_sub(Quantity::from_raw(3)), Ok(Quantity::from_raw(2))); +/// assert!(Quantity::from_raw(3).checked_sub(Quantity::from_raw(5)).is_err()); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Quantity(u128); + +impl Quantity { + /// Wraps a raw scaled integer as a `Quantity`. + #[must_use] + pub const fn from_raw(raw: u128) -> Self { + Self(raw) + } + + /// Returns the raw scaled integer. + #[must_use] + pub const fn raw(self) -> u128 { + self.0 + } + + /// Adds two quantities, returning an error on overflow rather than wrapping + /// or panicking. + /// + /// # Errors + /// + /// Returns [`ArithmeticError::Overflow`] if the result exceeds `u128::MAX`. + pub const fn checked_add(self, rhs: Self) -> Result { + match self.0.checked_add(rhs.0) { + Some(v) => Ok(Self(v)), + None => Err(ArithmeticError::Overflow), + } + } + + /// Subtracts one quantity from another, returning an error on underflow rather + /// than wrapping or panicking. + /// + /// # Errors + /// + /// Returns [`ArithmeticError::Underflow`] if `rhs` is greater than `self` + /// (a quantity is a magnitude and cannot go negative). + pub const fn checked_sub(self, rhs: Self) -> Result { + match self.0.checked_sub(rhs.0) { + Some(v) => Ok(Self(v)), + None => Err(ArithmeticError::Underflow), + } + } +} + +#[cfg(test)] +mod tests { + use super::Quantity; + use crate::error::ArithmeticError; + use proptest::prelude::*; + + #[test] + fn add_and_sub_happy_path() { + assert_eq!( + Quantity::from_raw(10).checked_add(Quantity::from_raw(5)), + Ok(Quantity::from_raw(15)) + ); + assert_eq!( + Quantity::from_raw(10).checked_sub(Quantity::from_raw(5)), + Ok(Quantity::from_raw(5)) + ); + } + + #[test] + fn add_overflow() { + assert_eq!( + Quantity::from_raw(u128::MAX).checked_add(Quantity::from_raw(1)), + Err(ArithmeticError::Overflow) + ); + } + + #[test] + fn sub_underflow() { + assert_eq!( + Quantity::from_raw(0).checked_sub(Quantity::from_raw(1)), + Err(ArithmeticError::Underflow) + ); + } + + proptest! { + #[test] + fn raw_round_trip(x in any::()) { + prop_assert_eq!(Quantity::from_raw(x).raw(), x); + } + + #[test] + fn sub_underflows_iff_rhs_greater(a in any::(), b in any::()) { + let result = Quantity::from_raw(a).checked_sub(Quantity::from_raw(b)); + if b > a { + prop_assert_eq!(result, Err(ArithmeticError::Underflow)); + } else { + prop_assert_eq!(result, Ok(Quantity::from_raw(a - b))); + } + } + + #[test] + fn json_round_trip(x in any::()) { + let q = Quantity::from_raw(x); + let back = serde_json::to_string(&q) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + prop_assert_eq!(back, Some(q)); + } + } +} diff --git a/crates/model/src/side.rs b/crates/model/src/side.rs new file mode 100644 index 0000000..78f69ee --- /dev/null +++ b/crates/model/src/side.rs @@ -0,0 +1,57 @@ +//! Order `Side` — the single source of truth for direction. + +use serde::{Deserialize, Serialize}; + +/// The direction of an order or position: buy or sell. +/// +/// `Side` is the single source of truth for direction; `Quantity` stays a +/// magnitude and never encodes sign. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Side { + /// A buy: increases a long position. + Buy, + /// A sell: increases a short position. + Sell, +} + +impl Side { + /// Returns the opposite side. + #[must_use] + pub const fn opposite(self) -> Self { + match self { + Self::Buy => Self::Sell, + Self::Sell => Self::Buy, + } + } +} + +#[cfg(test)] +mod tests { + use super::Side; + use proptest::prelude::*; + + #[test] + fn opposite_maps_buy_and_sell() { + assert_eq!(Side::Buy.opposite(), Side::Sell); + assert_eq!(Side::Sell.opposite(), Side::Buy); + } + + #[test] + fn serializes_to_expected_shape() { + assert_eq!( + serde_json::to_string(&Side::Buy).as_deref().ok(), + Some("\"Buy\"") + ); + assert_eq!( + serde_json::to_string(&Side::Sell).as_deref().ok(), + Some("\"Sell\"") + ); + } + + proptest! { + #[test] + fn opposite_is_an_involution(s in prop_oneof![Just(Side::Buy), Just(Side::Sell)]) { + prop_assert_eq!(s.opposite().opposite(), s); + } + } +} diff --git a/docs/superpowers/plans/2026-06-28-oath-model-numeric-primitives.md b/docs/superpowers/plans/2026-06-28-oath-model-numeric-primitives.md new file mode 100644 index 0000000..a876888 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-oath-model-numeric-primitives.md @@ -0,0 +1,815 @@ +# oath-model Numeric Primitives Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `oath-model` its first real content — the exact-domain numeric primitives `Price`, `Quantity`, `Side`, and their `ArithmeticError`. + +**Architecture:** One module per primitive (`error.rs`, `price.rs`, `quantity.rs`, `side.rs`) re-exported from `lib.rs`. Newtypes over raw `i128`/`u128`; no bare arithmetic operators — only checked, `const fn` add/sub that error rather than wrap. `serde` is the universal bound; no zero-copy/POD layout in this slice. + +**Tech Stack:** Rust 2024 (MSRV 1.90), `serde` (derive), `thiserror`; dev-only `proptest` + `serde_json`; tooling via `just` (`cargo nextest`, `cargo clippy`, `cargo deny`, `cargo machete`, `cargo mutants`, `taplo`). + +**Spec:** [docs/superpowers/specs/2026-06-28-oath-model-numeric-primitives-design.md](../specs/2026-06-28-oath-model-numeric-primitives-design.md) + +## Working context + +- All work happens in the **existing worktree** `.claude/worktrees/oath-model-numeric-primitives` on branch `feat/oath-model-numeric-primitives` (already created off `main`). Do **not** switch the primary checkout's branch. +- Open a GitHub issue (label `enhancement`) for this slice; the final PR references it with `Closes #N`. +- All code in this plan is **already prototyped and verified** against a faithful copy of the workspace lints, `cargo deny`, tests, doctests, and `rustfmt` — transcribe it verbatim. + +## Global Constraints + +- **Edition 2024, MSRV 1.90** — no APIs newer than 1.90 (`clippy.toml` pins `msrv = "1.90"`). +- **No `unsafe`** — `lib.rs` keeps `#![forbid(unsafe_code)]`. +- **No bare `+ - * /` on `Price`/`Quantity`** — only checked functions that return `Result` and **error rather than wrap**. +- **`clippy` runs as `-D warnings` with `pedantic` + `nursery` + `cargo` enabled** — so `#[must_use]`, `const fn`, `# Errors` doc sections, and back-ticked doc identifiers are mandatory, not optional. The prototyped code already satisfies this. +- **`missing_docs`** — every public item (including enum variants) carries a doc comment. +- **`unwrap`/`expect`/indexing** are denied in non-test code, but **allowed in tests** (`clippy.toml`: `allow-unwrap-in-tests`, `allow-expect-in-tests`, `allow-indexing-slicing-in-tests`). The tests here are written without them anyway. +- **`serde` is the universal bound** — every type derives `Serialize`/`Deserialize`. No custom serialization trait, no `repr`/`zerocopy` in this slice. +- **Conventional Commits** (enforced by `commit-msg` hook). The `pre-commit` hook runs `fmt`, `fmt-toml`, `typos`, `lint`, `test-no-run` on every commit; `just doc`, `cargo deny`, `cargo machete`, and the full test run happen at `pre-push`/Task 6. +- **Definition of done:** `just ci` green **and** `just mutants-diff` shows zero surviving mutants; one issue → one PR. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `crates/model/Cargo.toml` | Manifest: drop `rust_decimal`/`uuid`/`time`; add dev-deps `proptest`, `serde_json` | +| `Cargo.toml` (root) | Add `proptest`, `serde_json` to `[workspace.dependencies]` | +| `crates/model/src/lib.rs` | Crate docs, `mod` declarations, `pub use` re-exports | +| `crates/model/src/error.rs` | `ArithmeticError` | +| `crates/model/src/side.rs` | `Side` + tests | +| `crates/model/src/quantity.rs` | `Quantity` + tests | +| `crates/model/src/price.rs` | `Price` + tests | + +--- + +## Task 1: Reconcile the manifest — drop ADR-removed dependencies + +**Files:** + +- Modify: `crates/model/Cargo.toml` + +**Interfaces:** + +- Consumes: nothing. +- Produces: a manifest with only `serde` + `thiserror` as dependencies (both still unused, kept in the `cargo-machete` ignore list). + +- [ ] **Step 1: Edit `crates/model/Cargo.toml`** + +Replace the `[dependencies]` section and the model-specific block so the file reads exactly: + +```toml +[package] +name = "oath-model" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } + +# cargo-machete: deps declared ahead of first use; prune from `ignored` as they are adopted. +[package.metadata.cargo-machete] +ignored = ["serde", "thiserror"] +``` + +(This removes `rust_decimal`, `uuid`, `time` and their explanatory comment block, and drops them from the `ignored` list.) + +- [ ] **Step 2: Regenerate the lockfile (dependencies were removed)** + +Removing dependencies makes `Cargo.lock` stale, and the pre-commit hook runs `cargo check --locked`, which **fails** on a stale lock. Regenerate it: + +Run: `cargo check -p oath-model` +Expected: compiles; `git status --porcelain` now shows **both** `crates/model/Cargo.toml` and `Cargo.lock` modified (the orphaned `rust_decimal`/`uuid`/`time` entries and their unique transitive deps are dropped from the lock). + +- [ ] **Step 3: Verify machete, locked build, and formatting** + +Run: `cargo machete && cargo check -p oath-model --locked && taplo fmt --check` +Expected: `cargo machete` reports no unused dependencies (`serde`/`thiserror` are ignore-listed); `cargo check --locked` now **passes** (the lock is back in sync); no taplo diff. + +- [ ] **Step 4: Commit (include `Cargo.lock`)** + +```bash +git add crates/model/Cargo.toml Cargo.lock +git commit -m "chore(model): drop rust_decimal, uuid, time deps (ADR-0023/0027)" +``` + +--- + +## Task 2: `ArithmeticError` + +**Files:** + +- Create: `crates/model/src/error.rs` +- Modify: `crates/model/src/lib.rs` +- Modify: `crates/model/Cargo.toml` + +**Interfaces:** + +- Consumes: nothing. +- Produces: `pub enum ArithmeticError { Overflow, Underflow }` (derives `Debug, Error, Clone, Copy, PartialEq, Eq, Hash`). Used by `Price`/`Quantity` checked operations in Tasks 4–5. + +- [ ] **Step 1: Write the failing test + module file** + +Create `crates/model/src/error.rs`: + +```rust +//! Error type for checked exact-domain arithmetic. + +use thiserror::Error; + +/// An error from a checked exact-domain arithmetic operation on a `Price` or +/// `Quantity`. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ArithmeticError { + /// The result exceeds the maximum representable value. + #[error("arithmetic overflow: result exceeds the representable maximum")] + Overflow, + /// The result is below the minimum representable value (for example, + /// subtracting a larger magnitude from a smaller `Quantity`). + #[error("arithmetic underflow: result is below the representable minimum")] + Underflow, +} + +#[cfg(test)] +mod tests { + use super::ArithmeticError; + + #[test] + fn variants_are_distinct() { + assert_ne!(ArithmeticError::Overflow, ArithmeticError::Underflow); + assert_ne!( + ArithmeticError::Overflow.to_string(), + ArithmeticError::Underflow.to_string() + ); + } +} +``` + +Replace the entire contents of `crates/model/src/lib.rs` with: + +```rust +//! Root domain contract for OATH: the exact-domain numeric primitives +//! (`Price`, `Quantity`, `Side`) and the `ArithmeticError` their checked +//! operations return. +#![forbid(unsafe_code)] + +mod error; + +pub use error::ArithmeticError; +``` + +- [ ] **Step 2: Make `thiserror` an active dependency** + +In `crates/model/Cargo.toml`, change the `cargo-machete` ignore list to drop `thiserror` (now used): + +```toml +[package.metadata.cargo-machete] +ignored = ["serde"] +``` + +- [ ] **Step 3: Run the test to verify it passes** + +Run: `cargo test -p oath-model error::` +Expected: `test result: ok. 1 passed`. + +- [ ] **Step 4: Verify lint + format** + +Run: `just lint && cargo fmt --all -- --check && taplo fmt --check` +Expected: no warnings, no diffs. + +- [ ] **Step 5: Commit** + +```bash +git add crates/model/src/error.rs crates/model/src/lib.rs crates/model/Cargo.toml +git commit -m "feat(model): add ArithmeticError" +``` + +--- + +## Task 3: `Side` + dev-dependency setup + +**Files:** + +- Create: `crates/model/src/side.rs` +- Modify: `crates/model/src/lib.rs` +- Modify: `crates/model/Cargo.toml` +- Modify: `Cargo.toml` (root) + +**Interfaces:** + +- Consumes: nothing. +- Produces: `pub enum Side { Buy, Sell }` with `pub const fn opposite(self) -> Side` (derives `Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize`). + +- [ ] **Step 1: Add `proptest` + `serde_json` to the workspace** + +In the root `Cargo.toml`, append these two lines to the end of the `[workspace.dependencies]` block (right after `tracing = "0.1"`): + +```toml +proptest = "1" +serde_json = "1" +``` + +In `crates/model/Cargo.toml`, add a `[dev-dependencies]` section and **delete** the `[package.metadata.cargo-machete]` block entirely (after this task `serde` is used by `Side`, so nothing remains to ignore). The file becomes: + +```toml +[package] +name = "oath-model" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +serde_json = { workspace = true } +``` + +- [ ] **Step 2: Write the failing test + module file** + +Create `crates/model/src/side.rs` with the test module **only** (no `Side` type yet) so the build fails: + +```rust +//! Order `Side` — the single source of truth for direction. + +#[cfg(test)] +mod tests { + use super::Side; + use proptest::prelude::*; + + #[test] + fn opposite_maps_buy_and_sell() { + assert_eq!(Side::Buy.opposite(), Side::Sell); + assert_eq!(Side::Sell.opposite(), Side::Buy); + } + + #[test] + fn serializes_to_expected_shape() { + assert_eq!( + serde_json::to_string(&Side::Buy).ok(), + Some("\"Buy\"".to_owned()) + ); + assert_eq!( + serde_json::to_string(&Side::Sell).ok(), + Some("\"Sell\"".to_owned()) + ); + } + + proptest! { + #[test] + fn opposite_is_an_involution(s in prop_oneof![Just(Side::Buy), Just(Side::Sell)]) { + prop_assert_eq!(s.opposite().opposite(), s); + } + } +} +``` + +Update `crates/model/src/lib.rs` to (note alphabetical `mod`/`pub use` ordering, which `rustfmt` enforces): + +```rust +//! Root domain contract for OATH: the exact-domain numeric primitives +//! (`Price`, `Quantity`, `Side`) and the `ArithmeticError` their checked +//! operations return. +#![forbid(unsafe_code)] + +mod error; +mod side; + +pub use error::ArithmeticError; +pub use side::Side; +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cargo test -p oath-model side::` +Expected: FAIL — compile error `cannot find type \`Side\` in this scope` (and an unresolved `pub use side::Side`). + +- [ ] **Step 4: Implement `Side`** + +Insert the implementation at the **top** of `crates/model/src/side.rs`, above the `#[cfg(test)]` module (keep the existing `//!` line at the very top): + +```rust +use serde::{Deserialize, Serialize}; + +/// The direction of an order or position: buy or sell. +/// +/// `Side` is the single source of truth for direction; `Quantity` stays a +/// magnitude and never encodes sign. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Side { + /// A buy: increases a long position. + Buy, + /// A sell: increases a short position. + Sell, +} + +impl Side { + /// Returns the opposite side. + #[must_use] + pub const fn opposite(self) -> Self { + match self { + Self::Buy => Self::Sell, + Self::Sell => Self::Buy, + } + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cargo test -p oath-model side::` +Expected: `test result: ok. 3 passed`. + +- [ ] **Step 6: Verify lint, format, and locked build** + +Run: `just lint && cargo fmt --all -- --check && taplo fmt --check && cargo check -p oath-model --locked` +Expected: no warnings, no diffs. The `cargo test` run in Step 5 already pulled `proptest`/`serde_json` into `Cargo.lock`, so `cargo check --locked` passes (lock in sync). `git status --porcelain` shows `Cargo.lock` modified — it **must** be committed. + +- [ ] **Step 7: Commit (include `Cargo.lock`)** + +```bash +git add Cargo.toml Cargo.lock crates/model/Cargo.toml crates/model/src/side.rs crates/model/src/lib.rs +git commit -m "feat(model): add Side direction enum" +``` + +--- + +## Task 4: `Quantity` + +**Files:** + +- Create: `crates/model/src/quantity.rs` +- Modify: `crates/model/src/lib.rs` + +**Interfaces:** + +- Consumes: `ArithmeticError` (Task 2). +- Produces: `pub struct Quantity(u128)` with `from_raw(u128) -> Quantity`, `raw(self) -> u128` (both `const`), `checked_add(self, Quantity) -> Result`, `checked_sub(self, Quantity) -> Result` (both `const`; derives `Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize`). + +- [ ] **Step 1: Write the failing test + module file** + +Create `crates/model/src/quantity.rs` with the test module **only**: + +```rust +//! Unsigned fixed-point `Quantity`. + +#[cfg(test)] +mod tests { + use super::Quantity; + use crate::error::ArithmeticError; + use proptest::prelude::*; + + #[test] + fn add_and_sub_happy_path() { + assert_eq!( + Quantity::from_raw(10).checked_add(Quantity::from_raw(5)), + Ok(Quantity::from_raw(15)) + ); + assert_eq!( + Quantity::from_raw(10).checked_sub(Quantity::from_raw(5)), + Ok(Quantity::from_raw(5)) + ); + } + + #[test] + fn add_overflow() { + assert_eq!( + Quantity::from_raw(u128::MAX).checked_add(Quantity::from_raw(1)), + Err(ArithmeticError::Overflow) + ); + } + + #[test] + fn sub_underflow() { + assert_eq!( + Quantity::from_raw(0).checked_sub(Quantity::from_raw(1)), + Err(ArithmeticError::Underflow) + ); + } + + proptest! { + #[test] + fn raw_round_trip(x in any::()) { + prop_assert_eq!(Quantity::from_raw(x).raw(), x); + } + + #[test] + fn sub_underflows_iff_rhs_greater(a in any::(), b in any::()) { + let result = Quantity::from_raw(a).checked_sub(Quantity::from_raw(b)); + if b > a { + prop_assert_eq!(result, Err(ArithmeticError::Underflow)); + } else { + prop_assert_eq!(result, Ok(Quantity::from_raw(a - b))); + } + } + + #[test] + fn json_round_trip(x in any::()) { + let q = Quantity::from_raw(x); + let back = serde_json::to_string(&q) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + prop_assert_eq!(back, Some(q)); + } + } +} +``` + +Update `crates/model/src/lib.rs` (insert `quantity` in alphabetical position): + +```rust +//! Root domain contract for OATH: the exact-domain numeric primitives +//! (`Price`, `Quantity`, `Side`) and the `ArithmeticError` their checked +//! operations return. +#![forbid(unsafe_code)] + +mod error; +mod quantity; +mod side; + +pub use error::ArithmeticError; +pub use quantity::Quantity; +pub use side::Side; +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p oath-model quantity::` +Expected: FAIL — compile error `cannot find type \`Quantity\``. + +- [ ] **Step 3: Implement `Quantity`** + +Insert at the top of `crates/model/src/quantity.rs`, below the `//!` line and above the test module: + +```rust +use serde::{Deserialize, Serialize}; + +use crate::error::ArithmeticError; + +/// An unsigned, fixed-point quantity carried as a raw `u128` scaled integer. +/// +/// A `Quantity` is a **magnitude**: direction lives in [`Side`], and signed +/// exposure is derived in a position, never stored here. The unsigned inner type +/// makes a negative quantity unrepresentable by construction. +/// +/// [`Side`]: crate::Side +/// +/// ``` +/// use oath_model::Quantity; +/// let q = Quantity::from_raw(100); +/// assert_eq!(q.raw(), 100); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Quantity(u128); + +impl Quantity { + /// Wraps a raw scaled integer as a `Quantity`. + #[must_use] + pub const fn from_raw(raw: u128) -> Self { + Self(raw) + } + + /// Returns the raw scaled integer. + #[must_use] + pub const fn raw(self) -> u128 { + self.0 + } + + /// Adds two quantities, returning an error on overflow instead of wrapping. + /// + /// # Errors + /// + /// Returns [`ArithmeticError::Overflow`] if the result exceeds `u128::MAX`. + pub const fn checked_add(self, rhs: Self) -> Result { + match self.0.checked_add(rhs.0) { + Some(v) => Ok(Self(v)), + None => Err(ArithmeticError::Overflow), + } + } + + /// Subtracts one quantity from another. + /// + /// # Errors + /// + /// Returns [`ArithmeticError::Underflow`] if `rhs` is greater than `self` + /// (a quantity is a magnitude and cannot go negative). + pub const fn checked_sub(self, rhs: Self) -> Result { + match self.0.checked_sub(rhs.0) { + Some(v) => Ok(Self(v)), + None => Err(ArithmeticError::Underflow), + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p oath-model quantity::` +Expected: `test result: ok. 6 passed` (`add_and_sub_happy_path`, `add_overflow`, `sub_underflow`, `raw_round_trip`, `sub_underflows_iff_rhs_greater`, `json_round_trip`). + +- [ ] **Step 5: Verify lint + format** + +Run: `just lint && cargo fmt --all -- --check` +Expected: no warnings, no diffs. + +- [ ] **Step 6: Commit** + +```bash +git add crates/model/src/quantity.rs crates/model/src/lib.rs +git commit -m "feat(model): add Quantity magnitude newtype" +``` + +--- + +## Task 5: `Price` + +**Files:** + +- Create: `crates/model/src/price.rs` +- Modify: `crates/model/src/lib.rs` + +**Interfaces:** + +- Consumes: `ArithmeticError` (Task 2). +- Produces: `pub struct Price(i128)` with `from_raw(i128) -> Price`, `raw(self) -> i128` (both `const`), `checked_add(self, Price) -> Result`, `checked_sub(self, Price) -> Result` (both `const`; derives `Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize`). + +- [ ] **Step 1: Write the failing test + module file** + +Create `crates/model/src/price.rs` with the test module **only**: + +```rust +//! Signed fixed-point `Price`. + +#[cfg(test)] +mod tests { + use super::Price; + use crate::error::ArithmeticError; + use proptest::prelude::*; + + #[test] + fn add_and_sub_happy_path() { + assert_eq!( + Price::from_raw(10).checked_add(Price::from_raw(5)), + Ok(Price::from_raw(15)) + ); + assert_eq!( + Price::from_raw(10).checked_sub(Price::from_raw(5)), + Ok(Price::from_raw(5)) + ); + } + + #[test] + fn add_overflow_and_underflow() { + assert_eq!( + Price::from_raw(i128::MAX).checked_add(Price::from_raw(1)), + Err(ArithmeticError::Overflow) + ); + assert_eq!( + Price::from_raw(i128::MIN).checked_add(Price::from_raw(-1)), + Err(ArithmeticError::Underflow) + ); + } + + #[test] + fn sub_min_boundary() { + assert_eq!( + Price::from_raw(0).checked_sub(Price::from_raw(i128::MIN)), + Err(ArithmeticError::Overflow) + ); + assert_eq!( + Price::from_raw(i128::MIN).checked_sub(Price::from_raw(1)), + Err(ArithmeticError::Underflow) + ); + } + + #[test] + fn ordering_matches_integers() { + assert!(Price::from_raw(-1) < Price::from_raw(0)); + assert!(Price::from_raw(0) < Price::from_raw(1)); + } + + proptest! { + #[test] + fn raw_round_trip(x in any::()) { + prop_assert_eq!(Price::from_raw(x).raw(), x); + } + + #[test] + fn add_sub_inverse(a in any::(), b in any::()) { + let pa = Price::from_raw(a); + let pb = Price::from_raw(b); + if let Ok(sum) = pa.checked_add(pb) { + prop_assert_eq!(sum.checked_sub(pb), Ok(pa)); + } + } + + #[test] + fn add_commutative(a in any::(), b in any::()) { + prop_assert_eq!( + Price::from_raw(a).checked_add(Price::from_raw(b)), + Price::from_raw(b).checked_add(Price::from_raw(a)) + ); + } + + #[test] + fn json_round_trip(x in any::()) { + let p = Price::from_raw(x); + let back = serde_json::to_string(&p) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + prop_assert_eq!(back, Some(p)); + } + } +} +``` + +Update `crates/model/src/lib.rs` (insert `price` in alphabetical position — this is the final form): + +```rust +//! Root domain contract for OATH: the exact-domain numeric primitives +//! (`Price`, `Quantity`, `Side`) and the `ArithmeticError` their checked +//! operations return. +#![forbid(unsafe_code)] + +mod error; +mod price; +mod quantity; +mod side; + +pub use error::ArithmeticError; +pub use price::Price; +pub use quantity::Quantity; +pub use side::Side; +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p oath-model price::` +Expected: FAIL — compile error `cannot find type \`Price\``. + +- [ ] **Step 3: Implement `Price`** + +Insert at the top of `crates/model/src/price.rs`, below the `//!` line and above the test module. Note the sign-mapping in the `None` arms — this is what the `i128::MIN` boundary tests guard: + +```rust +use serde::{Deserialize, Serialize}; + +use crate::error::ArithmeticError; + +/// A signed, fixed-point price carried as a raw `i128` scaled integer. +/// +/// `Price` is precision-free: the scale (tick size) is instrument metadata, not +/// stored here. Negative prices are valid (spreads, basis instruments). +/// +/// # Ordering invariant +/// +/// Comparison is meaningful **only** among prices of the same instrument and +/// precision. `Ord` is derived because same-instrument book and limit logic needs +/// it, but comparing prices of different instruments compiles and means nothing. +/// +/// ``` +/// use oath_model::Price; +/// let p = Price::from_raw(12_345); +/// assert_eq!(p.raw(), 12_345); +/// assert_eq!(Price::from_raw(10).checked_add(Price::from_raw(5)), Ok(Price::from_raw(15))); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Price(i128); + +impl Price { + /// Wraps a raw scaled integer as a `Price`. + #[must_use] + pub const fn from_raw(raw: i128) -> Self { + Self(raw) + } + + /// Returns the raw scaled integer. + #[must_use] + pub const fn raw(self) -> i128 { + self.0 + } + + /// Adds two prices, returning an error on overflow instead of wrapping. + /// + /// # Errors + /// + /// Returns [`ArithmeticError::Overflow`] or [`ArithmeticError::Underflow`] if + /// the result is out of `i128` range. + pub const fn checked_add(self, rhs: Self) -> Result { + match self.0.checked_add(rhs.0) { + Some(v) => Ok(Self(v)), + None if self.0 > 0 => Err(ArithmeticError::Overflow), + None => Err(ArithmeticError::Underflow), + } + } + + /// Subtracts one price from another, returning an error on overflow. + /// + /// # Errors + /// + /// Returns [`ArithmeticError::Overflow`] or [`ArithmeticError::Underflow`] if + /// the result is out of `i128` range. + pub const fn checked_sub(self, rhs: Self) -> Result { + match self.0.checked_sub(rhs.0) { + Some(v) => Ok(Self(v)), + None if rhs.0 < 0 => Err(ArithmeticError::Overflow), + None => Err(ArithmeticError::Underflow), + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p oath-model price::` +Expected: `test result: ok. 8 passed` (`add_and_sub_happy_path`, `add_overflow_and_underflow`, `sub_min_boundary`, `ordering_matches_integers`, `raw_round_trip`, `add_sub_inverse`, `add_commutative`, `json_round_trip`). + +- [ ] **Step 5: Run the doctest** + +Run: `cargo test -p oath-model --doc` +Expected: `test result: ok. 2 passed` (the `Price` and `Quantity` doc examples). + +- [ ] **Step 6: Verify lint + format** + +Run: `just lint && cargo fmt --all -- --check` +Expected: no warnings, no diffs. + +- [ ] **Step 7: Commit** + +```bash +git add crates/model/src/price.rs crates/model/src/lib.rs +git commit -m "feat(model): add Price fixed-point newtype" +``` + +--- + +## Task 6: Full verification + mutation testing + PR + +**Files:** none (verification gate). Adds a test only if a mutant survives. + +**Interfaces:** + +- Consumes: everything from Tasks 1–5. +- Produces: a green `just ci`, zero surviving mutants, and an open PR. + +- [ ] **Step 1: Run the full CI gate** + +Run: `just ci` +Expected: every stage green — `fmt`, `fmt-toml`, `typos`, `lint`, `check`, `test` (unit + doctests), `deny`, `doc`, `machete`, `gitleaks`, `actionlint`, `shellcheck`. In particular `cargo machete` passes (no ignore list remains) and `just doc` resolves all cross-type intra-doc links now that every primitive exists. + +- [ ] **Step 2: Run mutation testing on the diff** + +Run: `just mutants-diff` +Expected: `cargo mutants` reports **0 missed / 0 survived** mutants across the changed files. (The checked-op boundary tests, the `i128::MIN`/`MAX` cases, and the `sub_underflows_iff_rhs_greater` property are designed to kill the arithmetic and sign-mapping mutants.) + +- [ ] **Step 3: If any mutant survived, add a killing test** + +For each surviving mutant cargo-mutants prints (e.g. *"replace `checked_add` match arm `None if self.0 > 0` with `true`"*), add a targeted unit test in the relevant `#[cfg(test)] mod tests` that fails under that mutation, then re-run `just mutants-diff` until zero survive. Commit: + +```bash +git add crates/model/src/ +git commit -m "test(model): kill surviving arithmetic mutants" +``` + +(Skip this step's commit if Step 2 already reported zero survivors.) + +- [ ] **Step 4: Push and open the PR** + +```bash +git push -u origin feat/oath-model-numeric-primitives +gh pr create --fill --label enhancement --body "Implements the oath-model numeric primitives (Price, Quantity, Side, ArithmeticError) per the slice-1 design. + +Closes #" +``` + +Expected: the `pre-push` hook re-runs `just ci` green; the PR opens; GitHub Actions CI + MSRV job run on the PR. + +--- + +## Self-Review + +**Spec coverage:** + +- `Price`/`Quantity`/`Side`/`ArithmeticError` → Tasks 5/4/3/2. ✓ +- Checked `const fn` add/sub, error-not-wrap, sign-mapping → Tasks 4–5 + boundary tests. ✓ +- Ordering invariant documented on `Price` → Task 5 doc comment. ✓ +- `Quantity` magnitude invariant documented → Task 4 doc comment. ✓ +- `serde` universal bound + round-trip enforcement → derives in Tasks 3–5 + `serde_json` proptest round-trips + `Side` shape check. ✓ +- Manifest reconciliation (drop `rust_decimal`/`uuid`/`time`, add `proptest`/`serde_json`, remove machete block) → Tasks 1 + 3. ✓ +- Deferred items (zerocopy/POD, decimal conversions, notional, rounding, Timestamp, symbology, Position) → not implemented, by design. ✓ +- DoD (`just ci` + `just mutants-diff`) → Task 6. ✓ + +**Placeholder scan:** No TBD/TODO; every code step contains complete, prototype-verified code; every command has an expected result. + +**Type consistency:** `from_raw`/`raw`/`checked_add`/`checked_sub`/`opposite`, `ArithmeticError::{Overflow,Underflow}`, and `Side::{Buy,Sell}` are used identically across the spec, interfaces, and code. `mod`/`pub use` ordering is alphabetical (rustfmt-enforced) and shown in full at each task that touches `lib.rs`. diff --git a/docs/superpowers/specs/2026-06-28-oath-model-numeric-primitives-design.md b/docs/superpowers/specs/2026-06-28-oath-model-numeric-primitives-design.md new file mode 100644 index 0000000..e9aa141 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-oath-model-numeric-primitives-design.md @@ -0,0 +1,304 @@ +# `oath-model` numeric primitives (slice 1) — design + +**Status:** Approved design, pre-implementation. +**Date:** 2026-06-28. +**Crate:** `oath-model` (`crates/model`). + +## Context + +`oath-model` is the root contract of the workspace (ADR-0009): every other crate +depends inward on it, and nothing it exposes may depend on anything else. Today the +crate is a two-line skeleton (`//!` doc + `#![forbid(unsafe_code)]`). Nothing above +it — `*/api` trait crates, the Kernel, Policies, adapters — can carry a real +signature until the root primitives exist. This slice gives the crate its first real +content: the **exact-domain numeric primitives** (`Price`, `Quantity`, `Side`) and +their error type. + +This is deliberately the smallest defensible root slice: one issue, one PR. It does +**not** attempt the full `oath-model` contract (symbology, time, the zero-copy +layout discipline) — each of those is a later slice with its own design and ADR +backing. + +### Governing ADRs + +- **ADR-0023** — numeric types: fixed-point `i128`, two-domain exact/analytical + split. The direct source for `Price`/`Quantity` shape and the money-op contract. +- **ADR-0002 / ADR-0020** — backend-agnostic Bus, one canonical message model; the + universal type bound is `Serialize`, POD is a backend-specific discipline. +- **ADR-0027** — wire representation: layered bounds, the `zerocopy` layout crate, + `u64`-nanos `Timestamp`, deferred schema versioning. Governs the *deferred* work + below; reconciles the `oath-model` manifest. *(Landed on `main` in #49.)* +- **CONTEXT.md** — the ubiquitous-language glossary (`Price`, `Quantity`, `Side`, + `Position`, `Source`, `InstrumentId`). + +## Goal + +Implement `Price`, `Quantity`, `Side`, and `ArithmeticError` as value types with +checked exact-domain arithmetic, total ordering where it makes sense, `serde` +support, and property-tested invariants — and reconcile the crate manifest by +dropping `rust_decimal`/`uuid`/`time`. + +## Scope (in) + +- `Price(i128)` — signed; raw constructor/accessor; checked add/sub; ordering; + `serde`. +- `Quantity(u128)` — unsigned magnitude; same surface; checked sub underflows to an + error. +- `Side { Buy, Sell }` — direction enum with `opposite()`; `serde`. +- `ArithmeticError { Overflow, Underflow }` — `thiserror`. +- Unit, `proptest` property, and `serde` round-trip tests for every operation and + invariant. +- Manifest reconciliation: remove `rust_decimal`, `uuid`, `time`, and the + `cargo-machete` ignore block; add `proptest` and `serde_json` as dev-dependencies. + +## Non-goals (deferred — each a later issue/PR) + +| Deferred item | Why deferred | Lands with | +| --- | --- | --- | +| `zerocopy` layout (`FromBytes`/`IntoBytes`/`Immutable`/`KnownLayout`), `#[repr(C)]`/`transparent`, compile-time `size_of`/no-padding asserts | No consumer yet — Bus/Event-Log unbuilt. ADR-0027 §3 names **`zerocopy`** (not bytemuck): its derive rejects padding at compile time and handles `i128` 16-byte alignment. Isolated `oath-model` dep. | The zero-copy layout slice | +| Instrument-keyed `from_decimal`/`to_decimal` | Precision is instrument metadata (ADR-0023); the `Instrument` type does not exist yet | The symbology slice (ADR-0025) | +| `notional = price × qty` (256-bit widen → rescale → checked-narrow) | Needs a `bnum`-class bigint (ADR-0023/0027); cold path, no consumer | The notional/money-ops slice | +| Rounding (banker's; tick/lot at order emission) | Needs instrument tick/lot metadata | The symbology / execution slices | +| `Timestamp` | ADR-0027 §4 fixes it as `#[repr(transparent)]` **`u64` nanoseconds since the Unix epoch, UTC** (UnixNanos); signed durations are a separate `i64`-nanos type. No `time` crate on the wire | The time slice | +| `InstrumentId` / `Symbol` / `Source` / `Instrument` | ADR-0025/0026: deterministic `(Env, generation, counter)` identity, **not** UUID | The symbology slice | +| `Position` signed-exposure derivation | Exposure is *derived* from `Quantity` + `Side`, never stored signed (ADR-0023) | The portfolio slice | + +## Architecture + +### Module structure (one module per primitive) + +```text +crates/model/src/ + lib.rs // crate docs, `mod` declarations, `pub use` re-exports + error.rs // ArithmeticError + price.rs // Price + #[cfg(test)] mod tests + quantity.rs // Quantity + #[cfg(test)] mod tests + side.rs // Side + #[cfg(test)] mod tests +``` + +Each primitive is an independently understandable, independently testable unit with +one responsibility. `Side` lives next to the numerics it constrains. The deferred +work has obvious homes: `from_decimal`/`to_decimal` in `price.rs`/`quantity.rs`, +`notional` later, `zerocopy` derives on each type. `lib.rs` re-exports the public +types so consumers write `oath_model::Price`, not `oath_model::price::Price`. + +### Types & invariants + +**`Price`** — signed fixed-point, raw `i128`. Negative prices are real (WTI −$37, +calendar/inter-commodity spreads, basis instruments), so the inner type is signed. + +```rust +pub struct Price(i128); + +impl Price { + pub const fn from_raw(raw: i128) -> Self; + pub const fn raw(self) -> i128; + pub const fn checked_add(self, rhs: Price) -> Result; + pub const fn checked_sub(self, rhs: Price) -> Result; +} +``` + +Derives: `Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, +Deserialize`. Ordering is the natural integer order (needed for book/limit +comparisons later). **Ordering invariant (documented on the type):** because a +`Price` is a *precision-free* raw scale (ADR-0023), comparison is meaningful **only** +among prices of the same instrument/precision — `price_aapl < price_btc` compiles but +means nothing across instruments. `Ord` is retained (same-topic book/limit logic +needs it; ADR-0023 accepts this); the constraint is stated in the doc-comment where a +reader hits it, mirroring the `Quantity`-is-a-magnitude invariant. + +**`Quantity`** — unsigned **magnitude**, raw `u128`. + +```rust +pub struct Quantity(u128); + +impl Quantity { + pub const fn from_raw(raw: u128) -> Self; + pub const fn raw(self) -> u128; + pub const fn checked_add(self, rhs: Quantity) -> Result; + pub const fn checked_sub(self, rhs: Quantity) -> Result; +} +``` + +Same derives as `Price`. **Invariant (documented on the type):** a `Quantity` is a +magnitude; direction lives in `Side` (the single source of truth); signed exposure +is *derived* in `Position` (a later slice), never stored as a signed quantity. The +unsigned inner type makes a negative quantity unrepresentable by construction, and +buys range where magnitudes get huge (wei). + +**`Side`** — the single source of truth for direction. + +```rust +pub enum Side { Buy, Sell } + +impl Side { + pub const fn opposite(self) -> Side; +} +``` + +Derives: `Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize`. No +`Ord` — there is no natural ordering of `Buy`/`Sell`. + +**`ArithmeticError`** — `thiserror` enum. + +```rust +pub enum ArithmeticError { + /// Result exceeds the maximum representable value. + Overflow, + /// Result is below the minimum representable value + /// (e.g. subtracting a larger magnitude from a smaller `Quantity`). + Underflow, +} +``` + +No layout attributes (`repr`, `zerocopy` derives) on any type — honoring the +"no wire-layout commitment in this slice" boundary. The types are nonetheless +chosen to be POD-eligible later (`i128`/`u128`/fieldless enum) so the deferred +`zerocopy` slice is purely additive. + +## Serialization stance + +Settled by ADR-0002/0020 and restated by ADR-0027 §1–2: + +- **`serde` is the universal contract.** Every canonical type derives + `Serialize`/`Deserialize`, enforced by a `serde_json` round-trip test (see + Testing). `oath-model` defines **no** custom serialization trait; the per-backend + variation lives in the `Bus` trait (`oath-bus-api`), not in the data. +- **Zero-copy is a backend-specific discipline, layered later.** The shared-memory / + read-in-place family (iceoryx2, Aeron IPC, mmap'd Event-Log replay) will add a + `zerocopy` bound on its own impls; stream backends (Kafka, RabbitMQ, + Aeron-over-network, TCP) need only `Serialize`. Deferring the layout work here is + *consistent* with that layering, not a shortcut. +- **Recorded gotcha for the zero-copy slice:** a fieldless `Side` enum is not + `zerocopy::FromBytes` (enums have invalid bit patterns), so it will need + `#[repr(u8)]` + `TryFromBytes` (or a `u8` wire newtype) when that slice lands. + +## Error handling + +All fallible arithmetic returns `Result<_, ArithmeticError>`. No bare `+ - * /` +operators are exposed on `Price`/`Quantity` (ADR-0023: a silent wrap in accounting +is the unrecoverable money bug). Internals delegate to the standard +`i128::checked_add` / `u128::checked_sub` etc. and map the `None` case: + +- `Quantity::checked_add` → `Overflow` (past `u128::MAX`). +- `Quantity::checked_sub` → `Underflow` (when `rhs > self`). +- `Price::checked_add` / `checked_sub` → `Overflow` for the positive bound, + `Underflow` for the negative bound, determined from the operand signs (signed + overflow only occurs in a direction the operand signs reveal). + +No `unwrap`/`expect`/indexing in non-test code (workspace lints are warn-level and +CI is warning-free). `panic_in_result_fn` stays satisfied because these functions +return `Result` and never panic. + +`checked_add`/`checked_sub` are `const fn`: the standard checked ops are const-stable +(≥ Rust 1.47, well under MSRV 1.90) and the sign-mapping is an explicit `match` (no +`?`, whose `Try` desugaring is not const). This is purely additive — `from_raw`/`raw` +are already `const` — and lets the arithmetic compose into `const` contexts (e.g. +table-driven tick/lot constants in a later slice). + +## Testing + +Unit tests per operation (in each file's `#[cfg(test)] mod tests`, which is +lint-exempt for `unwrap`): + +- add/sub happy paths for `Price` and `Quantity`. +- `Price::checked_add` at `i128::MAX` → `Err(Overflow)`; at `i128::MIN` + (`Price::from_raw(i128::MIN).checked_add(Price::from_raw(-1))`) → `Err(Underflow)`. +- `Price::checked_sub(Price::from_raw(0), Price::from_raw(i128::MIN))` → + `Err(Overflow)` (subtracting `i128::MIN` exceeds the positive bound); + `Price::from_raw(i128::MIN).checked_sub(Price::from_raw(1))` → `Err(Underflow)`. + These `i128::MIN` lines exercise the sign-mapping branch most likely to survive a + mutant. +- `Quantity::checked_add` at `u128::MAX` → `Err(Overflow)`. +- `Quantity::checked_sub` with `rhs > self` → `Err(Underflow)`. +- `Side::opposite` maps `Buy↔Sell`; ordering of `Price` matches integer order. +- **serde round-trip:** a `proptest` round-trip through `serde_json` + (`from_str(&to_string(x)) == x`) over the full `i128`/`u128` range for + `Price`/`Quantity`, plus an exact-shape unit check for `Side` (serializes to + `"Buy"`/`"Sell"`) — enforcing the `Serialize`/`Deserialize` universal-contract + claim (ADR-0002/0020) at the root. (`serde_test::assert_tokens` was evaluated and + rejected: `serde_test` 1.x has no `I128`/`U128` tokens, so it cannot represent the + numeric newtypes; `serde_json` was verified to round-trip the full range + losslessly, including `i128::MIN`/`MAX` and `u128::MAX`.) + +Property tests via `proptest` (new dev-dependency): + +- **Raw round-trip:** `Price::from_raw(x).raw() == x` for all `x: i128`; + `Quantity::from_raw(x).raw() == x` for all `x: u128`. +- **Add/sub inverse:** for in-range `a, b`, `a.checked_add(b)?.checked_sub(b)? == a` + (both types). +- **Add commutativity:** `Price::checked_add` — `a.checked_add(b)` equals + `b.checked_add(a)` (both `Ok`-equal or both `Err`). +- **`Quantity` sub boundary:** `a.checked_sub(b)` is `Err(Underflow)` iff `b > a`, + else `Ok(a - b)`. +- **`Side::opposite` involution:** `s.opposite().opposite() == s`. + +## Dependencies + +The current manifest carries deps "ahead of first use" via a `cargo-machete` +ignore list. This slice reconciles it (partial ADR-0027 reconciliation — `zerocopy` +and `bnum` are intentionally left for their deferred slices). + +**Before** (`crates/model/Cargo.toml`): + +```toml +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } +rust_decimal = { version = "1", features = ["serde-with-str"] } +uuid = { version = "1", features = ["v4", "serde"] } +time = { version = "0.3", features = ["serde"] } + +[package.metadata.cargo-machete] +ignored = ["rust_decimal", "serde", "thiserror", "time", "uuid"] +``` + +**After:** + +```toml +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +serde_json = { workspace = true } +``` + +- `rust_decimal` — removed (ADR-0023: it leaves `oath-model`; not byte-castable). +- `uuid` — removed permanently (ADR-0026/0027: ids are deterministic + `(Env, generation, counter)`, not UUID). +- `time` — removed from `oath-model`; it relocates to the **adapter boundary** + (parsing venue timestamp strings) and **frontend** (display) per ADR-0027 §4. + The wire `Timestamp` (later slice) is a raw `u64`, needing no `time` crate. +- `serde` / `thiserror` — now actually used (derives + error type), so they leave + the `cargo-machete` ignore list, which becomes empty and is deleted. +- `proptest` — added to `[workspace.dependencies]` (workspace pattern) and consumed + as a dev-dependency. +- `serde_json` — added likewise (dev-only); the `proptest` round-trip deserializes + what it serialized over the full numeric range (P1 from review). `serde_test` was + rejected: it has no `i128`/`u128` tokens. + +## Definition of done + +- `just ci` is green — fmt, fmt-toml, typos, lint (clippy `all` = deny), check, test + (+ doctests), deny, doc, machete, gitleaks, actionlint, shellcheck. +- `just mutants-diff` shows **zero surviving mutants** on the changed files (local + check; cargo-mutants is intentionally **not** part of CI). +- All public items documented (`missing_docs`), including enum variants; `Debug` + derived everywhere (`missing_debug_implementations`). +- No `unsafe`, no `unwrap`/`expect`/indexing in non-test code. +- Delivered as one issue → one worktree branch (`feat/oath-model-numeric-primitives`) + → one PR that `Closes` the issue. + +## Future slices (roadmap, not this PR) + +1. **zerocopy layout** — `zerocopy` derives, `#[repr(C)]`/`transparent`, + compile-time `size_of`/no-padding assertions, `Side` via `TryFromBytes` + (ADR-0027). Completes the manifest reconciliation (`zerocopy` dep). +2. **Timestamp** — `u64` UnixNanos + `i64`-nanos duration type (ADR-0027 §4). +3. **Symbology** — `InstrumentId`/`Symbol`/`Source`/`Instrument`, and the + instrument-keyed `from_decimal`/`to_decimal` conversions (ADR-0025). +4. **notional / money ops** — 256-bit widen via `bnum`, banker's rounding + (ADR-0023). +5. Upward: `oath-model` message types, then the `*/api` trait crates.