Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions pineforge_codegen/codegen/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<decltype(__pf_raw_index_value)>; "
"if constexpr(!std::is_same_v<__pf_raw_index_type,bool>) { "
Expand All @@ -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)); "
Expand Down Expand Up @@ -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<double>(); "
f"{check}"
"double v=__pf_array[(size_t)__pf_array_index]; "
"if(std::isnan(v)) return na<double>(); "
"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,
Expand Down Expand Up @@ -620,7 +648,7 @@ def _checked_array_end_remove(a: str, method: str) -> str:
"mode": lambda a, args: f"[&](){{ if({a}.empty()) return na<double>(); std::unordered_map<double,int> m; for(auto v:{a})m[v]++; double best=0; int bc=0; for(auto&[v,c]:m)if(c>bc||(c==bc&&v<best)){{bc=c;best=v;}} return best; }}()",
"percentile_linear_interpolation": lambda a, args: f"[&](){{ if({a}.empty()) return na<double>(); 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<double>(); 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>(); double v={a}[({args[0]})]; if(std::isnan(v)) return na<double>(); 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<double> 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<double> r; for(auto v:{a})r.push_back(s==0?1.0:(v-m)/s); return r; }}()",
Expand Down
1 change: 0 additions & 1 deletion pineforge_codegen/codegen/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []
Expand Down
198 changes: 198 additions & 0 deletions tests/test_array_checked_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,45 @@ def test_checked_index_rejects_na_nonfinite_and_range_before_integer_cast():
assert assignment.index("std::numeric_limits<int64_t>::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()"],
Expand Down Expand Up @@ -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<float>(0)
singleton = array.from(7.0)
var calls = array.new<int>()

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<int>()
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:
Expand Down Expand Up @@ -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 <cmath>
#include <iostream>
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 <iostream>
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 <iostream>
int main() {
for (int selector = 1; selector <= 4; ++selector) {
GeneratedStrategy strategy;
double value = static_cast<double>(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 <iostream>
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",
[
Expand Down
26 changes: 24 additions & 2 deletions tests/test_array_empty_aggregates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
],
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<float>(0)\n"
"index() =>\n"
" array.push(side_effects, 1.0)\n"
" 0\n"
"values = array.new<float>(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<double>()" in assignment
assert assignment.index("}((index()))") < assignment.index("}((values))")


def test_temporary_receiver_is_bound_before_stateful_argument():
cpp = _generate(
"values = array.new<float>(0)\n"
Expand Down
Loading