diff --git a/src/stats/context.rs b/src/stats/context.rs index 3514217..61fcb56 100644 --- a/src/stats/context.rs +++ b/src/stats/context.rs @@ -1,6 +1,6 @@ -//! Descriptive context signals (001 FR-008, FR-009, FR-011): fiat-currency distribution, -//! payment-method usage ranking, and premium consistency, computed from the already -//! extracted `f`/`pm`/`premium` values on the node's qualifying successful orders +//! Descriptive context signals: fiat-currency distribution, payment-method usage +//! ranking, and premium consistency, computed from the already extracted +//! `f`/`pm`/`premium` values on the node's qualifying successful orders //! (`models::order::OrderAggregate`). Takes plain slices, not the aggregate itself, //! matching `stats::lifecycle`/`stats::trade_size`/`stats::disputes`'s existing pattern //! of depending on primitives rather than `models::*` types. @@ -8,7 +8,7 @@ use std::cmp::Ordering; use std::collections::HashMap; -/// One currency's share of the fiat-currency distribution (001 FR-008). +/// One currency's share of the fiat-currency distribution. #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct FiatCurrencyShare { pub currency: String, @@ -16,17 +16,17 @@ pub struct FiatCurrencyShare { pub share_percent: f64, } -/// FR-008's fiat-currency distribution. `orders_considered` is the denominator: the -/// count of qualifying successful orders carrying a non-empty `f` value. `distribution` -/// is `None` only when that denominator is zero (every qualifying order had an empty -/// `f` value, or there were no qualifying orders at all). +/// The fiat-currency distribution. `orders_considered` is the denominator: the count +/// of qualifying successful orders carrying a non-empty `f` value. `distribution` is +/// `None` only when that denominator is zero (every qualifying order had an empty `f` +/// value, or there were no qualifying orders at all). #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct FiatBreakdown { pub orders_considered: usize, pub distribution: Option>, } -/// One payment method's share of the usage ranking (001 FR-009). +/// One payment method's share of the usage ranking. #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct PaymentMethodShare { pub method: String, @@ -34,16 +34,16 @@ pub struct PaymentMethodShare { pub share_percent: f64, } -/// FR-009's payment-method usage ranking. `total_mentions` is the denominator: the -/// count of `pm` mentions across every qualifying successful order (not a per-order -/// count). `distribution` is `None` only when that denominator is zero. +/// The payment-method usage ranking. `total_mentions` is the denominator: the count of +/// `pm` mentions across every qualifying successful order (not a per-order count). +/// `distribution` is `None` only when that denominator is zero. #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct PaymentMethodBreakdown { pub total_mentions: usize, pub distribution: Option>, } -/// FR-011's premium consistency signal. Both fields are `None` only when fewer than 2 +/// The premium consistency signal. Both fields are `None` only when fewer than 2 /// orders carry a valid `premium` tag. #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] pub struct PremiumSignal { @@ -66,8 +66,8 @@ fn ranked_shares( }) .collect(); - // FR-009: descending `share_percent`, ties broken by label ascending — deterministic - // even though the underlying tally is a `HashMap` with no guaranteed iteration order. + // Descending `share_percent`, ties broken by label ascending — deterministic even + // though the underlying tally is a `HashMap` with no guaranteed iteration order. shares.sort_by(|a, b| { share_of(b) .partial_cmp(&share_of(a)) @@ -78,8 +78,8 @@ fn ranked_shares( shares } -/// Computes FR-008's fiat-currency distribution from the `f` tag's value on each -/// qualifying successful order, already filtered to exclude empty values by +/// Computes the fiat-currency distribution from the `f` tag's value on each qualifying +/// successful order, already filtered to exclude empty values by /// `models::order::aggregate_order_events`. Byte-for-byte comparison, no trimming, no /// case normalization. pub fn compute_fiat_breakdown(fiat_values: &[String]) -> FiatBreakdown { @@ -115,8 +115,8 @@ pub fn compute_fiat_breakdown(fiat_values: &[String]) -> FiatBreakdown { } } -/// Computes FR-009's payment-method usage ranking from every `pm` mention across the -/// node's qualifying successful orders, already flattened by +/// Computes the payment-method usage ranking from every `pm` mention across the node's +/// qualifying successful orders, already flattened by /// `models::order::aggregate_order_events`. Byte-for-byte comparison is critical here: /// `"Bank transfer"` and `" Bank transfer"` are distinct methods, never trimmed or /// normalized. @@ -153,12 +153,12 @@ pub fn compute_payment_method_breakdown(mentions: &[String]) -> PaymentMethodBre } } -/// Computes FR-011's premium signal from the `premium` tag parsed as `i64` on each +/// Computes the premium signal from the `premium` tag parsed as `i64` on each /// qualifying successful order, already excluded when missing/unparseable by /// `models::order::aggregate_order_events`. `premium_baseline_percent` is the median; /// `premium_dispersion_percent` is the population standard deviation (divide by `N`, not -/// `N-1`, the same rule as `stats::trade_size`'s FR-010 computation) around the mean — -/// a distinct, separate computation from trade-size's own median/std-dev, since this is +/// `N-1`, the same rule as `stats::trade_size`'s computation) around the mean — a +/// distinct, separate computation from trade-size's own median/std-dev, since this is /// percentage points over a signed `i64` set, not sats over an unsigned one. `premium` /// comes from an untrusted relay event (`models::order`'s `saturating_add` on `amt` /// carries the same caveat): converting to `f64` loses precision above 2^53 in either @@ -278,9 +278,6 @@ mod tests { #[test] fn compute_payment_method_breakdown_ranks_mentions_not_orders() { - // One order mentioning both methods, one order mentioning only "Cash": Cash and - // "Bank transfer" both have 1 mention out of 3 total = 33.33%; SEPA has 1/3 too. - // Use a skewed example instead so the ranking is unambiguous. let mentions = vec!["SEPA".to_string(), "SEPA".to_string(), "Cash".to_string()]; let breakdown = compute_payment_method_breakdown(&mentions); @@ -294,8 +291,8 @@ mod tests { assert_eq!(distribution[1].mentions, 1); } - /// FR-009: byte-for-byte comparison — a leading space makes a distinct method, - /// never merged or trimmed. + /// Byte-for-byte comparison — a leading space makes a distinct method, never + /// merged or trimmed. #[test] fn compute_payment_method_breakdown_treats_leading_whitespace_as_a_distinct_method() { let mentions = vec!["Bank transfer".to_string(), " Bank transfer".to_string()]; @@ -330,13 +327,11 @@ mod tests { fn compute_premium_signal_computes_median_baseline_and_population_std_dev_dispersion() { let signal = compute_premium_signal(&[10, 20, 30, 40]); - // Median of [10, 20, 30, 40] = 25. assert_close(signal.premium_baseline_percent.unwrap(), 25.0); - // mean = 25; deviations -15,-5,5,15; squared 225,25,25,225; sum 500; /4 = 125. assert_close(signal.premium_dispersion_percent.unwrap(), 125.0_f64.sqrt()); } - /// FR-011: `premium` supports negative values (a discount rather than a markup). + /// `premium` supports negative values (a discount rather than a markup). #[test] fn compute_premium_signal_handles_negative_premiums() { let signal = compute_premium_signal(&[-10, -5, 0, 5, 10]); diff --git a/src/stats/disputes.rs b/src/stats/disputes.rs index 1003a31..3f40536 100644 --- a/src/stats/disputes.rs +++ b/src/stats/disputes.rs @@ -1,12 +1,12 @@ -//! Dispute signals (001 FR-006): a disputes-per-100-successful-trades ratio, computed -//! from PR 3's already deduplicated-by-`d`-tag, already-classified dispute counts +//! Dispute signals: a disputes-per-100-successful-trades ratio, computed from the +//! already deduplicated-by-`d`-tag, already-classified dispute counts //! (`models::dispute::DisputeAggregate`) and the successful-trade count already //! computed by `stats::lifecycle`. Takes plain counts, not a domain-model type, //! matching `stats::lifecycle`/`stats::trade_size`'s existing pattern of depending on //! primitives rather than `models::*` types. -/// FR-006's dispute signals for one node. The four counts are never `Option`: a count -/// of zero disputes is a real, computable value, not a not-applicable case. +/// Dispute signals for one node. The four counts are never `Option`: a count of zero +/// disputes is a real, computable value, not a not-applicable case. /// `disputes_per_100_trades` is `None` only when `successful_trades` is zero — there is /// no denominator to divide by — regardless of how many disputes exist. #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] @@ -18,8 +18,8 @@ pub struct DisputeSignals { pub disputes_per_100_trades: Option, } -/// Computes FR-006's dispute signals from already deduplicated/classified dispute -/// counts and the node's successful-trade count. +/// Computes dispute signals from already deduplicated/classified dispute counts and +/// the node's successful-trade count. pub fn compute_dispute_signals( total_disputes: usize, resolved_disputes: usize, diff --git a/src/stats/grid.rs b/src/stats/grid.rs index eb4e912..eb5a0d5 100644 --- a/src/stats/grid.rs +++ b/src/stats/grid.rs @@ -1,24 +1,23 @@ -//! Activity grid: bucketing and automatic granularity selection (002 FR-004/FR-005, 003 -//! FR-006), plus FR-005a's wide-range warning. Report/activity-grid logic, not a Phase 1 -//! lifetime metric, kept apart from `lifecycle.rs` for that reason. Pure, no I/O, per the -//! constitution's dependency direction for `stats`. +//! Activity grid: bucketing and automatic granularity selection, plus a wide-range +//! warning. Report/activity-grid logic, not a Phase 1 lifetime metric, kept apart from +//! `lifecycle.rs` for that reason. Pure, no I/O. use crate::stats::trade_size::compute_trade_stats; use chrono::{DateTime, Datelike, LocalResult, TimeZone, Utc}; use serde::Serialize; -/// T129 evidence: bucket-count practicality bounds, picked by reasoning about a terminal -/// table's usable row count (a table showing hundreds of rows is unusable), not from a -/// runtime measurement. Daily buckets while the range is a quarter or less, monthly -/// while it is roughly two years or less, yearly beyond that. +/// Bucket-count practicality bounds, picked by reasoning about a terminal table's +/// usable row count (a table showing hundreds of rows is unusable), not from a runtime +/// measurement. Daily buckets while the range is a quarter or less, monthly while it is +/// roughly two years or less, yearly beyond that. const DAILY_GRANULARITY_MAX_RANGE_DAYS: i64 = 90; const MONTHLY_GRANULARITY_MAX_RANGE_DAYS: i64 = 730; const SECONDS_PER_DAY: i64 = 86400; -/// The activity grid's bucket size (002 FR-005, 003 FR-006). Serializes as a lowercase -/// JSON string (`"daily"` / `"monthly"` / `"yearly"`), matching `RelayStatus`'s pattern -/// for other string-union report fields. +/// The activity grid's bucket size. Serializes as a lowercase JSON string (`"daily"` / +/// `"monthly"` / `"yearly"`), matching `RelayStatus`'s pattern for other string-union +/// report fields. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum Granularity { @@ -27,8 +26,8 @@ pub enum Granularity { Yearly, } -/// One qualifying successful order's contribution to the activity grid (002 FR-004): its -/// UTC timestamp and its trade amount in sats, when the order's `amt` tag parsed +/// One qualifying successful order's contribution to the activity grid: its UTC +/// timestamp and its trade amount in sats, when the order's `amt` tag parsed /// successfully. `amount_sats` is `None` for a successful order whose `amt` did not /// parse — it still counts toward `successful_trades`, but contributes nothing to /// `volume_sats`/`median_trade_sats`, mirroring `models::order::aggregate_order_events`'s @@ -40,10 +39,10 @@ pub struct GridOrder { pub amount_sats: Option, } -/// One row of the activity grid (002 FR-004). An empty bucket still appears with -/// `successful_trades`/`volume_sats` of `0` and `median_trade_sats` of `None` — both are -/// real values for an empty bucket per spec 002's Edge Cases, while a median over zero -/// orders is undefined, not zero (001 FR-003). +/// One row of the activity grid. An empty bucket still appears with +/// `successful_trades`/`volume_sats` of `0` and `median_trade_sats` of `None` — both +/// are real values for an empty bucket, while a median over zero orders is undefined, +/// not zero. #[derive(Debug, Clone, PartialEq)] pub struct GridBucket { pub bucket_start: i64, @@ -52,10 +51,10 @@ pub struct GridBucket { pub median_trade_sats: Option, } -/// The complete activity grid (002 FR-004/FR-005). `granularity`/`range_start`/ -/// `range_end` are `None` and `buckets` is empty only when the node has zero successful -/// orders (002 FR-019's zero-order Edge Case): there is no order timestamp to anchor a -/// default range on, and inventing one would misrepresent the node's actual history. +/// The complete activity grid. `granularity`/`range_start`/`range_end` are `None` and +/// `buckets` is empty only when the node has zero successful orders: there is no order +/// timestamp to anchor a default range on, and inventing one would misrepresent the +/// node's actual history. #[derive(Debug, Clone, PartialEq)] pub struct ActivityGrid { pub granularity: Option, @@ -64,13 +63,13 @@ pub struct ActivityGrid { pub buckets: Vec, } -/// 003 FR-004: an explicit caller-supplied range for the activity grid, or `Unbounded` -/// to keep this project's pre-PR-10 behavior (infer the range from the orders' own -/// min/max timestamp). By the time this reaches `compute_activity_grid`, both `since`/ -/// `until` in the `Bounded` case are always already fully resolved concrete values — -/// `cli::options` resolves everything explicitly given, and `run()` resolves the one -/// data-dependent default (`since` defaulting to the node's earliest order) before -/// calling here, so this module never needs to look anything up itself. +/// An explicit caller-supplied range for the activity grid, or `Unbounded` to infer the +/// range from the orders' own min/max timestamp. By the time this reaches +/// `compute_activity_grid`, both `since`/`until` in the `Bounded` case are always +/// already fully resolved concrete values — `cli::options` resolves everything +/// explicitly given, and `run()` resolves the one data-dependent default (`since` +/// defaulting to the node's earliest order) before calling here, so this module never +/// needs to look anything up itself. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GridRange { Unbounded, @@ -164,22 +163,21 @@ fn bucket_boundaries(granularity: Granularity, range_start: i64, range_end: i64) } } -/// 002 FR-004/FR-005, 003 FR-004/FR-006: builds the activity grid from a node's -/// qualifying successful orders. `range` is authoritative when `Bounded` (003 FR-004): -/// it always wins even when it disagrees with what orders exist, so an explicit range -/// with zero orders inside it still renders a real grid with empty buckets spanning the -/// requested range, never the null/empty result reserved for the true zero-orders/ -/// no-range case (002 FR-019). `range: GridRange::Unbounded` preserves this project's -/// pre-PR-10 behavior exactly: the range is the node's own observed lifetime, inferred -/// from the orders' own min/max timestamp. +/// Builds the activity grid from a node's qualifying successful orders. `range` is +/// authoritative when `Bounded`: it always wins even when it disagrees with what orders +/// exist, so an explicit range with zero orders inside it still renders a real grid +/// with empty buckets spanning the requested range, never the null/empty result +/// reserved for the true zero-orders/no-range case. `range: GridRange::Unbounded` +/// infers the range from the node's own observed lifetime: the orders' own min/max +/// timestamp. /// -/// `forced_granularity` overrides automatic selection (T129's evidence) when `Some` — -/// 003 FR-006's explicit `--view`, a configuration-sourced value, or any other caller -/// that already knows the desired granularity. When `range` is `Bounded`, a misaligned -/// `since`/`until` is snapped to the enclosing bucket's start/end once granularity is -/// known (003 FR-006); rejecting an explicit `--view`'s own misalignment instead of -/// snapping is `cli::options`'s job, upstream of this function — by the time a `Bounded` -/// range reaches here, snapping is always the correct behavior. +/// `forced_granularity` overrides automatic selection when `Some` — an explicit +/// `--view`, a configuration-sourced value, or any other caller that already knows the +/// desired granularity. When `range` is `Bounded`, a misaligned `since`/`until` is +/// snapped to the enclosing bucket's start/end once granularity is known; rejecting an +/// explicit `--view`'s own misalignment instead of snapping is `cli::options`'s job, +/// upstream of this function — by the time a `Bounded` range reaches here, snapping is +/// always the correct behavior. pub fn compute_activity_grid( orders: &[GridOrder], range: GridRange, @@ -193,8 +191,8 @@ pub fn compute_activity_grid( } } -/// 003 FR-006: a *defaulted* `--since`/`--until` (this function's entire reason for -/// existing: `GridRange::Unbounded` means neither flag was given at all) MUST snap to the +/// A *defaulted* `--since`/`--until` (this function's entire reason for existing: +/// `GridRange::Unbounded` means neither flag was given at all) MUST snap to the /// enclosing bucket's start/end when the granularity is forced — the reject-instead-of- /// snap rule applies only to an *explicitly given* `--since`/`--until` combined with an /// explicit `--view`, never to this inferred-from-orders, no-explicit-bound case. Reuses @@ -241,11 +239,10 @@ fn compute_unbounded_activity_grid( } } -/// 003 FR-004/FR-005/FR-006: `since > until` (T190/191's empty/inverted-range case, -/// reachable from `run()`'s data-dependent earliest-history default when FR-005's own -/// explicit-`--since` check never applied) stays empty — checked, and returned, before -/// any snapping happens, so snapping can never turn an inverted range into a non-empty -/// one. +/// `since > until` (the empty/inverted-range case, reachable from `run()`'s +/// data-dependent earliest-history default when the explicit-`--since` check never +/// applied) stays empty — checked, and returned, before any snapping happens, so +/// snapping can never turn an inverted range into a non-empty one. fn compute_bounded_activity_grid( orders: &[GridOrder], since: i64, @@ -285,8 +282,8 @@ fn compute_bounded_activity_grid( } } -/// 003 FR-006: snaps `since` down to the start of its enclosing bucket and `until` up to -/// the end of its enclosing bucket for the given `granularity`. A raw timestamp is not +/// Snaps `since` down to the start of its enclosing bucket and `until` up to the end of +/// its enclosing bucket for the given `granularity`. A raw timestamp is not /// itself a day boundary just because every calendar day is a valid daily bucket unit — /// `since`/`until` still need rounding to `00:00:00`/`23:59:59` UTC on their respective /// days, exactly like the monthly/yearly cases round to their own calendar boundaries. @@ -320,12 +317,12 @@ fn snap_range_to_granularity( } /// Builds every ordered, gap-free bucket in `[range_start, range_end]` for -/// `granularity`, counting only orders that fall inside `filter_range` when given -/// (003 FR-004's `Bounded` case). `filter_range` is always the same `(range_start, -/// range_end)` the buckets themselves span — once snapping has widened what's -/// displayed, every order inside that widened range must count too, not just the -/// orders inside the caller's original, possibly narrower, request. `filter_range: None` -/// counts every order, matching `GridRange::Unbounded`'s pre-PR-10 behavior. +/// `granularity`, counting only orders that fall inside `filter_range` when given (the +/// `Bounded` case). `filter_range` is always the same `(range_start, range_end)` the +/// buckets themselves span — once snapping has widened what's displayed, every order +/// inside that widened range must count too, not just the orders inside the caller's +/// original, possibly narrower, request. `filter_range: None` counts every order, +/// matching `GridRange::Unbounded`'s behavior. fn build_grid_buckets( orders: &[GridOrder], granularity: Granularity, @@ -373,15 +370,14 @@ fn build_grid_buckets( buckets } -/// 002 FR-005a: warns when a daily grid is combined with a time range wide enough to -/// produce an unreasonably large number of rows. Reuses T129's own daily/monthly -/// switch-over boundary as the warning threshold, so the warning and the auto-selection -/// rule never disagree about what counts as "too wide" for daily buckets. In ordinary -/// operation `select_granularity` already switches away from daily past that same -/// boundary, so this can only fire in practice once PR 10's `--view` override lets a -/// caller force daily granularity over a range auto-selection would never pick on its -/// own; exercised here ahead of that override with a manually forced scenario. Stderr-only -/// diagnostic (002 FR-017), no JSON field — the caller decides where/whether to print it. +/// Warns when a daily grid is combined with a time range wide enough to produce an +/// unreasonably large number of rows. Reuses the daily/monthly switch-over boundary as +/// the warning threshold, so the warning and the auto-selection rule never disagree +/// about what counts as "too wide" for daily buckets. In ordinary operation +/// `select_granularity` already switches away from daily past that same boundary, so +/// this can only fire in practice once a `--view` override lets a caller force daily +/// granularity over a range auto-selection would never pick on its own. Stderr-only +/// diagnostic, no JSON field — the caller decides where/whether to print it. pub fn wide_range_warning_message( granularity: Granularity, range_start: i64, @@ -472,7 +468,7 @@ mod tests { assert_eq!(grid.buckets[2].median_trade_sats, Some(3000.0)); } - /// Edge Cases: a successful order whose `amt` never parsed still counts toward + /// A successful order whose `amt` never parsed still counts toward /// `successful_trades` but contributes nothing to `volume_sats`/`median_trade_sats`. #[test] fn compute_activity_grid_counts_a_trade_with_no_amount_toward_successful_trades_only() { @@ -612,12 +608,12 @@ mod tests { ); } - /// 003 FR-006: an explicit `--view` with no `--since`/`--until` at all is still a - /// *defaulted* range in FR-006's own terms, so it MUST snap to the enclosing bucket - /// boundary, exactly like the config-sourced/automatic-selection cases -- the - /// reject-instead-of-snap rule applies only when `--since`/`--until` are *also* - /// explicitly given. `range_start`/`range_end` must reflect the snapped calendar-month - /// boundary, not the orders' own raw min/max timestamps. + /// An explicit `--view` with no `--since`/`--until` at all is still a *defaulted* + /// range, so it MUST snap to the enclosing bucket boundary, exactly like the + /// config-sourced/automatic-selection cases -- the reject-instead-of-snap rule + /// applies only when `--since`/`--until` are *also* explicitly given. + /// `range_start`/`range_end` must reflect the snapped calendar-month boundary, not + /// the orders' own raw min/max timestamps. #[test] fn compute_activity_grid_unbounded_with_forced_granularity_snaps_the_inferred_range() { let mid_march = Utc @@ -660,10 +656,10 @@ mod tests { ); } - /// The pre-PR-10 fully-automatic path (`Unbounded`, no forced granularity) must stay - /// exactly as it was: `range_start`/`range_end` are the orders' own raw min/max, never - /// snapped -- only an explicit `--view` (or a config-sourced value, once PR 12 lands) - /// triggers snapping for an otherwise-defaulted range. + /// The fully-automatic path (`Unbounded`, no forced granularity): + /// `range_start`/`range_end` are the orders' own raw min/max, never snapped -- only + /// an explicit `--view` (or a config-sourced value) triggers snapping for an + /// otherwise-defaulted range. #[test] fn compute_activity_grid_unbounded_with_no_forced_granularity_never_snaps() { let mid_march = Utc @@ -699,11 +695,11 @@ mod tests { assert_eq!(warning, None); } - // ---- 003 FR-004/FR-006: `GridRange::Bounded` ---- + // ---- GridRange::Bounded ---- - /// FR-004: an explicit range with zero orders inside it renders a real grid with - /// empty buckets spanning the requested range, never the null result reserved for - /// the true zero-orders/no-range case. + /// An explicit range with zero orders inside it renders a real grid with empty + /// buckets spanning the requested range, never the null result reserved for the + /// true zero-orders/no-range case. #[test] fn compute_activity_grid_bounded_range_with_zero_orders_still_renders_empty_buckets() { let since = 0; @@ -723,8 +719,8 @@ mod tests { .all(|bucket| bucket.successful_trades == 0)); } - /// FR-004: the bounded range wins even when orders exist outside it — only orders - /// inside `[since, until]` are counted. + /// The bounded range wins even when orders exist outside it — only orders inside + /// `[since, until]` are counted. #[test] fn compute_activity_grid_bounded_range_filters_out_orders_outside_the_range() { let since = SECONDS_PER_DAY; @@ -760,8 +756,8 @@ mod tests { assert_eq!(total_volume, 500); } - /// FR-006: an explicit forced granularity is used directly, with no automatic - /// selection, even over a range automatic selection would never pick on its own. + /// An explicit forced granularity is used directly, with no automatic selection, + /// even over a range automatic selection would never pick on its own. #[test] fn compute_activity_grid_bounded_range_uses_forced_granularity_directly() { let since = 0; @@ -776,7 +772,7 @@ mod tests { assert_eq!(grid.granularity, Some(Granularity::Monthly)); } - /// FR-006: a misaligned `since`/`until` is snapped to the enclosing calendar-month + /// A misaligned `since`/`until` is snapped to the enclosing calendar-month /// boundary when the monthly granularity comes from config/automatic selection /// rather than an explicit `--view` (already rejected upstream in `cli::options` in /// that case). @@ -816,10 +812,9 @@ mod tests { ); } - /// FR-006: an empty or inverted range (`since > until`) stays empty regardless of - /// snapping — proven here by choosing bounds that would land in *different* calendar - /// months once snapped, so snapping could only ever widen, never repair, the - /// inversion. + /// An empty or inverted range (`since > until`) stays empty regardless of snapping + /// — proven here by choosing bounds that would land in *different* calendar months + /// once snapped, so snapping could only ever widen, never repair, the inversion. #[test] fn compute_activity_grid_empty_inverted_range_stays_empty_after_snapping() { let since = Utc @@ -842,8 +837,8 @@ mod tests { assert_eq!(grid.range_end, Some(until)); } - /// FR-006: with no forced granularity, a `Bounded` range auto-selects granularity - /// from its own span, not from any order's own min/max. + /// With no forced granularity, a `Bounded` range auto-selects granularity from its + /// own span, not from any order's own min/max. #[test] fn compute_activity_grid_bounded_range_auto_selects_granularity_from_the_range_span() { let since = 0; @@ -854,8 +849,8 @@ mod tests { assert_eq!(grid.granularity, Some(Granularity::Monthly)); } - /// FR-006: once snapping widens the displayed range to the enclosing calendar month, - /// an order that falls inside that widened month but OUTSIDE the caller's originally + /// Once snapping widens the displayed range to the enclosing calendar month, an + /// order that falls inside that widened month but OUTSIDE the caller's originally /// requested (narrower) `[since, until]` must still be counted -- the grid claims to /// cover the whole month, so it must actually count the whole month, not silently /// exclude days the snap itself introduced. @@ -896,7 +891,7 @@ mod tests { ); } - /// 003 FR-006: a defaulted daily range (e.g. `--view daily` alone, or any other + /// A defaulted daily range (e.g. `--view daily` alone, or any other /// non-explicit-`--view` daily case) still snaps to UTC day boundaries -- a raw /// mid-day timestamp is not itself a day boundary just because a day is a valid /// bucket unit. diff --git a/src/stats/lifecycle.rs b/src/stats/lifecycle.rs index 70354a4..b596c26 100644 --- a/src/stats/lifecycle.rs +++ b/src/stats/lifecycle.rs @@ -1,18 +1,18 @@ use serde::Serialize; use std::collections::HashSet; -/// Longevity (001 FR-001): `first_seen_at` is `Some` only via the primary dev-fee anchor -/// path; the fallback path (no dev-fee anchor, but at least one qualifying successful -/// order) reports `first_seen_at` as `None` since it has no dev-fee event to derive it -/// from, even though `days_active` is still computable in that case. +/// `first_seen_at` is `Some` only via the primary dev-fee anchor path; the fallback +/// path (no dev-fee anchor, but at least one qualifying successful order) reports +/// `first_seen_at` as `None` since it has no dev-fee event to derive it from, even +/// though `days_active` is still computable in that case. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Longevity { pub first_seen_at: Option, pub days_active: Option, } -/// Compute longevity (Section 4.1.1, 001 FR-001). Primary anchor is the oldest -/// qualifying kind `8383` dev-fee-payment event's `created_at`. When the node has none, +/// Primary anchor is the oldest qualifying kind `8383` dev-fee-payment event's +/// `created_at`. When the node has none, /// falls back to the elapsed time between its first qualifying successful order's /// `created_at` and `now` — matching the primary path's own "elapsed time to now" /// semantic, rather than spanning first order to *last* order (which would stop @@ -42,8 +42,8 @@ pub fn compute_longevity( } } -/// Cumulative performance (Section 4.1.2, 001 FR-002). Pure exposure of values already -/// computed by `models::order::aggregate_order_events` — no new aggregation logic. +/// Pure exposure of values already computed by +/// `models::order::aggregate_order_events` — no new aggregation logic. #[derive(Debug, Clone, Copy, PartialEq, Serialize)] pub struct CumulativePerformance { pub total_successful_trades: usize, @@ -60,9 +60,8 @@ pub fn compute_cumulative_performance( } } -/// Liveness (Section 4.2.1/4.2.2, 001 FR-004): last successful trade and elapsed time -/// since it, both not applicable when the node has zero successful orders, plus the -/// rolling 7/30/90-day successful-trade counts. +/// Last successful trade and elapsed time since it, both not applicable when the node +/// has zero successful orders, plus the rolling 7/30/90-day successful-trade counts. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Liveness { pub last_successful_trade_at: Option, @@ -89,7 +88,6 @@ pub fn compute_liveness(successful_trade_timestamps: &[i64], now: i64) -> Livene } } -/// Compute rolling window metrics (Section 4.2.2) pub fn compute_rolling_windows(timestamps: &[i64], now: i64) -> (usize, usize, usize) { let day_7 = now - (7 * 86400); let day_30 = now - (30 * 86400); @@ -102,17 +100,16 @@ pub fn compute_rolling_windows(timestamps: &[i64], now: i64) -> (usize, usize, u (last_7d, last_30d, last_90d) } -/// Compute activity consistency (Section 4.2.3, 001 FR-005). The window is exactly 30 -/// UTC calendar days ending on and including today, not a rolling 30*86400-second -/// cutoff: computing the window's boundary as a raw timestamp comparison (rather than -/// aligning both ends to UTC calendar-day indices first) includes an extra day whenever -/// `now` isn't exactly at a UTC day boundary, since `now - 30*86400` and `now` land on -/// the same day-of-week offset but span 31 distinct day indices, not 30. +/// The window is exactly 30 UTC calendar days ending on and including today, not a +/// rolling 30*86400-second cutoff: computing the window's boundary as a raw timestamp +/// comparison (rather than aligning both ends to UTC calendar-day indices first) +/// includes an extra day whenever `now` isn't exactly at a UTC day boundary, since +/// `now - 30*86400` and `now` land on the same day-of-week offset but span 31 distinct +/// day indices, not 30. pub fn compute_activity_consistency(timestamps: &[i64], now: i64) -> (usize, usize) { let today = now.div_euclid(86400); let window_start_day = today - 29; // 30 calendar days inclusive: today-29 ..= today - // Get unique UTC calendar days with a trade within the 30-day window. let active_days: HashSet = timestamps .iter() .map(|&ts| ts.div_euclid(86400)) @@ -121,7 +118,6 @@ pub fn compute_activity_consistency(timestamps: &[i64], now: i64) -> (usize, usi let active_days_count = active_days.len(); - // Calculate max consecutive inactive days if active_days.is_empty() { return (0, 30); } @@ -155,10 +151,10 @@ mod tests { assert_eq!(longevity.days_active, Some(90.0)); } - /// FR-001's core fix: with no dev-fee anchor, `days_active` must span the node's - /// *first* qualifying successful order to *now*, not first order to last order — - /// otherwise a node with exactly one successful order would always read `0`, and - /// `days_active` would stop increasing after the node's last trade. + /// With no dev-fee anchor, `days_active` must span the node's *first* qualifying + /// successful order to *now*, not first order to last order — otherwise a node with + /// exactly one successful order would always read `0`, and `days_active` would + /// stop increasing after the node's last trade. #[test] fn compute_longevity_falls_back_to_first_successful_order_to_now_when_no_dev_fee_anchor() { let now = 100 * 86400; @@ -249,10 +245,10 @@ mod tests { assert_eq!(max_gap, 20); } - /// FR-005 regression: the window is exactly 30 UTC calendar days ending on and - /// including today, not a rolling 30*86400-second cutoff. A trade on the day exactly - /// 30 days before today (the window's first day) must still count; a trade one day - /// further back must not. + /// The window is exactly 30 UTC calendar days ending on and including today, not a + /// rolling 30*86400-second cutoff. A trade on the day exactly 30 days before today + /// (the window's first day) must still count; a trade one day further back must + /// not. #[test] fn compute_activity_consistency_window_is_exactly_thirty_calendar_days() { let now = 100 * 86400_i64; // today = day 100, window = days 71..=100 diff --git a/src/stats/mod.rs b/src/stats/mod.rs index 3cf2f04..f53c95d 100644 --- a/src/stats/mod.rs +++ b/src/stats/mod.rs @@ -15,29 +15,27 @@ use lifecycle::{ }; use trade_size::{compute_trade_stats, TradeSizeStats}; -/// Activity consistency (Section 4.2.3, 001 FR-005): pure wiring around -/// `lifecycle::compute_activity_consistency`'s existing tuple result, giving each of its -/// two values a self-documenting name for `NodeMetrics`. +/// Pure wiring around `lifecycle::compute_activity_consistency`'s existing tuple +/// result, giving each of its two values a self-documenting name for `NodeMetrics`. #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] pub struct ActivityConsistency { pub active_days_last_30d: usize, pub max_consecutive_inactive_days_last_30d: usize, } -/// Bond Policy (001 FR-012, 002 FR-007): the node's tri-state anti-abuse-bond -/// enforcement, mapped to the report schema's three-valued string by -/// `models::instance_status::BondEnabled::as_bond_policy_status`. Its own, distinctly -/// named sub-object per 002 FR-007 — never merged into the trade-history metrics above. +/// The node's tri-state anti-abuse-bond enforcement, mapped to the report schema's +/// three-valued string by `models::instance_status::BondEnabled::as_bond_policy_status`. +/// Its own, distinctly named sub-object — never merged into the trade-history metrics +/// above. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub struct BondPolicy { pub status: &'static str, } -/// Every core reputation metric this PR computes (001 FR-001 through FR-006, FR-008, -/// FR-009, FR-010, FR-011, FR-012) for one node, assembled from `stats::lifecycle`, -/// `stats::trade_size`, `stats::disputes`, and `stats::context`'s independently tested -/// computations, plus `models::instance_status`'s bond-policy mapping. Pure struct -/// assembly: no new business logic lives here. +/// Every core reputation metric computed for one node, assembled from +/// `stats::lifecycle`, `stats::trade_size`, `stats::disputes`, and `stats::context`'s +/// independently tested computations, plus `models::instance_status`'s bond-policy +/// mapping. Pure struct assembly: no new business logic lives here. #[derive(Debug, Clone, PartialEq)] pub struct NodeMetrics { pub longevity: Longevity, @@ -140,7 +138,6 @@ mod tests { assert_eq!(metrics.disputes.resolved_disputes, 0); assert_eq!(metrics.disputes.active_disputes, 1); assert_eq!(metrics.disputes.unknown_status_disputes, 0); - // 1 dispute / 2 successful trades * 100 = 50.0. assert_eq!(metrics.disputes.disputes_per_100_trades, Some(50.0)); assert_eq!(metrics.fiat_breakdown.orders_considered, 2); assert_eq!(metrics.payment_method_breakdown.total_mentions, 1); diff --git a/src/stats/trade_size.rs b/src/stats/trade_size.rs index 8841118..d1d3e95 100644 --- a/src/stats/trade_size.rs +++ b/src/stats/trade_size.rs @@ -1,16 +1,16 @@ -/// Trade-size statistics (Section 4.1.3, 001 FR-003, FR-010). `min_trade_sats`, -/// `max_trade_sats`, `mean_trade_sats`, and `median_trade_sats` are `None` only when the -/// amt-restricted set (001 FR-002) is empty. `std_dev_trade_sats` follows that same -/// empty-set rule. `coefficient_of_variation` has its own, stricter not-applicable rule -/// (FR-010): `None` when fewer than 2 orders exist, or when `median_trade_sats` is -/// exactly `0`, since dividing by a zero median is undefined regardless of sample size. -/// `median_trade_sats` is `f64`, not `u64`: an even-sized set's median is the average of -/// its two middle values, which is legitimately fractional (e.g. `[0, 1]` medians to -/// `0.5`), and truncating it to an integer would both misreport the value and corrupt -/// `coefficient_of_variation`'s division. `amt` comes from an untrusted relay event -/// (`models::order`'s `saturating_add` on the same field carries the identical caveat): -/// converting it to `f64` loses precision above 2^53, but that threshold is already over -/// 4x the entire 21M BTC supply in sats, so no realistic `amt` value is affected — only a +/// Trade-size statistics. `min_trade_sats`, `max_trade_sats`, `mean_trade_sats`, and +/// `median_trade_sats` are `None` only when the amt-restricted set is empty. +/// `std_dev_trade_sats` follows that same empty-set rule. `coefficient_of_variation` +/// has its own, stricter not-applicable rule: `None` when fewer than 2 orders exist, or +/// when `median_trade_sats` is exactly `0`, since dividing by a zero median is +/// undefined regardless of sample size. `median_trade_sats` is `f64`, not `u64`: an +/// even-sized set's median is the average of its two middle values, which is +/// legitimately fractional (e.g. `[0, 1]` medians to `0.5`), and truncating it to an +/// integer would both misreport the value and corrupt `coefficient_of_variation`'s +/// division. `amt` comes from an untrusted relay event (`models::order`'s +/// `saturating_add` on the same field carries the identical caveat): converting it to +/// `f64` loses precision above 2^53, but that threshold is already over 4x the entire +/// 21M BTC supply in sats, so no realistic `amt` value is affected — only a /// deliberately crafted, physically impossible one. #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] pub struct TradeSizeStats { @@ -31,7 +31,6 @@ const NOT_APPLICABLE: TradeSizeStats = TradeSizeStats { coefficient_of_variation: None, }; -/// Compute trade amount statistics (Section 4.1.3) pub fn compute_trade_stats(amounts: &[u64]) -> TradeSizeStats { if amounts.is_empty() { return NOT_APPLICABLE; @@ -51,8 +50,8 @@ pub fn compute_trade_stats(amounts: &[u64]) -> TradeSizeStats { sorted[sorted.len() / 2] as f64 }; - // Population standard deviation (divide by N, not N-1, per FR-010): this - // amt-restricted set is the node's complete historical record, not a sample. + // Population standard deviation (divide by N, not N-1): this amt-restricted set is + // the node's complete historical record, not a sample. let variance = amounts .iter() .map(|&v| { @@ -104,7 +103,6 @@ mod tests { assert_eq!(stats.mean_trade_sats, Some(100.0)); assert_eq!(stats.median_trade_sats, Some(100.0)); assert_eq!(stats.std_dev_trade_sats, Some(0.0)); - // FR-010: fewer than 2 orders -> not applicable, even though std_dev is defined. assert_eq!(stats.coefficient_of_variation, None); } @@ -126,12 +124,10 @@ mod tests { assert_eq!(stats.median_trade_sats, Some(25.0)); } - /// Regression: an even-sized set whose middle two values average to a fraction - /// (e.g. 0 and 1) must report that fraction exactly, not truncate it to an integer - /// — plan.md's JSON output contract explicitly states `median_trade_sats` over an - /// even-sized set is legitimately fractional, and truncating it would also corrupt - /// `coefficient_of_variation`'s division (a truncated-to-zero median would wrongly - /// report CV as not applicable instead of a real value). + /// An even-sized set whose middle two values average to a fraction (e.g. 0 and 1) + /// must report that fraction exactly, not truncate it to an integer — truncating it + /// would also corrupt `coefficient_of_variation`'s division (a truncated-to-zero + /// median would wrongly report CV as not applicable instead of a real value). #[test] fn compute_trade_stats_even_count_fractional_median_is_not_truncated() { let stats = compute_trade_stats(&[0, 1]);