From ee2bb9165f764beac55409a6d529fc8c83e76913 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 3 Oct 2026 00:34:30 +0200 Subject: [PATCH] fix(reforcexy): preserve native duration precision and finite gamma bounds --- ReforceXY/.basedpyright/diagnostics.json | 68 +++++------ ReforceXY/reward_space_analysis/README.md | 16 ++- .../reward_space_analysis.py | 52 ++++++++- .../reward_space_analysis/tests/README.md | 4 + .../tests/pbrs/test_pbrs.py | 107 ++++++++++++++++++ 5 files changed, 207 insertions(+), 40 deletions(-) diff --git a/ReforceXY/.basedpyright/diagnostics.json b/ReforceXY/.basedpyright/diagnostics.json index 37c57de..4309409 100644 --- a/ReforceXY/.basedpyright/diagnostics.json +++ b/ReforceXY/.basedpyright/diagnostics.json @@ -33,173 +33,173 @@ }, { "endCharacter": 5, - "endLine": 2156, + "endLine": 2194, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "No overloads for \"cut\" match the provided arguments", "rule": "reportCallIssue", "severity": "error", "startCharacter": 17, - "startLine": 2151 + "startLine": 2189 }, { "endCharacter": 21, - "endLine": 2153, + "endLine": 2191, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Argument of type \"_Array1D[Any]\" cannot be assigned to parameter \"bins\" of type \"int | Sequence[float] | Index[int] | Index[float] | IntervalIndex[Interval[Any]] | Series[Any]\" in function \"cut\"\n  Type \"_Array1D[Any]\" is not assignable to type \"int | Sequence[float] | Index[int] | Index[float] | IntervalIndex[Interval[Any]] | Series[Any]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"int\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Sequence[float]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Index[int]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Index[float]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"IntervalIndex[Interval[Any]]\"\n    \"ndarray[tuple[int], dtype[Any]]\" is not assignable to \"Series[Any]\"", "rule": "reportArgumentType", "severity": "error", "startCharacter": 13, - "startLine": 2153 + "startLine": 2191 }, { "endCharacter": 5, - "endLine": 2178, + "endLine": 2216, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to declared type \"RewardParams\"\n  Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to type \"RewardParams\"\n    \"dict[bytes, bytes]\" is not assignable to \"dict[str, RewardParamValue]\"\n      Type parameter \"_KT@dict\" is invariant, but \"bytes\" is not the same as \"str\"\n      Type parameter \"_VT@dict\" is invariant, but \"bytes\" is not the same as \"RewardParamValue\"", "rule": "reportAssignmentType", "severity": "error", "startCharacter": 34, - "startLine": 2174 + "startLine": 2212 }, { "endCharacter": 43, - "endLine": 2175, + "endLine": 2213, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "No overloads for \"__init__\" match the provided arguments", "rule": "reportCallIssue", "severity": "error", "startCharacter": 8, - "startLine": 2175 + "startLine": 2213 }, { "endCharacter": 42, - "endLine": 2175, + "endLine": 2213, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"iterable\" of type \"Iterable[list[bytes]]\" in function \"__init__\"\n  Type \"Any | None\" is not assignable to type \"Iterable[list[bytes]]\"\n    \"None\" is incompatible with protocol \"Iterable[list[bytes]]\"\n      \"__iter__\" is not present", "rule": "reportArgumentType", "severity": "error", "startCharacter": 13, - "startLine": 2175 + "startLine": 2213 }, { "endCharacter": 60, - "endLine": 2435, + "endLine": 2473, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"importances_mean\" for class \"dict[Unknown, Bunch]\"\n  Attribute \"importances_mean\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 44, - "startLine": 2435 + "startLine": 2473 }, { "endCharacter": 58, - "endLine": 2436, + "endLine": 2474, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"importances_std\" for class \"dict[Unknown, Bunch]\"\n  Attribute \"importances_std\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 43, - "startLine": 2436 + "startLine": 2474 }, { "endCharacter": 88, - "endLine": 2455, + "endLine": 2493, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"columns\" for class \"NDArray[Unknown]\"\n  Attribute \"columns\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 81, - "startLine": 2455 + "startLine": 2493 }, { "endCharacter": 88, - "endLine": 2455, + "endLine": 2493, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"columns\" for class \"list[Unknown]\"\n  Attribute \"columns\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 81, - "startLine": 2455 + "startLine": 2493 }, { "endCharacter": 17, - "endLine": 2464, + "endLine": 2502, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Object of type \"None\" cannot be called", "rule": "reportOptionalCall", "severity": "error", "startCharacter": 28, - "startLine": 2458 + "startLine": 2496 }, { "endCharacter": 29, - "endLine": 3063, + "endLine": 3101, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "No overloads for \"ptp\" match the provided arguments", "rule": "reportCallIssue", "severity": "error", "startCharacter": 11, - "startLine": 3063 + "startLine": 3101 }, { "endCharacter": 28, - "endLine": 3063, + "endLine": 3101, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Argument of type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" cannot be assigned to parameter \"a\" of type \"_ArrayLikeNumeric_co\" in function \"ptp\"\n  Type \"np_1darray[Any] | ExtensionArray | Categorical[object]\" is not assignable to type \"_ArrayLikeNumeric_co\"\n    Type \"ExtensionArray\" is not assignable to type \"_ArrayLikeNumeric_co\"\n      \"ExtensionArray\" is incompatible with protocol \"_SupportsArray[dtype[number[Any, Any] | numpy.bool[builtins.bool] | object_ | timedelta64[Any]]]\"\n        \"__array__\" is not present\n      \"ExtensionArray\" is incompatible with protocol \"_NestedSequence[_SupportsArray[dtype[number[Any, Any] | numpy.bool[builtins.bool] | object_ | timedelta64[Any]]]]\"\n        \"__reversed__\" is not present\n        \"count\" is not present\n        \"index\" is not present\n ...", "rule": "reportArgumentType", "severity": "error", "startCharacter": 18, - "startLine": 3063 + "startLine": 3101 }, { "endCharacter": 65, - "endLine": 3072, + "endLine": 3110, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"mean\" for class \"Categorical[object]\"\n  Attribute \"mean\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 61, - "startLine": 3072 + "startLine": 3110 }, { "endCharacter": 65, - "endLine": 3072, + "endLine": 3110, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Cannot access attribute \"mean\" for class \"ExtensionArray\"\n  Attribute \"mean\" is unknown", "rule": "reportAttributeAccessIssue", "severity": "error", "startCharacter": 61, - "startLine": 3072 + "startLine": 3110 }, { "endCharacter": 5, - "endLine": 3972, + "endLine": 4022, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to declared type \"RewardParams\"\n  Type \"dict[bytes, bytes] | dict[str, RewardParamValue]\" is not assignable to type \"RewardParams\"\n    \"dict[bytes, bytes]\" is not assignable to \"dict[str, RewardParamValue]\"\n      Type parameter \"_KT@dict\" is invariant, but \"bytes\" is not the same as \"str\"\n      Type parameter \"_VT@dict\" is invariant, but \"bytes\" is not the same as \"RewardParamValue\"", "rule": "reportAssignmentType", "severity": "error", "startCharacter": 34, - "startLine": 3968 + "startLine": 4018 }, { "endCharacter": 43, - "endLine": 3969, + "endLine": 4019, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "No overloads for \"__init__\" match the provided arguments", "rule": "reportCallIssue", "severity": "error", "startCharacter": 8, - "startLine": 3969 + "startLine": 4019 }, { "endCharacter": 42, - "endLine": 3969, + "endLine": 4019, "file": "ReforceXY/reward_space_analysis/reward_space_analysis.py", "message": "Argument of type \"Any | None\" cannot be assigned to parameter \"iterable\" of type \"Iterable[list[bytes]]\" in function \"__init__\"\n  Type \"Any | None\" is not assignable to type \"Iterable[list[bytes]]\"\n    \"None\" is incompatible with protocol \"Iterable[list[bytes]]\"\n      \"__iter__\" is not present", "rule": "reportArgumentType", "severity": "error", "startCharacter": 13, - "startLine": 3969 + "startLine": 4019 }, { "endCharacter": 22, diff --git a/ReforceXY/reward_space_analysis/README.md b/ReforceXY/reward_space_analysis/README.md index 0f6155b..8c7f008 100644 --- a/ReforceXY/reward_space_analysis/README.md +++ b/ReforceXY/reward_space_analysis/README.md @@ -215,11 +215,17 @@ be overridden via `--params`. counts for RandomForest and permutation importance (-1 = all cores). Programmatic reward parameters accept Python and NumPy real integer/floating -scalars. Strict validation checks a numeric gamma in its native precision before -normalizing it. Analysis-specific numeric string/bool coercion is retained; it -does not replace the stricter runtime configuration contract. Direct gamma -calculation falls back to 0.95 for invalid/non-finite input and clamps finite -out-of-range values; relaxed parameter validation records its resets/clamps. +scalars. Native duration scalars are checked against their bounds before direct +conversion to Python integers, without binary64 narrowing or a fixed-width cap. +Adjustment metadata for finite native durations records the final normalized +integer. Strict validation checks a numeric gamma in its native precision before +normalizing it. +Analysis-specific numeric string/bool coercion is retained; it does not replace +the stricter runtime configuration contract. Direct gamma calculation falls back +to 0.95 for invalid/non-finite input and clamps finite out-of-range values before +narrowing, including extended-precision scalars. Relaxed parameter validation +records its resets/clamps and also checks finite native gamma bounds before +narrowing. For a trained or resumed learner, supply its effective discount as `potential_gamma`, not a stale reward/config gamma. diff --git a/ReforceXY/reward_space_analysis/reward_space_analysis.py b/ReforceXY/reward_space_analysis/reward_space_analysis.py index 456aba0..8245fd1 100644 --- a/ReforceXY/reward_space_analysis/reward_space_analysis.py +++ b/ReforceXY/reward_space_analysis/reward_space_analysis.py @@ -595,6 +595,8 @@ def validate_reward_parameters( Returns a sanitized copy plus adjustments mapping (param -> original/adjusted/reason). Behavior: - Boolean-like keys are coerced to bool. + - Native duration bounds are checked before conversion directly to Python int. + - Finite out-of-range gamma is clamped before float conversion in relaxed mode. - Numeric-bounded keys are coerced to float when provided as str/bool/None. * In strict mode: raise on non-numeric or out-of-bounds. * In relaxed mode: fallback to min bound or 0.0 with adjustment reason. @@ -654,6 +656,42 @@ def validate_reward_parameters( and not bounds["min"] <= original_val <= bounds["max"] ): raise ValueError(f"Param: '{key}'={original_val!r} outside [0, 1]") + # Duration conversion and finite gamma clamps must precede binary64 narrowing. + native_duration = key in ("max_trade_duration_candles", "max_idle_duration_candles") + if (native_duration or key == "potential_gamma") and isinstance( + original_val, (int, float, np.integer, np.floating) + ): + native_finite = isinstance(original_val, (int, np.integer)) or np.isfinite(original_val) + if native_finite and ( + native_duration or not bounds["min"] <= original_val <= bounds["max"] + ): + adjusted_native = original_val + reason_parts = [] + if "min" in bounds and adjusted_native < bounds["min"]: + if strict: + raise ValueError(f"Param: '{key}'={original_val} below min {bounds['min']}") + adjusted_native = bounds["min"] + reason_parts.append(f"min={bounds['min']}") + if "max" in bounds and adjusted_native > bounds["max"]: + if strict: + raise ValueError(f"Param: '{key}'={original_val} above max {bounds['max']}") + adjusted_native = bounds["max"] + reason_parts.append(f"max={bounds['max']}") + adjusted_native = ( + int(adjusted_native) if native_duration else float(adjusted_native) + ) + if not isinstance(original_val, (int, float)): + reason_parts.insert(0, "numeric_coerce") + sanitized[key] = adjusted_native + if reason_parts: + adjustments[key] = { + "original": original_val, + "adjusted": adjusted_native, + "reason": ",".join(reason_parts), + "validation_mode": "strict" if strict else "relaxed", + } + continue + # Robust coercion to float using helper (handles None/str/bool/non-finite) coerced_val = _get_float_param({key: original_val}, key, np.nan) @@ -750,7 +788,7 @@ def validate_reward_parameters( } for key in ("max_trade_duration_candles", "max_idle_duration_candles"): if key in sanitized: - sanitized[key] = int(_get_float_param(sanitized, key)) + sanitized[key] = _get_int_param(sanitized, key) return sanitized, adjustments @@ -3263,6 +3301,18 @@ def _get_potential_gamma(params: RewardParams) -> float: - Guarantee returned float ∈ [0,1]. """ raw_gamma = params.get("potential_gamma") + bounds = _PARAMETER_BOUNDS["potential_gamma"] + if isinstance(raw_gamma, (int, float, np.integer, np.floating)): + native_finite = isinstance(raw_gamma, (int, np.integer)) or np.isfinite(raw_gamma) + if native_finite and not bounds["min"] <= raw_gamma <= bounds["max"]: + gamma = float(bounds["min"] if raw_gamma < bounds["min"] else bounds["max"]) + warnings.warn( + f"PBRS: potential_gamma={raw_gamma} outside [0,1]; clamped to {gamma}", + RewardDiagnosticsWarning, + stacklevel=2, + ) + return gamma + gamma = _get_float_param(params, "potential_gamma", np.nan) if not np.isfinite(gamma): if "potential_gamma" in params: diff --git a/ReforceXY/reward_space_analysis/tests/README.md b/ReforceXY/reward_space_analysis/tests/README.md index 875caba..58e0a70 100644 --- a/ReforceXY/reward_space_analysis/tests/README.md +++ b/ReforceXY/reward_space_analysis/tests/README.md @@ -228,6 +228,10 @@ Columns: | statistics-bootstrap-count-142 | statistics | Zero and negative resample counts fail for variable and constant data | statistics/test_statistics.py::test_bootstrap_rejects_nonpositive_resample_count | Rejects missing bootstrap | | api-unmasked-sample-probabilities-144 | api | Unmasked sample probabilities match marginal valid-action frequencies | api/test_api_helpers.py::test_unmasked_sampling_probabilities_match_action_frequencies | Spot/futures entries, long/short exits, and neutral probability | | cli-effective-reward-boundaries-145 | cli | Effective profit-target fallback and explicit zero idle denominator reach artifacts | cli/test_cli_params_and_csv.py::test_zero_target_and_idle_cap_reach_rewards_and_manifest | Metadata and near-target market coverage agree with runtime boundaries | +| pbrs-native-integer-durations-146 | pbrs | Native integer durations and adjustment metadata retain exact values | pbrs/test_pbrs.py::test_native_integer_durations_preserve_exact_values | Python, signed NumPy and unsigned NumPy integers; strict and relaxed | +| pbrs-native-duration-bounds-147 | pbrs | Floating duration bounds are checked before truncation without narrowing | pbrs/test_pbrs.py::test_native_duration_bounds_precede_truncation | Extended-precision cases run when supported by the platform | +| pbrs-native-gamma-reward-148 | pbrs | Finite out-of-range native gamma clamps reach the public reward | pbrs/test_pbrs.py::test_native_finite_gamma_clamps_the_public_reward | Extended-range cases run when supported by the platform | +| pbrs-relaxed-native-gamma-149 | pbrs | Relaxed native gamma clamps reach rewards while strict validation rejects | pbrs/test_pbrs.py::test_relaxed_native_gamma_clamps_before_reward_calculation | Preserves direct, strict and relaxed boundary contracts | ### Non-Owning Smoke / Reference Checks diff --git a/ReforceXY/reward_space_analysis/tests/pbrs/test_pbrs.py b/ReforceXY/reward_space_analysis/tests/pbrs/test_pbrs.py index 835ad98..0366119 100644 --- a/ReforceXY/reward_space_analysis/tests/pbrs/test_pbrs.py +++ b/ReforceXY/reward_space_analysis/tests/pbrs/test_pbrs.py @@ -1556,6 +1556,113 @@ class TestPBRS(RewardSpaceTestBase): }, ) + def test_native_integer_durations_preserve_exact_values(self): + """Validation preserves exact duration values and adjustment metadata.""" + values = ( + 2**53 + 1, + np.int64(2**53 + 1), + np.int64(2**63 - 1), + np.uint64(2**64 - 1), + 2**200 + 1, + ) + for key in ("max_trade_duration_candles", "max_idle_duration_candles"): + for strict in (True, False): + for value in values: + with self.subTest(key=key, strict=strict, value=value): + normalized, adjustments = validate_reward_parameters( + {key: value}, strict=strict + ) + self.assertEqual(normalized[key], int(value)) + if isinstance(value, np.integer): + self.assertEqual(adjustments[key]["adjusted"], int(value)) + + def test_native_duration_bounds_precede_truncation(self): + """Native floating durations truncate only after their bounds are checked.""" + values = [np.float32(3.75), np.float64(3.75)] + if np.finfo(np.longdouble).nmant > np.finfo(np.float64).nmant: + values.extend( + [np.longdouble("9007199254740993.0"), np.longdouble("9007199254740993.5")] + ) + for key, below_minimum, minimum in ( + ("max_trade_duration_candles", np.longdouble("0.5"), 1), + ("max_idle_duration_candles", np.longdouble("-0.5"), 0), + ): + for value in values: + for strict in (True, False): + with self.subTest(key=key, strict=strict, value=value): + normalized, _ = validate_reward_parameters({key: value}, strict=strict) + self.assertEqual(normalized[key], int(value)) + with self.subTest(key=key, below_minimum=below_minimum): + with self.assertRaises(ValueError): + validate_reward_parameters({key: below_minimum}, strict=True) + normalized, _ = validate_reward_parameters({key: below_minimum}, strict=False) + self.assertEqual(normalized[key], minimum) + + def test_native_finite_gamma_clamps_the_public_reward(self): + """Finite native discounts outside the domain use endpoints, not a fallback.""" + values = [(np.float64(2.0), 0.0), (np.float64(-1.0), -0.025)] + if np.finfo(np.longdouble).maxexp > np.finfo(np.float64).maxexp: + values.extend([(np.longdouble("1e400"), 0.0), (np.longdouble("-1e400"), -0.025)]) + context = reward_space_analysis.RewardContext( + 0.0, 0, 1, 0.0, 0.0, Positions.Neutral, Actions.Neutral + ) + for gamma, expected_shaping in values: + with self.subTest(gamma=gamma): + params = self.base_params( + potential_gamma=gamma, + hold_potential_enabled=True, + exit_potential_mode="retain_previous", + idle_penalty_ratio=0.0, + ) + with self.assertWarns(reward_space_analysis.RewardDiagnosticsWarning): + reward = reward_space_analysis.calculate_reward( + context, + params, + 100.0, + 0.03, + 2.0, + short_allowed=True, + action_masking=True, + prev_potential=0.025, + ) + self.assertEqual(reward.reward_shaping, expected_shaping) + self.assertEqual(reward.total, expected_shaping) + + def test_relaxed_native_gamma_clamps_before_reward_calculation(self): + """Relaxed finite-native clamps reach the reward; strict validation still rejects.""" + values = [(np.float64(2.0), 1.0), (np.float64(-1.0), 0.0)] + if np.finfo(np.longdouble).maxexp > np.finfo(np.float64).maxexp: + values.extend([(np.longdouble("1e400"), 1.0), (np.longdouble("-1e400"), 0.0)]) + context = reward_space_analysis.RewardContext( + 0.0, 0, 1, 0.0, 0.0, Positions.Neutral, Actions.Neutral + ) + for gamma, expected_gamma in values: + with self.subTest(gamma=gamma): + with self.assertRaises(ValueError): + validate_reward_parameters({"potential_gamma": gamma}, strict=True) + normalized, adjustments = validate_reward_parameters( + {"potential_gamma": gamma}, strict=False + ) + self.assertEqual(normalized["potential_gamma"], expected_gamma) + self.assertEqual(adjustments["potential_gamma"]["adjusted"], expected_gamma) + params = self.base_params( + **normalized, + hold_potential_enabled=True, + exit_potential_mode="retain_previous", + idle_penalty_ratio=0.0, + ) + reward = reward_space_analysis.calculate_reward( + context, + params, + 100.0, + 0.03, + 2.0, + short_allowed=True, + action_masking=True, + prev_potential=0.025, + ) + self.assertEqual(reward.reward_shaping, expected_gamma * 0.025 - 0.025) + def test_validate_reward_parameters_records_near_bound_clamps_exactly(self): """Relaxed validation applies near-bound clamps without approximate-equality suppression.""" cases = ( -- 2.53.0