Skip to content

Carry a variable-held string input default, or refuse it, instead of an empty default - #374

Merged
luisleo526 merged 2 commits into
mainfrom
i372-form4-default
Nov 10, 2026
Merged

luisleo526 merged 2 commits into
mainfrom
i372-form4-default

Conversation

@luisleo526

Copy link
Copy Markdown
Contributor

Summary

A string input whose default is a var variable that holds a literal was translated with an empty default. With

var DEFAULT_MODE = "Long only"
mode = input.string(DEFAULT_MODE, "Mode", options = ["Long only", "Long and short"])

the generated C++ read get_input_string("Mode", std::string("")) and the input manifest listed the input as "supported": false with 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 for input.timeframe, input.symbol, input.session and input.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.session and input.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 generic input(...) 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):

Script Before After
var D = "a" as the default of input.string, input.timeframe, input.symbol, input.session or input.text_area empty default, input listed as unsupported default carried, "supported": true
var string D = "a", var D = "Long" + " only", var D = true ? "a" : "b" (a constant expression of literals), also when the strategy reads D itself empty default carried
var A = "a", var B = "b" as the options of input.string options dropped, input unsupported options carried
D = "a" or const string D = "a" carried unchanged
a plain alias of a literal (alias = original) as default and option carried unchanged
var CUR = currency.USD (a built-in string constant) empty default carried ("USD")
var D = "a" and D := "b" or D += "b" anywhere, also inside an if, a for or a function empty default, no error error PF-E5096 at D
var D initialized 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; a varip empty default error PF-E5096 at D
var D declared inside a function empty default error PF-E5096 at D
var D declared twice existing error at the second declaration unchanged
var S = size.small (an inert visual constant) empty default unchanged
an option that is a reassigned variable option list dropped, input unsupported unchanged (no error: an option does not change what a strategy computes)
input.bool or input.float with a var default same trades as the plain declaration unchanged

What changes

File Change
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 constants
pineforge_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_expression uses it for every input whose getter is the string getter
pineforge_codegen/codegen/checked_settings.py (+5 -10) options go through the same resolver; the inventory of string constants is shared with the resolver
pineforge_codegen/diagnostics_catalog.json, tests/fixtures/diagnostic_codes_pin.json, tests/test_diagnostic_notes.py (+1 each) the code PF-E5096, its pin, and its entry in the list of codes added since the snapshot
docs/reference/support-contracts.md (+8 -1) the paragraph on string defaults and options held by variables
CHANGELOG.md (+5) one bullet
tests/test_input_string_var_default.py (new, 614 lines) the rows described below

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.py has rows that need no engine and rows that run a strategy:

  • the emitted getter default, the settings descriptor and the manifest for a var default and a var option (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;
  • the located refusals, each with its positive control: a later write, a write inside an if, a for and a function, a call initializer, a runtime member, a declaration inside a function, a function parameter, another input, a varip; the message, hint, code and position are asserted exactly;
  • the cases that must not change: literal, plain, const and 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;
  • the four sibling input kinds, each against its plain control, and a variable that holds a built-in string constant;
  • engine rows that run the strategy of the issue under its default and under an explicit alternate choice, the strategy that tests the default option, the literal workaround, and a bool and a float pair, each against its plain control on the same chart bars, comparing the trades.

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.

  • The new test file at the tests-only commit, without the engine (the engine rows skip): 14 passed, 23 failed, 5 skipped. With the engine (engine 1.11.0 built from its tag, the corpus 1m feed, native inputs required, nothing skipped): 18 passed, 24 failed.
  • The same file at this head: 37 passed and 5 skipped without the engine; 42 passed, 0 failed and 0 skipped with it.
  • The strategy of the issue edited to test its default option, run on the base with the engine over 8,000 ETHUSDT 15-minute chart bars and no input value set: 264 trades, against 132 for the same strategy with the variable declared without 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.
  • Translation of 1,267 Pine sources at the base and at this head, the whole answer (C++, manifest, diagnostics, accepted or refused) compared file by file: 1,245 identical and 22 different. The 22 are scripts written for this change (the scripts of the issue, the sibling input kinds, and one script for each case of the table above); none of the 856 tracked sources and none of the 325 corpus strategies changes. Accepted 1,108 and refused 159 at the base; accepted 1,100 and refused 167 at the head, the eight new refusals being the probe scripts of the table. The base against itself gave 1,239 of 1,239 identical on the first run of this comparison, as a control that it is deterministic.
  • 17 existing test files about inputs, settings, metadata and diagnostics, without the engine: 3,144 passed, 303 skipped (the engine, its corpus or private fixtures are not present), 0 failed. 12 existing engine-enabled test files about inputs, at this head with the engine: 210 passed, 0 failed, 0 skipped.
  • The whole test folder at this head, without the engine (eight workers): 22,877 passed, 4,780 skipped (the engine library, the engine checkout and its corpus, or private fixtures are not present in that run), 2 failed. The two failures are tests/test_host_member_names.py::test_committed_host_members_are_the_derived_set and tests/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.bool or input.float whose default is a var variable gives the same trades as its plain control, but its manifest default is null instead 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 (including input(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_default gives to a lowered default that is not a string.

🤖 Generated with Claude Code

luisleo526 and others added 2 commits November 10, 2026 23:21
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>
@luisleo526
luisleo526 merged commit 632e52c into main Nov 10, 2026
11 checks passed

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-safety
  • tests/test_allow_entry_in_qualifier.py — impact, full-run-safety
  • tests/test_allow_entry_in_request_const.py — impact, full-run-safety
  • tests/test_analyzer.py — full-run-safety
  • tests/test_analyzer_matrix_inference.py — full-run-safety
  • tests/test_analyzer_ta_return_types.py — full-run-safety
  • tests/test_anchor_tf_change.py — impact, full-run-safety
  • tests/test_anchor_time.py — impact, full-run-safety
  • tests/test_anchor_vwap.py — impact, full-run-safety
  • tests/test_array_bounds_insert_fill_slice.py — impact, full-run-safety
  • tests/test_array_checked_access.py — impact, full-run-safety
  • tests/test_array_empty_aggregates.py — impact, full-run-safety
  • tests/test_array_history.py — impact, full-run-safety
  • tests/test_array_missing_integer_index.py — impact, full-run-safety
  • tests/test_array_new_color_fill_and_na_size.py — impact, full-run-safety
  • tests/test_array_new_defaults_and_order.py — impact, full-run-safety
  • tests/test_array_reference_semantics.py — impact, full-run-safety
  • tests/test_array_retained_alias.py — impact, full-run-safety
  • tests/test_array_slice_view.py — impact, full-run-safety
  • tests/test_array_terminal_returns.py — impact, full-run-safety
  • tests/test_arrseg_coded_run_stops.py — impact, full-run-safety
  • tests/test_arrseg_review_fixes.py — impact, full-run-safety
  • tests/test_binop_operand_order.py — impact, full-run-safety
  • tests/test_block_shadow_primitive_types.py — impact, full-run-safety
  • tests/test_block_var_plain_siblings.py — impact, full-run-safety
  • tests/test_bool_na_refusal.py — impact, full-run-safety
  • tests/test_bool_na_refusals_2.py — impact, full-run-safety
  • tests/test_bool_na_values.py — impact, full-run-safety
  • tests/test_bool_string_inference.py — impact, full-run-safety
  • tests/test_builtin_name_shadow.py — impact, full-run-safety
  • tests/test_builtin_named_locals_compile.py — impact, full-run-safety
  • tests/test_builtin_named_locals_price_refusal.py — impact, full-run-safety
  • tests/test_builtin_named_locals_receipt.py — impact, full-run-safety
  • tests/test_builtin_named_locals_scope.py — impact, full-run-safety
  • tests/test_builtin_named_parameters.py — impact, full-run-safety
  • tests/test_builtin_named_parameters_reference.py — full-run-safety
  • tests/test_calc_on_order_fills_codegen.py — impact, full-run-safety
  • tests/test_calendar_lambda_locals.py — impact, full-run-safety
  • tests/test_call_argument_order.py — impact, full-run-safety
  • tests/test_callable_local_histories.py — impact, full-run-safety
  • tests/test_callable_series_param_types.py — impact, full-run-safety
  • tests/test_callable_string_series_type.py — impact, full-run-safety
  • tests/test_callable_ta_forwarded_length.py — impact, full-run-safety
  • tests/test_callable_var_first_reach.py — impact, full-run-safety
  • tests/test_checked_settings.py — impact, full-run-safety
  • tests/test_ci_engine_main_touched.py — full-run-safety
  • tests/test_closed_trade_fold.py — full-run-safety
  • tests/test_closed_trade_fold_emission.py — impact, full-run-safety
  • tests/test_closed_trade_text_equals.py — impact, full-run-safety
  • tests/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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant