Repository navigation
Carry a variable-held string input default, or refuse it, instead of an empty default - #374
Conversation
Rows for input.string, input.timeframe, input.symbol, input.session and input.text_area whose default or option is a global variable, declared with and without var: the emitted getter default and the input manifest, the located refusals for a variable that cannot be proven, the cases that must not change, and engine rows that run a strategy under its variable-held default against the same strategy with a plain default. On their own the rows fail, because a var-held string default is translated with an empty default. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A string input whose default was a var variable holding a literal was
translated with an empty default: the generated getter read std::string("")
and the input manifest listed the input as unsupported with no options. A
strategy that depends on the default option then ran differently from the
same strategy with a literal default, without any error.
The default is now carried when the variable is a global declared once with
a string literal (or a constant expression of literals) and never reassigned,
in the getter, the settings descriptor and the manifest. When that cannot be
proven (the variable is reassigned anywhere, declared in a function, declared
twice, or initialized from a call, an input, a parameter or a runtime value)
the script is refused with PF-E5096 at the variable instead of using an empty
default. An option that cannot be proven keeps its existing behaviour: the
manifest marks the input unsupported and no error is raised. The same rule
applies to every input that reads through the string getter: input.string,
input.timeframe, input.symbol, input.session and input.text_area.
Refs #372
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.
Not checked: tests were not run; formal verification proved 0 of 4 changed function(s) (1 sampled, not proven, 3 not verified).
Formal verification. PR-changed functions: 1/4 verified (0 proven, 1 may-equivalent, 0 distinguished) · 3 not verified (1 vacuous, 2 unsupported).
Not verified on this run: emit\_settings\_members (vacuous: never exercised), \_\_init\_\_ (unsupported), \_input\_default\_expression (unsupported).
Graphify review — findings
Adds proof of never-reassigned global string literals (declared with or without var) for the defaults and options of input.string, input.timeframe, input.symbol, input.session and input.text_area, so variable-held defaults are carried through instead of silently turning empty. A default variable that cannot be proven now fails with a located PF-E5096 error, while an unprovable option keeps the existing unsupported marker in the manifest. The new ConstantResolver.string_input_argument also accepts namespaced builtin constants as initializers, and its namespace check is broad enough that a non-string constant could be treated as a string default.
Worth a look
- Non-string namespaced constants can become string input defaults —
pineforge_codegen/codegen/constant_values.py:115· Escalate · medium- unverified hypothesis — survived the adversarial refute pass; needs human review
Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 270 functions depend on the 256 functions this change touches.
Health — grade A; 5 existing hotspot(s) in the area this change touches (pre-existing, not introduced here):
emit_settings_members()— 4 callers, 5 callees (medium)test_later_option_declaration_stays_unavailable_and_descriptor_unsupported()— 0 callers, 11 callees (medium)test_real_interfaces_keep_notes_and_short_sentences()— 0 callers, 9 callees (medium)test_stable_note_identity_uses_declared_severity()— 0 callers, 9 callees (medium)test_uncatalogued_note_retains_existing_nonfatal_identity()— 0 callers, 7 callees (medium)
Verification — 270 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 270 function(s) in the blast radius were not formally verified this run
Cross-repo impact
Cross-repo impact
This change impacts 2 symbol(s) across 1 peer repo(s) — blast radius over derived cross-repo edges:
- pineforge-4pass/pineforge-hpo —
python/pineforge_hpo/transpile.py(via import_moniker, heuristic),tests/python/test_transpile.py(via import_moniker, heuristic)
1 peer repo(s) in this group are not yet indexed — cross-repo impact may be incomplete.
Cross-repo linkage is derived (SCIP-moniker matches are sound; name/contract matches are heuristic) and ADVISORY — it never affects this check's conclusion.
Test selection
Test selection
516 of 516 test file(s) selected (100%) via static blast radius.
Escalated to a full run for safety (
noncode-change,uncovered-code) — the selection is not trustworthy on its own (see below). CI should run the whole suite.
tests/test_action_pins.py— full-run-safetytests/test_allow_entry_in_qualifier.py— impact, full-run-safetytests/test_allow_entry_in_request_const.py— impact, full-run-safetytests/test_analyzer.py— full-run-safetytests/test_analyzer_matrix_inference.py— full-run-safetytests/test_analyzer_ta_return_types.py— full-run-safetytests/test_anchor_tf_change.py— impact, full-run-safetytests/test_anchor_time.py— impact, full-run-safetytests/test_anchor_vwap.py— impact, full-run-safetytests/test_array_bounds_insert_fill_slice.py— impact, full-run-safetytests/test_array_checked_access.py— impact, full-run-safetytests/test_array_empty_aggregates.py— impact, full-run-safetytests/test_array_history.py— impact, full-run-safetytests/test_array_missing_integer_index.py— impact, full-run-safetytests/test_array_new_color_fill_and_na_size.py— impact, full-run-safetytests/test_array_new_defaults_and_order.py— impact, full-run-safetytests/test_array_reference_semantics.py— impact, full-run-safetytests/test_array_retained_alias.py— impact, full-run-safetytests/test_array_slice_view.py— impact, full-run-safetytests/test_array_terminal_returns.py— impact, full-run-safetytests/test_arrseg_coded_run_stops.py— impact, full-run-safetytests/test_arrseg_review_fixes.py— impact, full-run-safetytests/test_binop_operand_order.py— impact, full-run-safetytests/test_block_shadow_primitive_types.py— impact, full-run-safetytests/test_block_var_plain_siblings.py— impact, full-run-safetytests/test_bool_na_refusal.py— impact, full-run-safetytests/test_bool_na_refusals_2.py— impact, full-run-safetytests/test_bool_na_values.py— impact, full-run-safetytests/test_bool_string_inference.py— impact, full-run-safetytests/test_builtin_name_shadow.py— impact, full-run-safetytests/test_builtin_named_locals_compile.py— impact, full-run-safetytests/test_builtin_named_locals_price_refusal.py— impact, full-run-safetytests/test_builtin_named_locals_receipt.py— impact, full-run-safetytests/test_builtin_named_locals_scope.py— impact, full-run-safetytests/test_builtin_named_parameters.py— impact, full-run-safetytests/test_builtin_named_parameters_reference.py— full-run-safetytests/test_calc_on_order_fills_codegen.py— impact, full-run-safetytests/test_calendar_lambda_locals.py— impact, full-run-safetytests/test_call_argument_order.py— impact, full-run-safetytests/test_callable_local_histories.py— impact, full-run-safetytests/test_callable_series_param_types.py— impact, full-run-safetytests/test_callable_string_series_type.py— impact, full-run-safetytests/test_callable_ta_forwarded_length.py— impact, full-run-safetytests/test_callable_var_first_reach.py— impact, full-run-safetytests/test_checked_settings.py— impact, full-run-safetytests/test_ci_engine_main_touched.py— full-run-safetytests/test_closed_trade_fold.py— full-run-safetytests/test_closed_trade_fold_emission.py— impact, full-run-safetytests/test_closed_trade_text_equals.py— impact, full-run-safetytests/test_coded_run_stops.py— impact, full-run-safety- … and 466 more
non-code file(s) changed (
CHANGELOG.md,docs/reference/support-contracts.md,pineforge_codegen/diagnostics_catalog.json,tests/fixtures/diagnostic_codes_pin.json) → running the full suite for safety (a code graph can't see config/fixture/data deps)
changed code file(s) with no mapped test (
CHANGELOG.md,docs/reference/support-contracts.md,pineforge_codegen/codegen/input.py) — a coverage gap or a missing link — running the full suite rather than only the selected tests
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Formal verification
No difference found (not proven): No behavior difference found in \_string\_setting\_arg (not a proof).
The verifier ran both versions of \_string\_setting\_arg on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify emit\_settings\_members.
The verifier did not have enough to check emit\_settings\_members, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 234 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_\_init\_\_.
The verifier did not have enough to check \_\_init\_\_, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)
Could not verify: Could not verify \_input\_default\_expression.
The verifier did not have enough to check \_input\_default\_expression, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: method — first parameter is `self`, which needs a constructed instance (not synthesizable)
Summary
A string input whose default is a
varvariable that holds a literal was translated with an empty default. Withthe generated C++ read
get_input_string("Mode", std::string(""))and the input manifest listed the input as"supported": falsewith default""and no options. A strategy that depends on the default option (if mode == "Long only") then ran differently from the same strategy with a literal default, with no error: on 8,000 ETHUSDT 15-minute chart bars at the default, such a strategy made 264 trades against 132 for its control. The same empty default was produced forinput.timeframe,input.symbol,input.sessionandinput.text_area, which read through the same string getter. This is form 4 of #372.This change carries the literal when the variable is a global that is declared once, before the input, with a string literal (or a constant expression of literals) and is never reassigned, in the getter, in the settings descriptor and in the manifest (default, options and
"supported": true). When it cannot prove that for a default, it refuses the script with a located error,PF-E5096, at the variable, instead of using an empty default.Scope: the rule applies to a variable used as the whole default, or as an option, of
input.string,input.timeframe,input.symbol,input.sessionandinput.text_area. A default that is some other expression that is not a literal (a call, a concatenation with a variable or a ternary written in the call itself) and the genericinput(...)overload keep their existing behaviour (see the last section); this change does not claim that no input can still get an empty default.Behaviour
Measured by translating each script at the base and at this head (the C++ and the manifest of every case are in the test file):
var D = "a"as the default ofinput.string,input.timeframe,input.symbol,input.sessionorinput.text_area"supported": truevar string D = "a",var D = "Long" + " only",var D = true ? "a" : "b"(a constant expression of literals), also when the strategy readsDitselfvar A = "a",var B = "b"as the options ofinput.stringD = "a"orconst string D = "a"alias = original) as default and optionvar CUR = currency.USD(a built-in string constant)"USD")var D = "a"andD := "b"orD += "b"anywhere, also inside anif, aforor a functionPF-E5096atDvar Dinitialized from a call (str.tostring(1),str.upper("a")), a runtime member (syminfo.ticker), another variable (var D = original,var D = P + " only"), another input or a function parameter; avaripPF-E5096atDvar Ddeclared inside a functionPF-E5096atDvar Ddeclared twicevar S = size.small(an inert visual constant)input.boolorinput.floatwith avardefaultWhat changes
pineforge_codegen/codegen/constant_values.py(+69 -4)ConstantResolver.string_input_argument: the proof for a global string variable (one declaration, before the read, no write anywhere, literal-only initializer, or a built-in string constant);_STRING_INPUT_CONSTANT_MEMBERS, the shared inventory of built-in string constantspineforge_codegen/codegen/input.py(+30 -1)_string_input_argument: resolves a default (refusing when unproven) or an option (never refusing) before the empty coercion;_input_default_expressionuses it for every input whose getter is the string getterpineforge_codegen/codegen/checked_settings.py(+5 -10)pineforge_codegen/diagnostics_catalog.json,tests/fixtures/diagnostic_codes_pin.json,tests/test_diagnostic_notes.py(+1 each)PF-E5096, its pin, and its entry in the list of codes added since the snapshotdocs/reference/support-contracts.md(+8 -1)CHANGELOG.md(+5)tests/test_input_string_var_default.py(new, 614 lines)Tests
The change is two commits. The first adds the rows and fails on its own, because the translation still produced the empty default; the second adds the code, the catalog entry and its pin, the documentation paragraph and the changelog line.
The new file
tests/test_input_string_var_default.pyhas rows that need no engine and rows that run a strategy:vardefault and avaroption (the script of the issue, its options variant, and a variant with another variable name, literal and title), a plain control for each, and escaping of carried text;if, aforand a function, a call initializer, a runtime member, a declaration inside a function, a function parameter, another input, avarip; the message, hint, code and position are asserted exactly;constand folded-alias defaults, inert visual constants, unprovable options (supported false, no error), bool and float inputs, the order of errors when a settings error and a body error both occur;Checks
All runs below are on clean checkouts of the exact commits with Python 3.12 and pytest 9; hosted CI and Python 3.11, 3.13 and 3.14 are not part of these numbers.
var. The engine rows of the test file compare the trades of each pair of scripts on the same chart bars (20,000 bars for the strategy pairs, 8,000 for the bool and float pairs); they fail at the tests-only commit and pass at this head.tests/test_host_member_names.py::test_committed_host_members_are_the_derived_setandtests/test_parser.py::test_parse_basic_strategy[corpus-missing]; those two files give the same two failures at the base (48 passed, 2 failed and 27 skipped at the base and at this head).Not in this change
The other forms of #372 are separate. An
input.boolorinput.floatwhose default is avarvariable gives the same trades as its plain control, but its manifest default isnullinstead of the literal; this change does not touch it.Only a variable used as the whole default or option of the five functions above is resolved. A default that is another non-literal expression written in the call (a call, a concatenation with a variable, a ternary) and the generic
input(...)overload (includinginput(VAR, "Title")in a Pine v5 script) are not covered: they keep the behaviour they had before this change, including the empty default that_coerce_string_input_defaultgives to a lowered default that is not a string.🤖 Generated with Claude Code