diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index 8dbfa45..daa5557 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -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({a})", "slice": lambda a, args: f"std::vector({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():std::accumulate({a}.begin(),{a}.end(),0.0))", + "avg": lambda a, args: f"({a}.empty()?na():std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size())", + "min": lambda a, args: f"({a}.empty()?na():*std::min_element({a}.begin(),{a}.end()))", + "max": lambda a, args: f"({a}.empty()?na():*std::max_element({a}.begin(),{a}.end()))", + "range": lambda a, args: f"({a}.empty()?na():*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 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(); }}()" 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 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 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(); }}()" 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 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 m; for(auto v:{a})m[v]++; double best=0; int bc=0; for(auto&[v,c]:m)if(c>bc||(c==bc&&v=(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(); 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(); 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; }}()", "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; }}()", - "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(); double ma=0,mb=0; for(int i=0;i= 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) diff --git a/tests/test_array_empty_aggregates.py b/tests/test_array_empty_aggregates.py new file mode 100644 index 0000000..134b96b --- /dev/null +++ b/tests/test_array_empty_aggregates.py @@ -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(0)\n" + "peers = array.new(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()" in assignment + assert unsafe_operation in assignment + assert assignment.index("na()") < assignment.index(unsafe_operation) + + +def test_empty_int_array_aggregate_returns_typed_numeric_na(): + cpp = _generate( + "values = array.new(0)\n" + "ki48_result = values.min()\n" + "plot(ki48_result)" + ) + + assert "std::vector values" in cpp + assignment = next( + line + for line in cpp.splitlines() + if line.startswith(" ki48_result =") + ) + assert "values.empty()?na()" 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(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()" in assignment + assert assignment.count("std::vector(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(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(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(0)\n" + "side_effects = array.new(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(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(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 r" in assignment + assert "return r" in assignment + assert "return na()" not in assignment diff --git a/tests/test_codegen_audit_fixes.py b/tests/test_codegen_audit_fixes.py index c6bb695..70f3c49 100644 --- a/tests/test_codegen_audit_fixes.py +++ b/tests/test_codegen_audit_fixes.py @@ -208,7 +208,12 @@ def test_array_stdev_biased_arg(): "arr = array.new(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(): diff --git a/tests/test_compile_smoke.py b/tests/test_compile_smoke.py index 780fd45..5477abc 100644 --- a/tests/test_compile_smoke.py +++ b/tests/test_compile_smoke.py @@ -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(0) +peers = array.new(0) +ints = array.new(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()