diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index 44c58e7..b31878f 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -459,8 +459,20 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str: # --------------------------------------------------------------------------- -def _checked_array_index_prelude() -> str: - """C++ body shared by Pine v6 checked single-index operations.""" +def _checked_array_index_prelude(*, normalize_negative: bool = True) -> str: + """C++ body shared by checked single-index operations. + + Pine v6 explicitly gives end-relative negative indices to the checked + ``get``, ``set``, and ``remove`` cluster. Other indexed functions such as + ``percentrank`` still need the same NA/non-finite/range checks, but must + reject negative indices instead of normalizing them. ``insert`` remains a + separately bounded residual. + """ + checked_index = ( + "__pf_raw_index<0?__pf_raw_index+__pf_array_size:__pf_raw_index" + if normalize_negative + else "__pf_raw_index" + ) return ( "using __pf_raw_index_type=std::decay_t; " "if constexpr(!std::is_same_v<__pf_raw_index_type,bool>) { " @@ -482,8 +494,7 @@ def _checked_array_index_prelude() -> str: "std::to_string((int64_t)__pf_array.size())); } " "int64_t __pf_raw_index=(int64_t)__pf_raw_index_value; " "int64_t __pf_array_size=(int64_t)__pf_array.size(); " - "int64_t __pf_array_index=__pf_raw_index<0?" - "__pf_raw_index+__pf_array_size:__pf_raw_index; " + f"int64_t __pf_array_index={checked_index}; " "if(__pf_array_index<0||__pf_array_index>=__pf_array_size) " "pine_runtime_error(std::string(\"Index \")+std::to_string(__pf_raw_index)+" "\" is out of bounds. Array size is \"+std::to_string(__pf_array_size)); " @@ -566,6 +577,23 @@ def _checked_array_end_remove(a: str, method: str) -> str: ) +def _checked_array_percentrank(a: str, args: list[str]) -> str: + """Preserve degenerate results, then reject invalid PercentRank indices.""" + check = _checked_array_index_prelude(normalize_negative=False) + return ( + "[&](auto&& __pf_array){ " + "return [&](auto&& __pf_raw_index_value){ " + "if(__pf_array.size()<=1) return na(); " + f"{check}" + "double v=__pf_array[(size_t)__pf_array_index]; " + "if(std::isnan(v)) return na(); " + "int le=0; for(auto x:__pf_array) " + "if(!std::isnan(x) && x<=v) le++; " + "return (double)(le-1)/(__pf_array.size()-1)*100.0; " + f"}}(({args[0]})); }}(({a}))" + ) + + # Methods called as ``array.method(arr, ...)`` or ``arr.method(...)``. ARRAY_METHODS = { "get": _checked_array_get, @@ -620,7 +648,7 @@ def _checked_array_end_remove(a: str, method: str) -> str: "mode": lambda a, args: f"[&](){{ if({a}.empty()) return na(); std::unordered_map m; for(auto v:{a})m[v]++; double best=0; int bc=0; for(auto&[v,c]:m)if(c>bc||(c==bc&&v(); auto c={a}; std::sort(c.begin(),c.end()); double k=({args[0]}/100.0)*c.size()-0.5; int i=std::max(0,(int)k); double f=k-i; if(i+1>=(int)c.size()) return c.back(); return c[i]*(1-f)+c[i+1]*f; }}()", "percentile_nearest_rank": lambda a, args: f"[&](){{ if({a}.empty()) return na(); auto c={a}; std::sort(c.begin(),c.end()); int r=(int)std::ceil(({args[0]}/100.0)*c.size()); return (double)c[std::min(r-1,(int)c.size()-1)]; }}()", - "percentrank": lambda a, args: f"[&](){{ if({a}.size()<=1) return na(); double v={a}[({args[0]})]; if(std::isnan(v)) return na(); int le=0; for(auto x:{a}) if(!std::isnan(x) && x<=v) le++; return (double)(le-1)/({a}.size()-1)*100.0; }}()", + "percentrank": _checked_array_percentrank, "abs": lambda a, args: f"[&](){{ std::vector r; for(auto v:{a})r.push_back(std::abs(v)); return r; }}()", "join": lambda a, args: "[&](){{ std::string r; for(size_t i=0;i<{arr}.size();i++){{ if(i>0)r+={sep}; r+=std::to_string({arr}[i]); }} return r; }}()".format(arr=a, sep=args[0] if args else 'std::string(",")'), "standardize": lambda a, args: f"[&](){{ double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); s=std::sqrt(s/{a}.size()); std::vector r; for(auto v:{a})r.push_back(s==0?1.0:(v-m)/s); return r; }}()", diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index f38430d..ddfe0d3 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -426,7 +426,6 @@ def _array_method_expr( "variance": (0,), "percentile_linear_interpolation": (0,), "percentile_nearest_rank": (0,), - "percentrank": (0,), } bound_args = list(args) arg_bindings: list[tuple[str, str]] = [] diff --git a/tests/test_array_checked_access.py b/tests/test_array_checked_access.py index 72b7604..4db9330 100644 --- a/tests/test_array_checked_access.py +++ b/tests/test_array_checked_access.py @@ -127,6 +127,45 @@ def test_checked_index_rejects_na_nonfinite_and_range_before_integer_cast(): assert assignment.index("std::numeric_limits::max()") < integer_cast +def test_percentrank_checks_bounds_without_normalizing_negative_indices(): + cpp = _generate( + "values = array.from(1.0, 2.0, 3.0)\n" + "idx() =>\n" + " -1\n" + "rank = array.percentrank(values, idx())" + ) + assignment = next( + line for line in cpp.splitlines() if line.startswith(" rank =") + ) + assert assignment.count("idx()") == 1 + assert "[&](auto&& __pf_array)" in assignment + assert "return [&](auto&& __pf_raw_index_value)" in assignment + assert "__pf_array.size()<=1" in assignment + assert "pine_runtime_error" in assignment + assert "int64_t __pf_array_index=__pf_raw_index;" in assignment + assert "__pf_raw_index<0?" not in assignment + + +def test_percentrank_keeps_temporary_receiver_before_index_once(): + cpp = _generate( + "values = array.from(1.0, 2.0, 3.0)\n" + "receiver() =>\n" + " array.copy(values)\n" + "idx() =>\n" + " 2\n" + "rank = array.percentrank(receiver(), idx())" + ) + assignment = next( + line for line in cpp.splitlines() if line.startswith(" rank =") + ) + assert assignment.count("receiver()") == 1 + assert assignment.count("idx()") == 1 + assert assignment.index("[&](auto&& __pf_array)") < assignment.index( + "[&](auto&& __pf_raw_index_value)" + ) + assert assignment.index("}((idx()))") < assignment.index("}((receiver()))") + + @pytest.mark.parametrize( "access", ["array.get(pivots, i)", "array.first(pivots)", "pivots.last()"], @@ -281,6 +320,75 @@ def test_checked_udt_lvalue_access_preserves_alias(access: str): """ +_PERCENTRANK_BOUNDS_ERROR_SOURCE = """//@version=6 +strategy("PercentRank bounds errors") +selector = close +values = array.from(1.0, 2.0, 3.0) +int missing = na +rank = 0.0 + +if selector == 1 + rank := array.percentrank(values, -1) +else if selector == 2 + rank := array.percentrank(values, 3) +else if selector == 3 + rank := array.percentrank(values, missing) +else if selector == 4 + rank := array.percentrank(values, math.pow(10, 400)) +""" + + +_PERCENTRANK_VALID_AND_DEGENERATE_SOURCE = """//@version=6 +strategy("PercentRank valid and degenerate indices") +values = array.from(1.0, 2.0, 3.0) +empty = array.new(0) +singleton = array.from(7.0) +var calls = array.new() + +empty_index() => + array.push(calls, 1) + 999 + +singleton_index() => + array.push(calls, 2) + -999 + +low = array.percentrank(values, 0) +high = array.percentrank(values, 2) +empty_rank = array.percentrank(empty, empty_index()) +singleton_rank = array.percentrank(singleton, singleton_index()) +call_count = array.size(calls) +call_code = array.get(calls, 0) * 10 + array.get(calls, 1) +""" + + +_PERCENTRANK_INTERNAL_NAME_SOURCE = """//@version=6 +strategy("PercentRank internal-name collision") +__pf_array = array.from(1.0, 2.0, 3.0) +__pf_raw_index_value = 2 +rank = array.percentrank(__pf_array, __pf_raw_index_value) +""" + + +_PERCENTRANK_ERROR_ORDER_SOURCE = """//@version=6 +strategy("PercentRank error evaluation order") +var values = array.from(1.0, 2.0, 3.0) +var order = array.new() +var after = 0 + +receiver() => + array.push(order, 1) + array.copy(values) + +bad_index() => + array.push(order, 2) + -1 + +rank = array.percentrank(receiver(), bad_index()) +after := 1 +""" + + def _find_engine_library() -> Path | None: explicit = os.environ.get("PINEFORGE_ENGINE_LIB") if explicit: @@ -337,6 +445,96 @@ def _compile_and_run(cpp_source: str) -> str: return ran.stdout +def test_percentrank_valid_and_degenerate_indices_runtime(): + driver = r""" +#include +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) { + std::cerr << strategy.last_error() << "\n"; + return 2; + } + std::cout << strategy.low << " " << strategy.high << " " + << std::isnan(strategy.empty_rank) << " " + << std::isnan(strategy.singleton_rank) << " " + << strategy.call_count << " " << strategy.call_code << "\n"; +} +""" + output = _compile_and_run( + transpile(_PERCENTRANK_VALID_AND_DEGENERATE_SOURCE) + driver + ) + assert tuple(int(float(value)) for value in output.split()) == ( + 0, + 100, + 1, + 1, + 2, + 12, + ) + + +def test_percentrank_internal_names_compile_without_self_initialization(): + cpp = transpile(_PERCENTRANK_INTERNAL_NAME_SOURCE) + assert "auto&& __pf_array=(__pf_array)" not in cpp + driver = r""" +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + if (!strategy.last_error().empty()) { + std::cerr << strategy.last_error() << "\n"; + return 2; + } + std::cout << strategy.rank << "\n"; +} +""" + assert float(_compile_and_run(cpp + driver)) == 100.0 + + +def test_percentrank_oob_indices_surface_deterministic_last_error(): + driver = r""" +#include +int main() { + for (int selector = 1; selector <= 4; ++selector) { + GeneratedStrategy strategy; + double value = static_cast(selector); + Bar bar{value, value, value, value, 1.0, selector}; + strategy.run(&bar, 1); + std::cout << selector << "\t" << strategy.last_error() << "\n"; + } +} +""" + output = _compile_and_run(transpile(_PERCENTRANK_BOUNDS_ERROR_SOURCE) + driver) + assert output.splitlines() == [ + "1\tIndex -1 is out of bounds. Array size is 3", + "2\tIndex 3 is out of bounds. Array size is 3", + "3\tIndex na is out of bounds. Array size is 3", + "4\tIndex inf is out of bounds. Array size is 3", + ] + + +def test_percentrank_error_preserves_receiver_index_order_and_halts(): + driver = r""" +#include +int main() { + GeneratedStrategy strategy; + Bar bar{1.0, 1.0, 1.0, 1.0, 1.0, 0}; + strategy.run(&bar, 1); + std::cout << strategy.last_error() << "\n"; + for (auto value : strategy.order) std::cout << value; + std::cout << " " << strategy.after << "\n"; +} +""" + output = _compile_and_run( + transpile(_PERCENTRANK_ERROR_ORDER_SOURCE) + driver + ).splitlines() + assert output == ["Index -1 is out of bounds. Array size is 3", "12 0"] + + @pytest.mark.parametrize( "access", [ diff --git a/tests/test_array_empty_aggregates.py b/tests/test_array_empty_aggregates.py index 134b96b..3ce3f1f 100644 --- a/tests/test_array_empty_aggregates.py +++ b/tests/test_array_empty_aggregates.py @@ -27,7 +27,7 @@ def _generate(body: str) -> str: ("array.mode(values)", "std::unordered_map"), ("array.percentile_linear_interpolation(values, 50)", "c.back()"), ("array.percentile_nearest_rank(values, 50)", "c[std::min"), - ("array.percentrank(values, 0)", "double v=values"), + ("array.percentrank(values, 0)", "double v=__pf_array"), ("array.covariance(values, peers)", "ma/=n"), ], ) @@ -101,7 +101,6 @@ def test_empty_temporary_array_aggregate_evaluates_receiver_once(): "percentage()", "values.empty()", ), - ("array.percentrank(values, index())", "index()", "values.size()<=1"), ], ) def test_empty_calculation_evaluates_stateful_argument_once_before_guard( @@ -136,6 +135,29 @@ def test_empty_calculation_evaluates_stateful_argument_once_before_guard( assert assignment.index(argument_call) < assignment.index(guard) +def test_empty_percentrank_evaluates_stateful_index_once_before_guard(): + cpp = _generate( + "side_effects = array.new(0)\n" + "index() =>\n" + " array.push(side_effects, 1.0)\n" + " 0\n" + "values = array.new(0)\n" + "ki48_result = array.percentrank(values, index())\n" + "plot(ki48_result)" + ) + + assignment = next( + line + for line in cpp.splitlines() + if line.startswith(" ki48_result =") + ) + assert assignment.count("index()") == 1 + assert "[&](auto&& __pf_array)" in assignment + assert "return [&](auto&& __pf_raw_index_value)" in assignment + assert "if(__pf_array.size()<=1) return na()" in assignment + assert assignment.index("}((index()))") < assignment.index("}((values))") + + def test_temporary_receiver_is_bound_before_stateful_argument(): cpp = _generate( "values = array.new(0)\n"