From 6ae9557cf6918e133c0e2caa1f466a9de3dd8f80 Mon Sep 17 00:00:00 2001 From: Songu3020 Date: Tue, 21 Jul 2026 17:36:11 +0100 Subject: [PATCH] feat(scoring): add scoring module with score calculation and strategies --- Cargo.lock | 261 ++++++++++++++++++++++++++++-- Cargo.toml | 1 + contracts/scoring/Cargo.toml | 9 ++ contracts/scoring/src/lib.rs | 114 +++++++++++++ contracts/scoring/src/strategy.rs | 62 +++++++ contracts/scoring/src/tests.rs | 142 ++++++++++++++++ 6 files changed, 574 insertions(+), 15 deletions(-) create mode 100644 contracts/scoring/Cargo.toml create mode 100644 contracts/scoring/src/lib.rs create mode 100644 contracts/scoring/src/strategy.rs create mode 100644 contracts/scoring/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8573992..80f77db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,7 +147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand", + "rand 0.8.6", ] [[package]] @@ -180,6 +180,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "block-buffer" version = "0.10.4" @@ -283,7 +304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -506,7 +527,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -531,7 +552,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -543,6 +564,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -562,13 +593,19 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -638,6 +675,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "group" version = "0.13.0" @@ -645,7 +705,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -812,6 +872,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.33" @@ -952,6 +1018,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.46" @@ -961,6 +1052,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.6" @@ -968,8 +1071,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -979,7 +1092,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -988,7 +1111,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", ] [[package]] @@ -1011,6 +1152,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "registry" version = "0.1.0" @@ -1037,12 +1184,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "schemars" version = "0.9.0" @@ -1067,6 +1239,13 @@ dependencies = [ "serde_json", ] +[[package]] +name = "scoring" +version = "0.1.0" +dependencies = [ + "proptest", +] + [[package]] name = "sec1" version = "0.7.3" @@ -1202,7 +1381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1273,7 +1452,7 @@ dependencies = [ "ed25519-dalek", "elliptic-curve", "generic-array", - "getrandom", + "getrandom 0.2.17", "hex-literal", "hmac", "k256", @@ -1281,8 +1460,8 @@ dependencies = [ "num-integer", "num-traits", "p256", - "rand", - "rand_chacha", + "rand 0.8.6", + "rand_chacha 0.3.1", "sec1", "sha2", "sha3", @@ -1334,7 +1513,7 @@ dependencies = [ "ctor", "derive_arbitrary", "ed25519-dalek", - "rand", + "rand 0.8.6", "rustc_version", "serde", "serde_json", @@ -1489,6 +1668,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1560,6 +1752,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1572,12 +1770,30 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.125" @@ -1719,6 +1935,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.8.52" diff --git a/Cargo.toml b/Cargo.toml index 2c705a1..5454e19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "contracts/registry", "contracts/escrow", "contracts/settlement", + "contracts/scoring", ] [workspace.dependencies] diff --git a/contracts/scoring/Cargo.toml b/contracts/scoring/Cargo.toml new file mode 100644 index 0000000..051c004 --- /dev/null +++ b/contracts/scoring/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "scoring" +version = "0.1.0" +edition = "2021" + +[dependencies] + +[dev-dependencies] +proptest = "1.5" diff --git a/contracts/scoring/src/lib.rs b/contracts/scoring/src/lib.rs new file mode 100644 index 0000000..aedeab5 --- /dev/null +++ b/contracts/scoring/src/lib.rs @@ -0,0 +1,114 @@ +/// Maximum possible score a contributor can achieve. +pub const MAX_SCORE: u64 = 1000; + +/// Minimum possible score (floored at 0). +pub const MIN_SCORE: u64 = 0; + +/// Base points awarded for participation. +const BASE_POINTS: u64 = 500; + +/// Maximum time bonus achievable. +const MAX_TIME_BONUS: u64 = 500; + +/// Penalty per hint used (in points). +const HINT_PENALTY_PER_HINT: u64 = 50; + +/// Maximum total hint penalty (caps at 500 so base - max_penalty >= 0). +const MAX_HINT_PENALTY: u64 = 500; + +/// Calculate a contributor's score for a challenge session. +/// +/// # Arguments +/// * `time_delta` - Elapsed time in seconds relative to the target time. +/// - Positive: solver took longer than target (smaller bonus). +/// - Negative: solver finished faster than target (capped at max bonus). +/// - Zero: solver matched the target exactly. +/// * `hints_used` - Number of hints the solver consumed. More hints = lower score. +/// * `session_duration` - Total session wall-clock time in seconds. +/// Used for duration normalization; floored internally to avoid division by zero. +/// +/// # Scoring Formula +/// ```text +/// score = clamp(BASE_POINTS + time_bonus - hint_penalty, MIN_SCORE, MAX_SCORE) +/// ``` +/// +/// # Guarantees +/// - Score is always in `[MIN_SCORE, MAX_SCORE]`. +/// - Hint penalties are monotonic: more hints never increase the score. +/// - Handles negative time deltas, hint-count overflow, and zero-duration sessions. +pub fn calculate_score(time_delta: i64, hints_used: u32, session_duration: u64) -> u64 { + // Time bonus: negative delta = faster = higher bonus, capped at MAX_TIME_BONUS + let time_bonus = { + let raw = if time_delta < 0 { + MAX_TIME_BONUS + } else { + MAX_TIME_BONUS.saturating_sub(time_delta as u64) + }; + raw.min(MAX_TIME_BONUS) + }; + + // Hint penalty: each hint costs HINT_PENALTY_PER_HINT, capped at MAX_HINT_PENALTY. + // Use saturating arithmetic to handle u32 -> u64 overflow safely. + let hint_penalty = { + let raw = (hints_used as u64).saturating_mul(HINT_PENALTY_PER_HINT); + raw.min(MAX_HINT_PENALTY) + }; + + // Floor session_duration to 1 to prevent division-by-zero semantics; + // here it's used for optional future normalization but kept for safety. + let _effective_duration = session_duration.max(1); + + let raw_score = BASE_POINTS + .saturating_add(time_bonus) + .saturating_sub(hint_penalty); + + raw_score.min(MAX_SCORE) +} + +/// Calculate score and return it alongside the individual components for inspection. +pub fn calculate_score_detailed(time_delta: i64, hints_used: u32, session_duration: u64) -> ScoreDetail { + let time_bonus = { + let raw = if time_delta < 0 { + MAX_TIME_BONUS + } else { + MAX_TIME_BONUS.saturating_sub(time_delta as u64) + }; + raw.min(MAX_TIME_BONUS) + }; + + let hint_penalty = { + let raw = (hints_used as u64).saturating_mul(HINT_PENALTY_PER_HINT); + raw.min(MAX_HINT_PENALTY) + }; + + let _effective_duration = session_duration.max(1); + + let raw_score = BASE_POINTS + .saturating_add(time_bonus) + .saturating_sub(hint_penalty); + + let score = raw_score.min(MAX_SCORE); + + ScoreDetail { + score, + base_points: BASE_POINTS, + time_bonus, + hint_penalty, + effective_duration: _effective_duration, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScoreDetail { + pub score: u64, + pub base_points: u64, + pub time_bonus: u64, + pub hint_penalty: u64, + pub effective_duration: u64, +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod strategy; diff --git a/contracts/scoring/src/strategy.rs b/contracts/scoring/src/strategy.rs new file mode 100644 index 0000000..6d0cd45 --- /dev/null +++ b/contracts/scoring/src/strategy.rs @@ -0,0 +1,62 @@ +use proptest::prelude::*; + +/// Strategy for generating arbitrary time_delta values. +/// Covers negative, zero, and positive values including extreme ranges. +pub fn time_delta_strategy() -> impl Strategy { + prop_oneof![ + // Negative values (fast solver) + (-1_000_000i64..-1), + // Zero (exact target) + Just(0i64), + // Positive values (slow solver) + (1i64..1_000_000), + // Edge cases + Just(i64::MIN), + Just(i64::MAX), + Just(-1i64), + Just(1i64), + ] +} + +/// Strategy for generating arbitrary hints_used values. +/// Covers zero, typical ranges, and u32::MAX for overflow testing. +pub fn hints_used_strategy() -> impl Strategy { + prop_oneof![ + // Zero hints + Just(0u32), + // Typical range (0..=20) + (0u32..=20), + // Large values approaching overflow + (1000u32..=u32::MAX), + // Specific edge values + Just(u32::MAX), + Just(1u32), + Just(10u32), + ] +} + +/// Strategy for generating arbitrary session_duration values. +/// Covers zero, typical, and extreme durations. +pub fn session_duration_strategy() -> impl Strategy { + prop_oneof![ + // Zero duration (edge case) + Just(0u64), + // Typical ranges + (1u64..=86400), + // Very long sessions + (86401u64..=31_536_000), + // Extreme values + Just(u64::MAX), + Just(1u64), + ] +} + +/// Strategy for generating a complete set of scoring inputs. +#[allow(dead_code)] +pub fn scoring_inputs_strategy() -> impl Strategy { + ( + time_delta_strategy(), + hints_used_strategy(), + session_duration_strategy(), + ) +} diff --git a/contracts/scoring/src/tests.rs b/contracts/scoring/src/tests.rs new file mode 100644 index 0000000..6ddde75 --- /dev/null +++ b/contracts/scoring/src/tests.rs @@ -0,0 +1,142 @@ +use proptest::prelude::*; + +use crate::{calculate_score, calculate_score_detailed, MAX_SCORE, MIN_SCORE}; +use crate::strategy::*; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(1000))] + + /// Property 1: Score is never negative. + /// For ALL valid inputs, the score must be >= MIN_SCORE (0). + #[test] + fn score_is_never_negative( + time_delta in time_delta_strategy(), + hints_used in hints_used_strategy(), + session_duration in session_duration_strategy(), + ) { + let score = calculate_score(time_delta, hints_used, session_duration); + prop_assert!( + score >= MIN_SCORE, + "Score {} is negative for inputs (time_delta={}, hints_used={}, session_duration={})", + score, time_delta, hints_used, session_duration, + ); + } + + /// Property 2: Score never exceeds the documented maximum. + /// For ALL valid inputs, the score must be <= MAX_SCORE (1000). + #[test] + fn score_never_exceeds_max( + time_delta in time_delta_strategy(), + hints_used in hints_used_strategy(), + session_duration in session_duration_strategy(), + ) { + let score = calculate_score(time_delta, hints_used, session_duration); + prop_assert!( + score <= MAX_SCORE, + "Score {} exceeds MAX_SCORE {} for inputs (time_delta={}, hints_used={}, session_duration={})", + score, MAX_SCORE, time_delta, hints_used, session_duration, + ); + } + + /// Property 3: Hint penalties are monotonic. + /// For the same time_delta and session_duration, increasing hints_used + /// must never increase the score. + #[test] + fn hint_penalties_are_monotonic( + time_delta in time_delta_strategy(), + session_duration in session_duration_strategy(), + fewer_hints in 0u32..=50, + more_hints in 51u32..=100, + ) { + let score_fewer = calculate_score(time_delta, fewer_hints, session_duration); + let score_more = calculate_score(time_delta, more_hints, session_duration); + prop_assert!( + score_more <= score_fewer, + "Score with more hints ({}) > score with fewer hints ({}) \ + for hints_used {} vs {}, time_delta={}, session_duration={}", + score_more, score_fewer, more_hints, fewer_hints, time_delta, session_duration, + ); + } + + /// Property 4: Negative time_delta never reduces score below zero-delta score. + /// Finishing faster (negative delta) should yield a score >= finishing at target (delta=0). + #[test] + fn negative_time_delta_not_worse_than_zero( + hints_used in 0u32..=20, + session_duration in session_duration_strategy(), + ) { + let score_zero_delta = calculate_score(0, hints_used, session_duration); + let score_negative_delta = calculate_score(-1000, hints_used, session_duration); + prop_assert!( + score_negative_delta >= score_zero_delta, + "Negative time delta score ({}) < zero delta score ({})", + score_negative_delta, score_zero_delta, + ); + } + + /// Property 5: Zero-duration sessions do not cause panics or undefined behavior. + /// The function must return a valid score for session_duration = 0. + #[test] + fn zero_duration_session_produces_valid_score( + time_delta in time_delta_strategy(), + hints_used in hints_used_strategy(), + ) { + let score = calculate_score(time_delta, hints_used, 0); + prop_assert!( + score >= MIN_SCORE && score <= MAX_SCORE, + "Zero-duration score {} out of range [{}, {}]", + score, MIN_SCORE, MAX_SCORE, + ); + } + + /// Property 6: Hint overflow (u32::MAX) does not cause panic or invalid score. + /// Using the maximum u32 value for hints must be handled safely. + #[test] + fn hint_overflow_produces_valid_score( + time_delta in time_delta_strategy(), + session_duration in session_duration_strategy(), + ) { + let score = calculate_score(time_delta, u32::MAX, session_duration); + prop_assert!( + score >= MIN_SCORE && score <= MAX_SCORE, + "Hint overflow score {} out of range [{}, {}]", + score, MIN_SCORE, MAX_SCORE, + ); + } + + /// Property 7: Detailed score components are internally consistent. + /// The returned score equals base + time_bonus - hint_penalty, clamped to range. + #[test] + fn detailed_score_components_are_consistent( + time_delta in time_delta_strategy(), + hints_used in hints_used_strategy(), + session_duration in session_duration_strategy(), + ) { + let detail = calculate_score_detailed(time_delta, hints_used, session_duration); + let expected = (detail.base_points as i64) + + (detail.time_bonus as i64) + - (detail.hint_penalty as i64); + let clamped = expected.clamp(MIN_SCORE as i64, MAX_SCORE as i64) as u64; + prop_assert_eq!( + detail.score, clamped, + "Score {} does not match clamped computation {} \ + (base={}, time_bonus={}, hint_penalty={})", + detail.score, clamped, detail.base_points, detail.time_bonus, detail.hint_penalty, + ); + } + + /// Property 8: Effective duration is always >= 1 (no division by zero). + #[test] + fn effective_duration_always_positive( + time_delta in time_delta_strategy(), + hints_used in hints_used_strategy(), + session_duration in session_duration_strategy(), + ) { + let detail = calculate_score_detailed(time_delta, hints_used, session_duration); + prop_assert!( + detail.effective_duration >= 1, + "effective_duration {} is less than 1", + detail.effective_duration, + ); + } +}