From 9ed0d3bf55e08a0ccfe1f97974ef7a6b7e947af8 Mon Sep 17 00:00:00 2001 From: M Date: Sun, 30 Aug 2026 23:09:58 +0200 Subject: [PATCH] Hold a calc operand's draw for the expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field the format rendered and a calc read was drawn twice, so the operand shown could differ from the operand computed — the same disagreement the dotted-path rule already fences, in its plainest spelling. The README carried it as a caveat, which is what a rule like this exists to remove. A calc operand now joins the names an expansion holds, so it is drawn once and every later read of it — the calc, and any bare token spelling it — sees that draw. The hold stays per expansion: each repeat iteration and each nested template draws its own. expand reads a calc's operands before the call and hands over their values, so a builtin takes (emitted, operands) rather than the sibling fields, and the evaluator indexes that slice instead of walking the node tree. That is what keeps the draws a local. Handing them to a builtin instead lets them escape through an indirect call, which put two maps on the heap for every held format, calc or not: BenchmarkBound went 56 B/5 allocs -> 744 B/10. Measured against main: Bound 359 -> 349 ns at 56 B/5 allocs, unchanged; Calc 416 -> 577 ns and one more alloc, which is what the correlation costs. Validation is unchanged: t.bound still carries only the levels a dotted token reads, so the overlap fences fence exactly what they did. Co-Authored-By: Claude Opus 5 --- builtins.go | 20 +++++------ calc.go | 100 +++++++++++++++++++++++++++++++++++----------------- node.go | 9 +++-- render.go | 68 ++++++++++++++++++++--------------- template.go | 44 ++++++++++++++++------- 5 files changed, 155 insertions(+), 86 deletions(-) diff --git a/builtins.go b/builtins.go index bf1889b..88627b2 100644 --- a/builtins.go +++ b/builtins.go @@ -31,35 +31,35 @@ var builtins = map[string]builtin{ "ulid": {arity: 0, prep: generate(ulid)}, "nanoid": {arity: 1, check: posIntArg, prep: func(a []string) callFn { n := atoi(a[0]) - return func(s *session, _ string, _ map[string]node) string { return nanoid(s, n) } + return func(s *session, _ string, _ []string) string { return nanoid(s, n) } }}, "hex": {arity: 1, check: posIntArg, prep: func(a []string) callFn { n := atoi(a[0]) - return func(s *session, _ string, _ map[string]node) string { return randHex(s, n) } + return func(s *session, _ string, _ []string) string { return randHex(s, n) } }}, "base64": {arity: 1, check: posIntArg, prep: func(a []string) callFn { n := atoi(a[0]) - return func(s *session, _ string, _ map[string]node) string { + return func(s *session, _ string, _ []string) string { return base64.StdEncoding.EncodeToString(randBytes(s, n)) } }}, "int": {arity: 2, check: intRangeArgs, prep: func(a []string) callFn { lo, span := atoi(a[0]), atoi(a[1])-atoi(a[0])+1 - return func(s *session, _ string, _ map[string]node) string { return strconv.Itoa(lo + s.IntN(span)) } + return func(s *session, _ string, _ []string) string { return strconv.Itoa(lo + s.IntN(span)) } }}, "float": {arity: 3, check: floatArgs, prep: func(a []string) callFn { lo, _ := strconv.ParseFloat(a[0], 64) hi, _ := strconv.ParseFloat(a[1], 64) dp := atoi(a[2]) - return func(s *session, _ string, _ map[string]node) string { + return func(s *session, _ string, _ []string) string { return strconv.FormatFloat(lo+s.Float64()*(hi-lo), 'f', dp, 64) } }}, "iban": {arity: 1, check: ibanArg, prep: func(a []string) callFn { cc := a[0] - return func(s *session, _ string, _ map[string]node) string { return iban(s, cc) } + return func(s *session, _ string, _ []string) string { return iban(s, cc) } }}, - // calc is the one builtin that reads the sibling fields (to render its operands); + // calc is the one builtin that names operands (expand reads them for it); // every other ignores them. 1 or 2 args: the expression and an optional decimals. "calc": {arity: -1, check: checkCalc, prep: calcPrep}, // seq is the one stateful builtin: a per-session counter from 1, advancing on @@ -70,7 +70,7 @@ var builtins = map[string]builtin{ if len(a) == 1 { key = a[0] } - return func(s *session, _ string, _ map[string]node) string { + return func(s *session, _ string, _ []string) string { return strconv.FormatUint(s.next(key), 10) } }}, @@ -81,13 +81,13 @@ var builtins = map[string]builtin{ // lifts that one function into the prep every registry entry supplies. func derive(f func(emitted string) string) func([]string) callFn { return func([]string) callFn { - return func(_ *session, emitted string, _ map[string]node) string { return f(emitted) } + return func(_ *session, emitted string, _ []string) string { return f(emitted) } } } func generate(f func(rng) string) func([]string) callFn { return func([]string) callFn { - return func(s *session, _ string, _ map[string]node) string { return f(s) } + return func(s *session, _ string, _ []string) string { return f(s) } } } diff --git a/calc.go b/calc.go index bffada5..578607c 100644 --- a/calc.go +++ b/calc.go @@ -9,42 +9,48 @@ import ( ) // calc is the {calc(expr[, dp])} token: an arithmetic expression over number -// literals and sibling-field names (rendered, then parsed as numbers), with -// + - * /, unary minus and parentheses. It is a registry builtin like any other -// {name(args)} function — the one whose check and prep read the sibling fields (to -// render its operands). The value prints in minimal decimal form, or rounded to dp -// when given. A field that doesn't render to a number becomes NaN, which -// propagates and prints as "NaN" — visible, never a render error. The expression is -// parsed once at New into an AST every render shares and none mutates, so render -// cannot fail and must not carry per-render state. +// literals and sibling-field names, with + - * /, unary minus and parentheses. It is +// a registry builtin like any other {name(args)} function — the one that names +// operands, which expand reads for it (once per expansion, so the value computed is +// the value the format showed) and hands over as strings. The value prints in +// minimal decimal form, or rounded to dp when given. An operand that isn't a number +// becomes NaN, which propagates and prints as "NaN" — visible, never a render error. +// The expression is parsed once at New into an AST every render shares and none +// mutates, so render cannot fail and must not carry per-render state. -// calcNode is a parsed expression node. +// calcNode is a parsed expression node. It evaluates over the operand values expand +// read, so the evaluator touches neither the rng nor the node tree. type calcNode interface { - eval(s *session, fields map[string]node) float64 + eval(operands []string) float64 } type calcNum float64 // a number literal -type calcVar string // a sibling-field name +type calcVar string // a sibling-field name, before indexVars places it +type calcIdx int // an operand, by its position in the values expand read type calcNeg struct{ x calcNode } type calcBin struct { // a + - * / b op byte l, r calcNode } -func (n calcNum) eval(*session, map[string]node) float64 { return float64(n) } +func (n calcNum) eval([]string) float64 { return float64(n) } -func (n calcVar) eval(s *session, fields map[string]node) float64 { - v, err := strconv.ParseFloat(strings.TrimSpace(render(s, fields[string(n)])), 64) +func (n calcIdx) eval(operands []string) float64 { + v, err := strconv.ParseFloat(strings.TrimSpace(operands[n]), 64) if err != nil { return math.NaN() // a non-numeric operand stays visible, never an error } return v } -func (n calcNeg) eval(s *session, fields map[string]node) float64 { return -n.x.eval(s, fields) } +// eval on an unplaced name cannot happen: calcPrep runs indexVars over every +// expression it compiles, so only a calcIdx reaches a render. +func (n calcVar) eval([]string) float64 { return math.NaN() } -func (n calcBin) eval(s *session, fields map[string]node) float64 { - l, r := n.l.eval(s, fields), n.r.eval(s, fields) +func (n calcNeg) eval(operands []string) float64 { return -n.x.eval(operands) } + +func (n calcBin) eval(operands []string) float64 { + l, r := n.l.eval(operands), n.r.eval(operands) switch n.op { case '+': return l + r @@ -82,19 +88,39 @@ func checkCalc(fields map[string]node, args []string) error { return nil } -// calcPrep parses the expression and decimals once, at compile time. checkCalc -// proved both valid, so neither step here can fail; dp -1 prints the minimal form. +// calcPrep parses the expression and decimals once, at compile time, and places each +// operand name at the position expand will read it into. checkCalc proved both args +// valid, so no step here can fail; dp -1 prints the minimal form. func calcPrep(args []string) callFn { expr, _ := parseCalc(args[0]) + at := make(map[string]int) + for i, name := range calcVars(expr) { + at[name] = i + } + placed := indexVars(expr, at) dp := -1 if len(args) == 2 { dp = atoi(args[1]) } - return func(s *session, _ string, fields map[string]node) string { - return strconv.FormatFloat(expr.eval(s, fields), 'f', dp, 64) + return func(_ *session, _ string, operands []string) string { + return strconv.FormatFloat(placed.eval(operands), 'f', dp, 64) } } +// indexVars replaces each operand name with its position in the values expand reads. +// Both sides take that order from calcVars, so they cannot drift. +func indexVars(n calcNode, at map[string]int) calcNode { + switch n := n.(type) { + case calcVar: + return calcIdx(at[string(n)]) + case calcNeg: + return calcNeg{indexVars(n.x, at)} + case calcBin: + return calcBin{n.op, indexVars(n.l, at), indexVars(n.r, at)} + } + return n +} + // calcOperands lists the sibling-field names every {calc(...)} token in a format // reads, so cycle detection sees the field edges calc renders through (a function // token otherwise carries no field edge). @@ -124,19 +150,29 @@ func calcTokenOperands(body string) []string { return calcVars(expr) } -// calcVars lists the field names an expression references, for the existence -// check in checkCalc. +// calcVars lists the distinct field names an expression reads, in the order it first +// names each. That order is the contract between expand, which reads the operands +// into a slice, and indexVars, which places each name at its position in it. func calcVars(n calcNode) []string { - switch n := n.(type) { - case calcVar: - return []string{string(n)} - case calcNeg: - return calcVars(n.x) - case calcBin: - return append(calcVars(n.l), calcVars(n.r)...) - default: - return nil + var out []string + seen := map[string]bool{} + var walk func(calcNode) + walk = func(n calcNode) { + switch n := n.(type) { + case calcVar: + if name := string(n); !seen[name] { + seen[name] = true + out = append(out, name) + } + case calcNeg: + walk(n.x) + case calcBin: + walk(n.l) + walk(n.r) + } } + walk(n) + return out } // calcParser is a recursive-descent parser over the expression runes, threading diff --git a/node.go b/node.go index 7189df0..76ddbbd 100644 --- a/node.go +++ b/node.go @@ -47,9 +47,12 @@ type template struct { ops []op // format compiled once (see compileOps); what expand walks grow int // minimum output size, to size the render buffer // bound maps each field the format addresses by dotted path to one path token - // reading it: drawn once per expansion (see compileOps and expand), and the - // token names the other half of an overlap. nil when the format takes no path. + // reading it, which is the half of an overlap the fences name. nil when the + // format takes no path. bound map[string]string + // held is every name drawn once per expansion: the bound levels above, plus the + // siblings a {calc()} reads. nil when the format holds nothing (see expand). + held map[string]bool } func (*template) isNode() {} @@ -160,7 +163,7 @@ func compileTemplate(m map[string]any) (node, error) { if err := checkTokens(format, t.fields); err != nil { return nil, err } - t.ops, t.grow, t.bound = compileOps(format) + t.ops, t.grow, t.bound, t.held = compileOps(format) if err := checkNoOverlap(format, t.bound); err != nil { return nil, err } diff --git a/render.go b/render.go index cb307e8..865d0ae 100644 --- a/render.go +++ b/render.go @@ -137,13 +137,14 @@ func classChar(s *session, c rune) byte { func expand(s *session, t *template) string { var b strings.Builder b.Grow(t.grow) - // One draw per bound head, held for this expansion only: a nested template and - // each repeat iteration get their own, since each is its own expansion. - var bound *draws - if len(t.bound) > 0 { - bound = &draws{ - variant: make(map[string]node, len(t.bound)), - value: make(map[string]string, len(t.bound)), + // One draw per held name, for this expansion only: a nested template and each + // repeat iteration get their own, since each is its own expansion. held stays a + // local: nothing hands it to a builtin, so it and its maps never leave the stack. + var held *draws + if len(t.held) > 0 { + held = &draws{ + variant: make(map[string]node, len(t.held)), + value: make(map[string]string, len(t.held)), } } for i := range t.ops { @@ -154,57 +155,66 @@ func expand(s *session, t *template) string { case 'c': b.WriteByte(classChar(s, o.r)) case 'f': - b.WriteString(resolve(s, o.arms, t, bound)) + b.WriteString(readField(s, t, held, o.arms[s.IntN(len(o.arms))])) case 'b': - b.WriteString(o.call(s, b.String(), t.fields)) // b.String() is the output so far + // A calc's operands are read here, in the order its expression first + // names them, so the value it computes is the value the format showed. + var operands []string + if len(o.operands) > 0 { + operands = make([]string, len(o.operands)) + for j, name := range o.operands { + operands[j] = readField(s, t, held, arm{name: name, key: name}) + } + } + b.WriteString(o.call(s, b.String(), operands)) // b.String() is the output so far } } return b.String() } -// draws is what an expansion has already drawn for its bound heads: the variant -// each head was drawn as, so every path under it reads one row, and the value each -// path read, so the same path read twice reads one value. +// draws is what an expansion has already drawn for its held names: the variant each +// was drawn as, so every path under it reads one row, and the value each read, so +// the same name read twice reads one value. type draws struct { variant map[string]node value map[string]string } -// resolve renders one field alternation: the '|' alternatives, one picked at -// random. An arm's key is a sibling field or a {..path} reference, which linkRefs -// bound into fields too. A field the format addresses by dotted path is bound: it -// is drawn once for the expansion, so {place.postal-code} and {place.locality} -// read one row and either read twice gives one value. checkTokens, checkPath and -// linkRefs prove every step, so this cannot fail. -func resolve(s *session, arms []arm, t *template, bound *draws) string { - a := arms[s.IntN(len(arms))] - if _, isBound := t.bound[a.key]; !isBound { +// readField renders one arm of a token. An arm's key is a sibling field or a +// {..path} reference, which linkRefs bound into fields too. A name the expansion +// holds — a level some token addresses by dotted path, or a sibling a {calc()} +// reads — is drawn once and kept, so {place.postal-code} and {place.locality} read +// one row, either read twice gives one value, and a shown operand is the operand +// computed. Every other name is drawn afresh, so {word} {word} still draws twice. +// checkTokens, checkPath and linkRefs prove every step, so this cannot fail. +func readField(s *session, t *template, held *draws, a arm) string { + if !t.held[a.key] { return render(s, t.fields[a.key]) } - if v, read := bound.value[a.name]; read { + if v, read := held.value[a.name]; read { return v } - n, drew := bound.variant[a.key] + n, drew := held.variant[a.key] if !drew { n = drawn(s, t.fields[a.key]) - bound.variant[a.key] = n + held.variant[a.key] = n } // Hold the draw at every level passed through, so two paths sharing a prefix // share it. for i, seg := range a.tail { if i < len(a.steps) { - held, drew := bound.variant[a.steps[i]] + step, drew := held.variant[a.steps[i]] if !drew { - held = drawn(s, child(n, seg)) - bound.variant[a.steps[i]] = held + step = drawn(s, child(n, seg)) + held.variant[a.steps[i]] = step } - n = held + n = step continue } n = child(n, seg) } v := render(s, n) - bound.value[a.name] = v + held.value[a.name] = v return v } diff --git a/template.go b/template.go index 0fa2962..1074976 100644 --- a/template.go +++ b/template.go @@ -60,8 +60,8 @@ func eachToken(format string, fn func(ftoken) error) error { // builtin is a format-string function invoked as {name(args)}. It receives the // session (its rng, and the {seq()} counters), the output emitted so far in the -// current expansion (for derivations such as a checksum over preceding digits), -// the sibling fields (only calc reads them, to render its operands), and its args. +// current expansion (for derivations such as a checksum over preceding digits), and +// the values of the operands it named (only calc names any). // Almost all are pure over (rng, emitted, args) — no wall-clock, no crypto/rand — // so seeding stays reproducible; a time-based id derives its time from the rng. // seq is the one exception: it advances per-session counter state, which is itself @@ -183,7 +183,7 @@ func checkNoRepeatedArm(body string, names []string) error { // fieldTokens returns the field and reference names a format renders via {name} // or {a|..b} tokens (function tokens, which carry no field edges, are excluded). -// These are exactly the child nodes expand's resolve recurses into. +// These are exactly the child nodes expand recurses into, through readField. func fieldTokens(format string) []string { var names []string _ = eachToken(format, func(t ftoken) error { @@ -303,8 +303,10 @@ func splitArms(body string) []arm { return arms } -// callFn is a builtin bound to one call site: its args already parsed. -type callFn func(s *session, emitted string, fields map[string]node) string +// callFn is a builtin bound to one call site: its args already parsed. It reads the +// output emitted so far in the current expansion (a derivation's payload) and the +// values of the operands it named, which expand read for it. +type callFn func(s *session, emitted string, operands []string) string // op is one compiled unit of a format string: a literal run, a class char, a field // alternation, or a builtin already bound to its args. compile builds these so @@ -315,19 +317,32 @@ type op struct { r rune // kind 'c' arms []arm // kind 'f': the '|' alternatives, split into key and path once call callFn + // operands names the sibling fields a {calc()} reads, in the order its + // expression first names them. expand reads them before the call, so the + // evaluator never touches the node tree. nil for every other builtin. + operands []string } // compileOps turns a format string into ops, and returns the smallest output it can // produce (literals plus one byte per class char) to size the render buffer, plus -// the fields the format addresses by dotted path — each drawn once per expansion, -// so every token reading one sees the same row (see expand). bound is nil when the -// format takes no path, so data that uses none carries no render-time cost. -// Call checkTokens first: it is what proves the scan and every token are valid. -func compileOps(format string) ([]op, int, map[string]string) { +// two sets. bound is the levels the format addresses by dotted path, each mapped to +// the first path reading it, which is what the overlap fences name. held is every +// name drawn once per expansion — those levels, plus the siblings a {calc()} reads, +// so an operand shown is the operand computed. Both are nil when the format needs +// neither, so data that uses neither carries no render-time cost. Call checkTokens +// first: it is what proves the scan and every token are valid. +func compileOps(format string) ([]op, int, map[string]string, map[string]bool) { var ops []op var lit strings.Builder var bound map[string]string + var held map[string]bool grow := 0 + hold := func(name string) { + if held == nil { + held = map[string]bool{} + } + held[name] = true + } flush := func() { if lit.Len() > 0 { grow += lit.Len() @@ -346,7 +361,11 @@ func compileOps(format string) ([]op, int, map[string]string) { case 'b': flush() if name, args, ok := funcCall(t.body); ok { - ops = append(ops, op{kind: 'b', call: builtins[name].prep(args)}) + operands := calcTokenOperands(t.body) + for _, operand := range operands { + hold(operand) // a calc renders its operand, so the expansion holds that draw + } + ops = append(ops, op{kind: 'b', call: builtins[name].prep(args), operands: operands}) } else { arms := splitArms(t.body) for _, a := range arms { @@ -357,6 +376,7 @@ func compileOps(format string) ([]op, int, map[string]string) { if _, named := bound[a.key]; !named { bound[a.key] = a.name // the first path reading it, for error messages } + hold(a.key) } } ops = append(ops, op{kind: 'f', arms: arms}) @@ -365,5 +385,5 @@ func compileOps(format string) ([]op, int, map[string]string) { return nil }) flush() - return ops, grow, bound + return ops, grow, bound, held }