6.5 KiB
G-175 @dynamic resolve multiplicity - one render, one resolution (verdict + optional landing)
Status: proposed Scope: src/modules/punk/args-999999.0a1.0.tm (resolve @dynamic cache-skip, by-id entry points get_spec/parse_status/arg_error/synopsis); src/modules/punk/ns-999999.0a1.0.tm (cmdhelp render pipeline - the four independent by-id fetches); src/tests/modules/punk/args/testsuites/args/dynamic.test + src/tests/modules/punk/ns/testsuites/ns/cmdhelp.test (once-per-render counter pins + cross-render freshness pins, if the landing arm is taken) Goal: a verdict with evidence on whether re-resolving @dynamic definitions multiple times within ONE user-level render (the 'i ' pipeline) is architecturally required - and if not, either a render-scoped single-resolution mechanism lands (each @dynamic definition's substitution scripts run once per cmdhelp invocation, byte-identical output, cross-render freshness untouched) or the per-pass re-resolution is pinned as a recorded decision with its measured cost, the rationale written in this file. Acceptance: the resolve-level cost share of the multiplicity is measured (not just the subcommand-builder share - each pass re-runs full argdata processing of the definition text) and recorded here; the by-id fetch sites in the cmdhelp pipeline are enumerated with which could accept a pre-resolved spec; a decision is recorded - EITHER a landed mechanism with pins (a counter fixture proving a dynamic definition's substitution scripts run exactly once per cmdhelp render; byte-identical render output; a mutating fixture proving a SECOND render still observes changed state - the @dynamic freshness contract across renders survives) OR a pinned-as-accepted rationale; punk/args and punk/ns suites pass under the canonical runtests interpreter either way.
Context
Surfaced 2026-08-08 while root-causing the 'i ::tcl::prefix' unqualified-ns stderr warnings (12 = 4 resolves x 3 subcommands - the warning fix landed separately, punk::args 0.25.2 / tclcore 0.4.2). The warning was the only SIGNAL that the ::tcl::prefix @dynamic definition was being re-resolved 4 times in a single 'i' render; with it fixed, the multiplicity is silent.
Probe evidence (trace on the tclcore tclprefix_subcommands builder, tclsh90s, 2026-08-08 - line anchors point-in-time):
RESOLVE#1 via resolve < get_spec < parse_status < cmdhelp
RESOLVE#2 via resolve < get_dict < parse < parse_status < cmdhelp
RESOLVE#3 via resolve < get_spec < cmdhelp
RESOLVE#4 via resolve < get_spec < synopsis < arg_error < cmdhelp
Four independent by-id fetches: parse_status resolves twice on its own (spec fetch plus its internal parse), cmdhelp fetches the spec for rendering, and the arg_error/synopsis path fetches again. Each fetch is correct in isolation; nothing shares the resolved spec across the pipeline.
Architecture (args-999999.0a1.0.tm as at 2026-08-08): resolve consults the
rawdef_cache_argdata cache ONLY for non-dynamic definitions (if {!$is_dynamic}
~args:2175) - a @dynamic definition re-runs its tstr command substitutions AND the
full argdata processing on every resolve. That per-resolve re-expansion is the
documented contract (define -help: "@dynamic definitions re-expand on every
resolve" / "Use @dynamic only when the value can change between resolves") and is
what keeps e.g ensemble subcommand choice-tables live when ensembles are extended
at runtime. ALL tclcore ensemble docs are @dynamic (info, encoding, dict, file,
namespace, array, zipfs, tcl::prefix), so every 'i ' pays the
multiplicity.
Measured cost (steady-state second render, warmed lazy loads, tclsh90s):
i ::tcl::prefix total ~196ms; subcommand-builder 4 calls = 4.4ms (2%)
i ::dict total ~701ms; subcommand-builder 4 calls = 29.8ms (4%)
The builder share is the FLOOR of the waste (3 redundant calls of 4): the full argdata re-processing per redundant resolve is not yet isolated - measuring it is the first investigation step. Note the totals themselves: the multiplicity sits inside renders already costing 200-700ms.
Approach
- Measure the resolve-level share: time resolve for a representative @dynamic definition (tcl::prefix small, dict large) and multiply out the redundant passes; record here. If the whole-render saving is negligible even for dict, the pin-as-accepted arm is available cheaply.
- Enumerate the by-id entry points in the cmdhelp pipeline (parse_status x2, get_spec, arg_error/synopsis) and determine which can accept a pre-resolved spec or share a resolution without API breakage (several already have spec-shaped internal forms).
- Candidate mechanisms, smallest-first: a. cmdhelp resolves once and passes the resolved spec down (plumbing change, punk::ns-side; punk::args API additions only where a by-id entry lacks a by-spec form). b. a render-scoped resolution context in punk::args (explicit begin/end or token-passed), dynamic argdata memoized within the context only. c. epoch/generation-keyed short cache - REJECT-by-default: invalidation semantics are exactly the hard part, and the @dynamic contract is per-resolve freshness.
- Whichever arm: pin the outcome (once-per-render counter fixture + byte-identical render + cross-render freshness for the landing arm; measured-cost rationale recorded here for the accept arm).
Notes
- Risk to respect: a @dynamic substitution script may observe state mutated MID-render (in principle even by an earlier substitution). Render-scoped memoization changes that observable - almost certainly acceptable (a render is one logical moment), but it is the semantic delta to state explicitly in the decision.
- The stale-colour REVIEW comment beside the non-dynamic cache consult (~args:2176 "don't use cached version if 'colour off' vs 'colour on' different...") shows even the static cache has known freshness caveats - any new memoization should not widen that class.
- parse_status resolving twice by itself (spec + its internal parse) may be worth collapsing independently of the dynamic question - it halves the multiplicity for ALL definitions, static ones included (static hits the cache, so the win there is small but the call-shape cleanup may pay anyway).
- Related arcs: the G-046 display-field masking (achieved, see goals/archive/G-046-punkargs-deferred-help-and-fixes.md - expensive -help processing deferred to display time) already splits parse-relevant from display-relevant work - a render-scoped mechanism should compose with it, not duplicate it.