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
31 changes: 15 additions & 16 deletions pineforge_codegen/codegen/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,39 +485,38 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str:
"copy": lambda a, args: f"std::vector<double>({a})",
"slice": lambda a, args: f"std::vector<double>({a}.begin()+(int)({args[0]}),{a}.begin()+(int)({args[1]}))",
"concat": lambda a, args: f"{a}.insert({a}.end(),{args[0]}.begin(),{args[0]}.end())",
"sum": lambda a, args: f"std::accumulate({a}.begin(),{a}.end(),0.0)",
"avg": lambda a, args: f"({a}.empty()?0.0:std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size())",
"min": lambda a, args: f"*std::min_element({a}.begin(),{a}.end())",
"max": lambda a, args: f"*std::max_element({a}.begin(),{a}.end())",
"range": lambda a, args: f"(*std::max_element({a}.begin(),{a}.end())-*std::min_element({a}.begin(),{a}.end()))",
"sum": lambda a, args: f"({a}.empty()?na<double>():std::accumulate({a}.begin(),{a}.end(),0.0))",
"avg": lambda a, args: f"({a}.empty()?na<double>():std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size())",
"min": lambda a, args: f"({a}.empty()?na<double>():*std::min_element({a}.begin(),{a}.end()))",
"max": lambda a, args: f"({a}.empty()?na<double>():*std::max_element({a}.begin(),{a}.end()))",
"range": lambda a, args: f"({a}.empty()?na<double>():*std::max_element({a}.begin(),{a}.end())-*std::min_element({a}.begin(),{a}.end()))",
"every": lambda a, args: f"std::all_of({a}.begin(),{a}.end(),[](double v){{return v!=0.0;}})",
"some": lambda a, args: f"std::any_of({a}.begin(),{a}.end(),[](double v){{return v!=0.0;}})",
# stdev/variance honor the optional 2nd ``biased`` arg (Pine v6:
# biased=true → population (default), false → sample / n-1). The no-arg
# form keeps the original population emission byte-identical.
# biased=true → population (default), false → sample / n-1).
"stdev": 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); "
f"[&](){{ if({a}.empty()) return na<double>(); double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); "
f"double _d=({args[0]})?(double){a}.size():((double){a}.size()-1.0); "
f"return _d>0?std::sqrt(s/_d):na<double>(); }}()"
if args else
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); return std::sqrt(s/{a}.size()); }}()"
f"[&](){{ if({a}.empty()) return na<double>(); double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); return std::sqrt(s/{a}.size()); }}()"
),
"variance": 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); "
f"[&](){{ if({a}.empty()) return na<double>(); double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); "
f"double _d=({args[0]})?(double){a}.size():((double){a}.size()-1.0); "
f"return _d>0?s/_d:na<double>(); }}()"
if args else
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); return s/{a}.size(); }}()"
f"[&](){{ if({a}.empty()) return na<double>(); double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); return s/{a}.size(); }}()"
),
"median": lambda a, args: f"[&](){{ auto c={a}; std::sort(c.begin(),c.end()); int n=c.size(); return n%2?c[n/2]:(c[n/2-1]+c[n/2])/2.0; }}()",
"mode": lambda a, args: f"[&](){{ 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"[&](){{ 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"[&](){{ auto c={a}; std::sort(c.begin(),c.end()); int r=(int)std::ceil(({args[0]}/100.0)*c.size()); return c[std::min(r-1,(int)c.size()-1)]; }}()",
"median": lambda a, args: f"[&](){{ if({a}.empty()) return na<double>(); auto c={a}; std::sort(c.begin(),c.end()); int n=c.size(); return n%2?c[n/2]:(c[n/2-1]+c[n/2])/2.0; }}()",
"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; }}()",
"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; }}()",
"covariance": lambda a, args: f"[&](){{ auto&b={args[0]}; int n=std::min({a}.size(),b.size()); double ma=0,mb=0; for(int i=0;i<n;i++){{ma+={a}[i];mb+=b[i];}} ma/=n;mb/=n; double c=0; for(int i=0;i<n;i++)c+=({a}[i]-ma)*(b[i]-mb); return c/n; }}()",
"covariance": lambda a, args: f"[&](){{ auto&&b=({args[0]}); int n=std::min({a}.size(),b.size()); if(n<=0) return na<double>(); double ma=0,mb=0; for(int i=0;i<n;i++){{ma+={a}[i];mb+=b[i];}} ma/=n;mb/=n; double c=0; for(int i=0;i<n;i++)c+=({a}[i]-ma)*(b[i]-mb); return c/n; }}()",
"binary_search": lambda a, args: f"[&](){{ auto it=std::lower_bound({a}.begin(),{a}.end(),{args[0]}); return (it!={a}.end()&&*it=={args[0]})?(double)(it-{a}.begin()):-1.0; }}()",
"binary_search_leftmost": lambda a, args: f"[&](){{ auto it=std::lower_bound({a}.begin(),{a}.end(),{args[0]}); return (it!={a}.end()&&*it=={args[0]})?(double)(it-{a}.begin()):(double)(it-{a}.begin()-1); }}()",
"binary_search_rightmost": lambda a, args: f"[&](){{ auto it=std::upper_bound({a}.begin(),{a}.end(),{args[0]}); return (it!={a}.begin()&&*(it-1)=={args[0]})?(double)(it-{a}.begin()-1):(double)(it-{a}.begin()); }}()",
Expand Down
39 changes: 38 additions & 1 deletion pineforge_codegen/codegen/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,44 @@ def _array_method_expr(
f"codegen: unhandled array method '{method}' — analyzer should have "
f"rejected. Add it to ARRAY_METHODS."
)
lower_receiver = lambda recv: ARRAY_METHODS[method](recv, args)
# Pine evaluates call arguments before entering the array
# calculation, even when an empty receiver makes the result ``na``.
# The empty guards in these methods must therefore consume a
# one-evaluation binding rather than leaving the original argument
# expression after the guard. Build the binding into
# ``lower_receiver`` so a temporary receiver is still evaluated
# first by ``_array_receiver_once_expr``.
eager_scalar_arg_methods = {
"stdev": (0,),
"variance": (0,),
"percentile_linear_interpolation": (0,),
"percentile_nearest_rank": (0,),
"percentrank": (0,),
}
bound_args = list(args)
arg_bindings: list[tuple[str, str]] = []
occupied = "\n".join((array_expr, *args))
counter = getattr(self, "_array_arg_counter", 0)
for arg_index in eager_scalar_arg_methods.get(method, ()):
if arg_index >= len(args):
continue
while True:
token = f"__pf_array_arg_{counter}"
counter += 1
if token not in occupied:
break
bound_args[arg_index] = token
arg_bindings.append((token, args[arg_index]))
self._array_arg_counter = counter

def lower_receiver(recv: str) -> str:
lowered = ARRAY_METHODS[method](recv, bound_args)
for token, original in reversed(arg_bindings):
lowered = (
f"[&](){{ auto {token}=({original}); "
f"return {lowered}; }}()"
)
return lowered

return self._array_receiver_once_expr(array_expr, args, lower_receiver)

Expand Down
176 changes: 176 additions & 0 deletions tests/test_array_empty_aggregates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""Regression tests for Pine's empty-array calculation semantics."""

from __future__ import annotations

import pytest

from pineforge_codegen import transpile


def _generate(body: str) -> str:
return transpile(f'//@version=6\nstrategy("T")\n{body}\n')


@pytest.mark.parametrize(
("expression", "unsafe_operation"),
[
("array.sum(values)", "std::accumulate"),
("array.avg(values)", "std::accumulate"),
("array.min(values)", "std::min_element"),
("array.max(values)", "std::max_element"),
("array.range(values)", "std::max_element"),
("array.stdev(values)", "std::sqrt"),
("array.stdev(values, false)", "std::sqrt"),
("array.variance(values)", "std::accumulate"),
("array.variance(values, false)", "std::accumulate"),
("array.median(values)", "std::sort"),
("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.covariance(values, peers)", "ma/=n"),
],
)
def test_empty_numeric_array_calculations_guard_before_use(
expression: str,
unsafe_operation: str,
):
cpp = _generate(
"values = array.new<float>(0)\n"
"peers = array.new<float>(0)\n"
f"ki48_result = {expression}\n"
"plot(ki48_result)"
)

assignment = next(
line
for line in cpp.splitlines()
if line.startswith(" ki48_result =")
)
assert "na<double>()" in assignment
assert unsafe_operation in assignment
assert assignment.index("na<double>()") < assignment.index(unsafe_operation)


def test_empty_int_array_aggregate_returns_typed_numeric_na():
cpp = _generate(
"values = array.new<int>(0)\n"
"ki48_result = values.min()\n"
"plot(ki48_result)"
)

assert "std::vector<int> values" in cpp
assignment = next(
line
for line in cpp.splitlines()
if line.startswith(" ki48_result =")
)
assert "values.empty()?na<double>()" in assignment
assert "*std::min_element(values.begin(),values.end())" in assignment


def test_empty_temporary_array_aggregate_evaluates_receiver_once():
cpp = _generate(
"values = array.new<float>(0)\n"
"ki48_result = array.sum(array.copy(values))\n"
"plot(ki48_result)"
)

assignment = next(
line
for line in cpp.splitlines()
if line.startswith(" ki48_result =")
)
assert "auto&& __pf_array_receiver_" in assignment
assert "na<double>()" in assignment
assert assignment.count("std::vector<double>(values)") == 1


@pytest.mark.parametrize(
("expression", "argument_call", "guard"),
[
("array.stdev(values, biased())", "biased()", "values.empty()"),
("array.variance(values, biased())", "biased()", "values.empty()"),
(
"array.percentile_linear_interpolation(values, percentage())",
"percentage()",
"values.empty()",
),
(
"array.percentile_nearest_rank(values, percentage())",
"percentage()",
"values.empty()",
),
("array.percentrank(values, index())", "index()", "values.size()<=1"),
],
)
def test_empty_calculation_evaluates_stateful_argument_once_before_guard(
expression: str,
argument_call: str,
guard: str,
):
cpp = _generate(
"side_effects = array.new<float>(0)\n"
"biased() =>\n"
" array.push(side_effects, 1.0)\n"
" false\n"
"percentage() =>\n"
" array.push(side_effects, 1.0)\n"
" 50.0\n"
"index() =>\n"
" array.push(side_effects, 1.0)\n"
" 0\n"
"values = array.new<float>(0)\n"
f"ki48_result = {expression}\n"
"plot(ki48_result)"
)

assignment = next(
line
for line in cpp.splitlines()
if line.startswith(" ki48_result =")
)
assert assignment.count(argument_call) == 1
assert argument_call in assignment
assert guard in assignment
assert assignment.index(argument_call) < assignment.index(guard)


def test_temporary_receiver_is_bound_before_stateful_argument():
cpp = _generate(
"values = array.new<float>(0)\n"
"side_effects = array.new<float>(0)\n"
"percentage() =>\n"
" array.push(side_effects, 1.0)\n"
" 50.0\n"
"ki48_result = array.percentile_nearest_rank(array.copy(values), percentage())\n"
"plot(ki48_result)"
)

assignment = next(
line
for line in cpp.splitlines()
if line.startswith(" ki48_result =")
)
receiver = "std::vector<double>(values)"
assert assignment.count(receiver) == 1
assert assignment.count("percentage()") == 1
assert assignment.index(receiver) < assignment.index("percentage()")
assert assignment.index("percentage()") < assignment.index(".empty()")


def test_empty_standardize_remains_an_empty_array_not_scalar_na():
cpp = _generate(
"values = array.new<float>(0)\n"
"ki48_result = array.standardize(values)\n"
"plot(array.size(ki48_result))"
)

assignment = next(
line
for line in cpp.splitlines()
if line.startswith(" ki48_result =")
)
assert "std::vector<double> r" in assignment
assert "return r" in assignment
assert "return na<double>()" not in assignment
7 changes: 6 additions & 1 deletion tests/test_codegen_audit_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,12 @@ def test_array_stdev_biased_arg():
"arr = array.new<float>(0)\narray.push(arr, close)\n"
"v = array.stdev(arr, false)\nplot(v)\n"
)
assert "_d=(false)?" in cpp.replace(" ", "").replace("\n", "") or "_d=(false)" in cpp
assignment = next(line for line in cpp.splitlines() if line.startswith(" v ="))
token = assignment.split("auto ", 1)[1].split("=", 1)[0]
assert token.startswith("__pf_array_arg_")
assert f"auto {token}=(false);" in assignment
assert f"_d=({token})?" in assignment
assert assignment.index("(false)") < assignment.index("arr.empty()")


def test_array_stdev_no_arg_unchanged():
Expand Down
25 changes: 25 additions & 0 deletions tests/test_compile_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,31 @@ def test_nested_array_slice_aggregates_compile():
""")


def test_empty_numeric_array_calculations_compile():
_check("empty_numeric_array_calculations", """
values = array.new<float>(0)
peers = array.new<float>(0)
ints = array.new<int>(0)
sum_v = array.sum(values)
avg_v = array.avg(values)
min_v = array.min(values)
max_v = array.max(values)
range_v = array.range(values)
stdev_v = array.stdev(values, false)
variance_v = array.variance(values, false)
median_v = array.median(values)
mode_v = array.mode(values)
linear_v = array.percentile_linear_interpolation(values, 50)
nearest_v = array.percentile_nearest_rank(ints, 50)
rank_v = array.percentrank(values, 0)
covariance_v = array.covariance(values, peers)
covariance_temp_v = array.covariance(values, array.copy(peers))
plot(sum_v + avg_v + min_v + max_v + range_v + stdev_v + variance_v +
median_v + mode_v + linear_v + nearest_v + rank_v + covariance_v +
covariance_temp_v)
""")


def test_descending_for_by_array_remove_compiles():
_check("descending_for_by_array_remove", """
var levels = array.new<float>()
Expand Down
Loading