Resolve the SP2b multi-pick semantics:
- range count drawn in [min,max] (a fixed {N$$} keeps min==max and does NOT
draw, so its seed sequence is byte-for-byte unchanged);
- independent (~) = N draws WITH replacement, each branch resolved
independently (a lone ref re-rolls every time);
- unique lone-ref (count>=2, wildcard surface) = N DISTINCT options from the
ref filtered pool — a per-site SP2a multi-select. count==1 stays on the
legacy single-pick branch path (seed safe).
Extracts ref_option_pool (catalog lookup + :expr/!null filter + chain
constraints, incl. the strict-unknown raise + warnings) from _resolve_ref so
both the single pick and the lone-ref multi-pick share it. Also closes the
coverage gap: a $var branch inside a multi-block on the combine surface.
The {N$$sep$$} brace prefix now also accepts a count range ({N-M$$}) and a
~ independent flag ({N~$$} / {N-M~$$}, repeats allowed; absent = unique).
Both tokenizers (Python engine + TS richTokenize) parse it identically
into dp-multi meta {min, max, independent}; legacy `count` is kept (== max)
so existing readers + the resolver behave unchanged for a fixed {N$$}.
Shared syntax-corpus.json locks Py≡TS parity (existing multi_pick entries
gain the new keys; new range/lone-ref cases added).
Resolution of the range/independent semantics lands next (engine resolver);
this commit is tokenizer + parity only — a fixed {N$$} is byte-for-byte
unchanged.
Wildcard + fixed_values surfaces are binding PRODUCERS, not consumers
— they don't read upstream \$var. Combine + derivation + assembler do
(template interpolation is their reason to exist).
Adds VarOutOfSurfaceError to engine.syntax.types + extends SurfaceKind
literal with "fixed_values". _resolve_var checks surface membership
in _VAR_SURFACES_ALLOWED frozenset; out-of-surface refs warn + render
literal \$name in lenient mode (existing default), raise in strict
mode (used by tests + dev mode).
Lenient strategy means workflows saved before this cycle keep
resolving — the warning surfaces in toast / conflict scanner without
crashing the queue.
Test fixture adjustments document the new contract:
- tests/syntax/test_resolve.py default surface flips wildcard -> combine
(these tests cover VAR resolution mechanics, not surface gating)
- tests/syntax/test_determinism.py corpus single_var + var_in_text
cases use surface=combine
- tests/engine/modules/test_wildcard_handler.py:
* test_resolve_var_substitution_in_option renamed to
test_resolve_var_in_option_renders_literal_warns + asserts new
lenient path
* test_resolve_pinned_mode_resolves_inline_syntax swaps \$color for
a {red|red} alternation that DOES resolve on wildcard surface
Surface gating tests live in tests/engine/syntax/test_resolve_var_surface.py
covering all 5 surfaces + lenient vs strict modes + internal-key skip.