From d0c477ec7818a337f465cf19565517f23553d31b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 3 Oct 2026 02:05:02 +0200 Subject: [PATCH] Keep a name's frame with the read entering its category, refuse a binding in a choice's item, and read a name as a transform operand --- CHANGELOG.md | 11 +-- README.md | 15 ++-- docs/decisions.md | 17 +++-- draws.go | 6 +- format.go | 16 +++- name.go | 186 ++++++++++++++++++++++++++++------------------ node.go | 7 +- record.go | 3 +- reference.go | 4 +- render.go | 54 +++++++------- todo.md | 9 ++- 11 files changed, 198 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 230d153..581ffa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,12 @@ replacement, and each removed path, column or flag. - A name keeps one pick: `{/misc.territory as t}` binds one row of `misc.territory` to `t` and prints nothing, and `{t}`, `{t.capital}` and `{t.calling-code}` read that one row. A name lives in the category binding it, so every field and record column of the - category reads one pick, and a name a `repeat` binds picks again on every iteration. - Refused at `New`: a name bound twice in one category, or inside a `repeat` and outside - it; a name that is a field or an option; a binding of anything but a reference; a - binding nothing reads; a path through a name that selects a row; and a binding in a - table. A field, column, category or folder name may not contain ` as `. + category reads one pick, a transform reads it as `{lowercase(t.name)}`, and a name a + `repeat` binds picks again on every iteration. Refused at `New`: a name bound twice in + one category, or inside a `repeat` and outside it; a name that is a field or an option; + a binding of anything but a reference; a binding in a choice's item; a binding nothing + reads; a path through a name that selects a row; and a binding in a table. A field, + column, category or folder name may not contain ` as `. - First release: the CLI, the library and the shipped data set. - Table categories: a category JSON naming a `rows` TSV beside it, with the options `key`, `name`, `weight` and `parent`; a path selects a row by key or name, diff --git a/README.md b/README.md index 6ca5188..12fa13d 100644 --- a/README.md +++ b/README.md @@ -656,7 +656,7 @@ fail, except in a [typed column](#datatype), which must prove neither happens. ### Transforms `{lowercase(x)}`, `{uppercase(x)}` and `{ascii(x)}` rewrite the value of `x` — a -field, a path or a `..path` — and nest. `ascii` folds Latin letters (`Åsa Öberg` +field, a path, a `..path` or a [name](#names) — and nest. `ascii` folds Latin letters (`Åsa Öberg` → `Asa Oberg`) and drops any other non-ASCII rune. `x` is held ([One draw, one spelling](#one-draw-one-spelling)), so an email built from a name matches the name beside it: @@ -709,17 +709,20 @@ row: ``` Renders e.g. `🇳🇴 Norway: capital Oslo, calling code +47`. Everything under a name is drawn -once, so `{n}` and `{n.path}` agree, and a field `n` renders twice prints the same text twice. +once, so `{n}` and `{n.path}` agree, a [transform](#transforms) of `n.path` reads the same +value, and a field `n` renders twice prints the same text twice. A name is a pick of its own: +`{t.name}` and `{/misc.territory.name}` beside it are two draws. + A name is drawn on its first read, and lives in the category binding it: any field of the category reads it, and a record's columns read one pick. Each render of the category picks anew, and so does each bare reference to it. A name a `repeat` binds picks again on every iteration, and one bound outside the repeat keeps its pick on every line. Rejected at load: a name bound twice in one category, or both inside a `repeat` and outside -it; a name that is a field or an option; a binding of anything but a reference; a binding -nothing reads; a path through a name that selects a row, which the reference selects instead; -and a binding in a table's format or cell. A field, column, category or folder name may not -contain ` as `. +it; a name that is a field or an option; a binding of anything but a reference; a binding in +a choice's item, which every other item would leave unbound; a binding nothing reads; a path +through a name that selects a row, which the reference selects instead; and a binding in a +table's format or cell. A field, column, category or folder name may not contain ` as `. ### Draw group diff --git a/docs/decisions.md b/docs/decisions.md index 73658b5..3f44575 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -894,10 +894,13 @@ a struct's fields as one. 2026-10-03, larv-review. Goals 5.3 and 5.6. `bindNames` scopes a name at compile, and a render keeps one `pickFrame` per scope. One field binds a name where another reads it, `"first": "{/person as p}{p.first}"` beside -`"last": "{p.last}"`, so the scope cannot be the template holding the binding; the category is the smallest unit holding every field and record column. The -scope is lexical: a category referencing another never sees its names, so what a category -renders never depends on who references it. A pick is drawn on its first read, so a record's -columns, rendered in name order, read one pick whichever binds it. A read entering a category -past its root, `Fake("cat.field")` or `{/cat.field}`, keeps the category's frame in the memo of -that read: per render and group, as the read's own draw is. Valid while a name is read only -inside the category binding it. +`"last": "{p.last}"`, so the scope cannot be the template holding the binding; the category +is the smallest unit holding every field and record column. A choice's item binds no name, +since a read outside the item would see a binding another item leaves out. The scope is +lexical: a read entering a category, by a reference or through a name, sees none of the +frames rendering above it, so what a category renders never depends on who references it. A +pick is drawn on its first read, so a record's columns, rendered in name order, read one pick +whichever binds it. A read entering a category keeps the frame of each scope it enters in its +own memo, and the category's root renders that frame when the read lands on the root, so +`{n}` and `{n.path}` read one pick: per render and group for a reference path, per pick for a +path through a name, and per render for `Fake("cat.field")`. diff --git a/draws.go b/draws.go index f49728b..3695b45 100644 --- a/draws.go +++ b/draws.go @@ -45,7 +45,11 @@ type renderScope struct { draws *renderDraws group string row renderedRow - entry *drawMemo // the memo of the read that entered what renders + // entry is the memo of the read that entered the category rendering, nil where none did; + // base, the depth of the frame stack when it entered, below which no frame is the + // category's. + entry *drawMemo + base int // pick is the named pick what renders is part of, at pickKey, its path from the name; nil // outside one. pick *namedPick diff --git a/format.go b/format.go index 185be82..5003366 100644 --- a/format.go +++ b/format.go @@ -247,7 +247,12 @@ func checkTokens(toks []formatToken, fields map[string]node) ([]unboundRead, err func checkToken(t formatToken, fields map[string]node) ([]unboundRead, error) { switch t.kind { case builtinCall: - return nil, checkFunc(t, fields) + err := checkFunc(t, fields) + var miss *fieldMiss + if errors.As(err, &miss) { + return []unboundRead{{miss.head, err}}, nil + } + return nil, err case nameBind: return nil, checkBind(t) case nameRead: @@ -493,10 +498,15 @@ func (c *formatOps) holdName(a arm, label string) { c.held[a.head] = h } -func (c *formatOps) function(tok formatToken, refs map[string]refBinding) { +func (c *formatOps) function(tok formatToken, refs map[string]refBinding, isName func(string) bool) { var operands []arm for _, operand := range tok.names { a := splitArm(operand, refs) + if isName(a.head) { + a.kind = namedRead + operands = append(operands, a) + continue + } c.holdName(a, fmt.Sprintf("%s operand %q", tok.fn, operand)) operands = append(operands, a) } @@ -528,7 +538,7 @@ func compileFormat(toks []formatToken, refs map[string]refBinding, isName func(h c.grow += len(tok.lit) c.ops = append(c.ops, op{formatToken: tok}) case builtinCall: - c.function(tok, refs) + c.function(tok, refs, isName) case nameRead: c.field(tok, refs, isName) } diff --git a/name.go b/name.go index b87b7e7..fb4dc6a 100644 --- a/name.go +++ b/name.go @@ -40,34 +40,38 @@ func (sc *nameScope) lookup(name string) *nameBinding { } // bindNames gives every template of a compiled category or inline template the scope its -// names live in, and refuses a name bound twice along one chain of scopes, a name a field -// spells too, a read no field or name answers, and a binding nothing reads. +// names live in, and refuses a name bound twice along one chain of scopes, a binding in a +// choice's item, a name a field spells too, a read no field or name answers, and a binding +// nothing reads. func bindNames(root node) error { - var scopes []*nameScope - var gather func(n node, scope *nameScope, where string) error - gather = func(n node, scope *nameScope, where string) error { + top := &nameScope{owner: root} + scopes := []*nameScope{top} + var gather func(n node, scope *nameScope, inChoice bool, where string) error + gather = func(n node, scope *nameScope, inChoice bool, where string) error { if t, isTemplate := n.(*template); isTemplate { if t.repeat > 1 && n != root { scope = &nameScope{up: scope, owner: t} scopes = append(scopes, scope) + inChoice = false } t.scope = scope - for _, tok := range t.tokens { - if tok.kind != nameBind { - continue - } - if err := scope.bind(tok, t, where); err != nil { - return err - } + if err := scope.bindAll(t, inChoice, where); err != nil { + return err } } - return eachContained(n, where, func(c node, where string) error { return gather(c, scope, where) }) + _, isChoice := n.(*choice) + return eachContained(n, where, func(c node, where string) error { return gather(c, scope, inChoice || isChoice, where) }) } - top := &nameScope{owner: root} - scopes = append(scopes, top) - if err := gather(root, top, ""); err != nil { + if err := gather(root, top, false, ""); err != nil { return err } + for _, sc := range scopes[1:] { + for _, b := range sc.order { + if outer := sc.up.lookup(b.name); outer != nil { + return fmt.Errorf("%stoken {%s}: name %q is bound outside this repeat already, by {%s}; rename one", b.where, b.body, b.name, outer.body) + } + } + } if err := resolveReads(root, ""); err != nil { return err } @@ -79,6 +83,37 @@ func bindNames(root node) error { return nil } +// bindAll binds every name t's tokens bind. A choice's item binds none: a read outside the item +// would find the name bound by an item that may never be drawn. +func (sc *nameScope) bindAll(t *template, inChoice bool, where string) error { + for _, tok := range t.tokens { + if tok.kind != nameBind { + continue + } + if inChoice { + return fmt.Errorf("%stoken {%s}: a choice's item binds no name, since every other item would leave it unbound; bind it outside the choice", where, tok.body) + } + if b, twice := sc.bindings[tok.bound]; twice { + return fmt.Errorf("%stoken {%s}: name %q is bound twice %s, by {%s} too; rename one", where, tok.body, tok.bound, sc.spelled(), b.body) + } + b := &nameBinding{name: tok.bound, ref: tok.names[0], body: tok.body, where: where, scope: sc, index: len(sc.order), binder: t} + if sc.bindings == nil { + sc.bindings = map[string]*nameBinding{} + } + sc.bindings[b.name] = b + sc.order = append(sc.order, b) + } + return nil +} + +// spelled names the scope in an error. +func (sc *nameScope) spelled() string { + if sc.up == nil { + return "outside any repeat" + } + return "in one repeat" +} + // close refuses a binding nothing reads, and gives the owner of a scope binding names the scope. func (sc *nameScope) close() error { for _, b := range sc.order { @@ -86,14 +121,8 @@ func (sc *nameScope) close() error { return fmt.Errorf("%stoken {%s}: nothing reads name %q; drop the token", b.where, b.body, b.name) } } - if len(sc.order) == 0 { - return nil - } - switch o := sc.owner.(type) { - case *template: - o.ownScope = sc - case *choice: - o.ownScope = sc + if t, isTemplate := sc.owner.(*template); isTemplate && len(sc.order) > 0 { + t.ownScope = sc } return nil } @@ -113,22 +142,6 @@ func eachContained(n node, where string, fn func(c node, where string) error) er return nil } -func (sc *nameScope) bind(tok formatToken, binder *template, where string) error { - if b, twice := sc.bindings[tok.bound]; twice { - return fmt.Errorf("%stoken {%s}: name %q is bound twice in one scope, by {%s} too; rename one", where, tok.body, tok.bound, b.body) - } - if b := sc.up.lookup(tok.bound); b != nil { - return fmt.Errorf("%stoken {%s}: name %q is bound outside this repeat already, by {%s}; rename one", where, tok.body, tok.bound, b.body) - } - b := &nameBinding{name: tok.bound, ref: tok.names[0], body: tok.body, where: where, scope: sc, index: len(sc.order), binder: binder} - if sc.bindings == nil { - sc.bindings = map[string]*nameBinding{} - } - sc.bindings[b.name] = b - sc.order = append(sc.order, b) - return nil -} - // resolveReads answers each unbound read under n with a name its template sees, fields before the // format as compile reports them, and refuses a field spelling a name. func resolveReads(n node, where string) error { @@ -141,7 +154,11 @@ func resolveReads(n node, where string) error { } for _, name := range sortedNames(t.fields) { if b := t.scope.lookup(name); b != nil { - return fmt.Errorf("%stoken {%s}: name %q is a field too; options, fields and names share one namespace, so rename one", b.where, b.body, name) + holder := "the root template" + if where != "" { + holder = strings.TrimSuffix(where, ": ") + } + return fmt.Errorf("%stoken {%s}: name %q is a field of %s too; options, fields and names share one namespace, so rename one", b.where, b.body, name, holder) } } for _, u := range t.unbound { @@ -163,13 +180,15 @@ func (t *template) isName(head string) bool { // every template is linked, so the binder has resolved that reference. func linkNames(path string, t *template) error { for i := range t.compiled.ops { - for j := range t.compiled.ops[i].arms { - a := &t.compiled.ops[i].arms[j] - if a.kind != namedRead { - continue - } - if err := linkName(t, a); err != nil { - return fmt.Errorf("%s: token {%s}: %w", t.site.label(path), a.spelling, err) + o := &t.compiled.ops[i] + for _, reads := range [][]arm{o.arms, o.operands} { + for j := range reads { + if reads[j].kind != namedRead { + continue + } + if err := linkName(t, &reads[j]); err != nil { + return fmt.Errorf("%s: token {%s}: %w", t.site.label(path), o.body, err) + } } } } @@ -223,23 +242,58 @@ type frameStack struct { frames []*pickFrame } -// pushFrame opens a frame of scope until popFrame closes it, returning the mark popFrame takes. -func (d *renderDraws) pushFrame(scope *nameScope) int { +// pushFrame opens f until popFrame closes it, returning the mark popFrame takes. +func (d *renderDraws) pushFrame(f *pickFrame) int { + mark := d.depth() if d.frames == nil { d.frames = &frameStack{} } - mark := len(d.frames.frames) - d.frames.frames = append(d.frames.frames, newPickFrame(scope)) + d.frames.frames = append(d.frames.frames, f) return mark } func (d *renderDraws) popFrame(mark int) { d.frames.frames = d.frames.frames[:mark] } -// pickOf is b's pick in the frame rendering its scope. A read entering the scope past its owner, -// as Fake("cat.field") or {/cat.field} do, keeps the frame in the memo of that read. +func (d *renderDraws) depth() int { + if d.frames == nil { + return 0 + } + return len(d.frames.frames) +} + +// entering is sc as a read entering another category sees it: none of the frames rendering, and +// memo, which keeps the frame of each scope the read enters past its owner. +func (sc renderScope) entering(memo *drawMemo) renderScope { + sc.base, sc.entry, sc.pick = sc.draws.depth(), memo, nil + return sc +} + +// frameOf is the frame a render of scope's owner opens: the one the read entering the category +// keeps, so {n} and {n.path} read one frame, else a fresh one. +func (sc renderScope) frameOf(scope *nameScope) *pickFrame { + if sc.entry == nil { + return newPickFrame(scope) + } + return sc.entry.frameOf(scope) +} + +func (m *drawMemo) frameOf(scope *nameScope) *pickFrame { + f, ok := m.frames[scope] + if !ok { + f = newPickFrame(scope) + if m.frames == nil { + m.frames = map[*nameScope]*pickFrame{} + } + m.frames[scope] = f + } + return f +} + +// pickOf is b's pick: in the frame rendering its scope since the read entering the category, else +// in the frame that read keeps, as for Fake("cat.field") or {/cat.field}. func (sc renderScope) pickOf(b *nameBinding) *namedPick { if stack := sc.draws.frames; stack != nil { - for i := len(stack.frames) - 1; i >= 0; i-- { + for i := len(stack.frames) - 1; i >= sc.base; i-- { if f := stack.frames[i]; f.scope == b.scope { return &f.picks[b.index] } @@ -249,19 +303,12 @@ func (sc renderScope) pickOf(b *nameBinding) *namedPick { if memo == nil { memo = &sc.groupDraws().memo } - f, ok := memo.frames[b.scope] - if !ok { - f = newPickFrame(b.scope) - if memo.frames == nil { - memo.frames = map[*nameScope]*pickFrame{} - } - memo.frames[b.scope] = f - } - return &f.picks[b.index] + return &memo.frameOf(b.scope).picks[b.index] } func readName(s *generatorState, sc renderScope, a arm) readValue { - return readPicked(s, sc.pickOf(a.bind), a.bind.head, a.steps, a.levels, a.path, sc) + p := sc.pickOf(a.bind) + return readPicked(s, p, a.bind.head, a.steps, a.levels, a.path, sc.entering(&p.memo)) } // drawRowOf draws the row a render of t reads: inside the pick's rows where t renders as part of one. @@ -297,19 +344,12 @@ func readPicked(s *generatorState, p *namedPick, head node, steps []pathStep, le return r } leaf := drawSteps(s, head, steps, &p.pins, &p.memo, levels) - mark := -1 if c, isChoice := leaf.(*choice); isChoice { - if c.ownScope != nil { - mark = sc.draws.pushFrame(c.ownScope) - } leaf = p.memo.variantOf(s, c, key) } sc = sc.at(leaf, &p.pins) - sc.pick, sc.pickKey, sc.entry = p, key, &p.memo + sc.pick, sc.pickKey = p, key r := renderLeaf(s, leaf, sc) - if mark >= 0 { - sc.draws.popFrame(mark) - } if p.memo.value == nil { p.memo.value = map[string]readValue{} } diff --git a/node.go b/node.go index 9920cc7..6438566 100644 --- a/node.go +++ b/node.go @@ -25,10 +25,9 @@ func (*folder) isNode() {} // relative dot paths every item can address, so carriedByAll and List both read the one // answer to what a path may reach through this choice. type choice struct { - items []node - cum []float64 - shared map[string]bool - ownScope *nameScope // set on a category's root binding names, by bindNames + items []node + cum []float64 + shared map[string]bool } func (*choice) isNode() {} diff --git a/record.go b/record.go index 85ab697..ede1fe8 100644 --- a/record.go +++ b/record.go @@ -264,8 +264,7 @@ func recordOf(n node) (*template, []Column, error) { func renderRecord(s *generatorState, t *template, columns []Column, sc renderScope) *Record { sc = sc.in(t) if t.ownScope != nil { - mark := sc.draws.pushFrame(t.ownScope) - defer sc.draws.popFrame(mark) + defer sc.draws.popFrame(sc.draws.pushFrame(sc.frameOf(t.ownScope))) } if t.site.isFormat() { sc = sc.at(t.site.table.rowNode, &sc.groupDraws().pins) diff --git a/reference.go b/reference.go index 784362c..93a064f 100644 --- a/reference.go +++ b/reference.go @@ -100,7 +100,9 @@ func compileArms(t *template) { for i := range t.compiled.ops { o := &t.compiled.ops[i] for j := range o.operands { - compileArm(t, &o.operands[j]) + if o.operands[j].kind != namedRead { + compileArm(t, &o.operands[j]) + } } for j := range o.arms { if o.arms[j].kind != namedRead { diff --git a/render.go b/render.go index c703bc1..779a6a3 100644 --- a/render.go +++ b/render.go @@ -61,10 +61,6 @@ func renderOnce(s *generatorState, n node) string { func render(s *generatorState, n node, sc renderScope) string { switch n := n.(type) { case *choice: - if n.ownScope != nil { - mark := sc.draws.pushFrame(n.ownScope) - defer sc.draws.popFrame(mark) - } return render(s, pick(s, n), sc) case *nullItem: return "" @@ -83,38 +79,48 @@ func render(s *generatorState, n node, sc renderScope) string { sc = sc.in(n) if n.repeat == 1 { if n.ownScope != nil { - mark := sc.draws.pushFrame(n.ownScope) - defer sc.draws.popFrame(mark) + defer sc.draws.popFrame(sc.draws.pushFrame(sc.frameOf(n.ownScope))) } if lit, fixed := n.fixedText(); fixed { return lit } return expand(s, n, sc) } - var b strings.Builder - b.Grow(n.repeat * (n.compiled.grow + len(n.separator))) - for i := 0; i < n.repeat; i++ { - if i > 0 { - b.WriteString(n.separator) - } - b.WriteString(expandAnew(s, n, sc.draws.frames)) - } - return b.String() + return renderRepeat(s, n, sc) default: panic(internalError("uncompiled node %T", n)) } } +// renderRepeat renders each iteration of t as a render of its own, inside the name scopes +// rendering t, where a name bound outside t keeps its pick: a scope sc's category was entered +// past keeps its frame in that read's memo, else in sc's render's. +func renderRepeat(s *generatorState, t *template, sc renderScope) string { + entry := sc.entry + if entry == nil { + entry = &sc.groupDraws().memo + } + var b strings.Builder + b.Grow(t.repeat * (t.compiled.grow + len(t.separator))) + for i := 0; i < t.repeat; i++ { + if i > 0 { + b.WriteString(t.separator) + } + b.WriteString(expandAnew(s, t, sc.draws.frames, sc.base, entry)) + } + return b.String() +} + // expandAnew expands one repeat iteration of t as a render of its own, in no group, inside the -// name scopes of frames. Inlined into render's loop, its draws would move to the heap. +// name scopes of frames from base. Inlined into render's loop, its draws would move to the heap. // //go:noinline -func expandAnew(s *generatorState, t *template, frames *frameStack) string { +func expandAnew(s *generatorState, t *template, frames *frameStack, base int, entry *drawMemo) string { draws := renderDraws{frames: frames} if t.ownScope != nil { - defer draws.popFrame(draws.pushFrame(t.ownScope)) + defer draws.popFrame(draws.pushFrame(newPickFrame(t.ownScope))) } - return expand(s, t, renderScope{draws: &draws}) + return expand(s, t, renderScope{draws: &draws, base: base, entry: entry}) } // pick selects one item. Uniform choices are O(1); weighted choices are an @@ -180,8 +186,9 @@ func readField(s *generatorState, t *template, hold *drawMemo, sc renderScope, a return readName(s, sc, a) case sc.pick != nil && !isRef(a.head): return readUnder(s, t, sc, a) + case a.kind == freshRead && isRef(a.head): + return readValue{text: render(s, t.head(a.head), sc.entering(nil))} case a.kind == freshRead: - sc.pick = nil return readValue{text: render(s, t.head(a.head), sc)} } return readMemo(s, t, hold, nil, sc, a) @@ -203,9 +210,8 @@ func readMemo(s *generatorState, t *template, memo *drawMemo, pins *pinSet, sc r } leaf := drawSteps(s, t.head(a.head), a.steps, pins, memo, a.levels) if pins != nil { - sc = sc.at(leaf, pins) + sc = sc.at(leaf, pins).entering(memo) } - sc.pick, sc.entry = nil, memo r := renderLeaf(s, leaf, sc) if memo.value == nil { memo.value = map[string]readValue{} @@ -228,10 +234,6 @@ func traceRead(trace renderTrace, t *template, sc renderScope, a arm) { // renderLeaf draws and renders what a read lands on: null on a null item, or on a column of one // reference alone whose read drew null. func renderLeaf(s *generatorState, n node, sc renderScope) readValue { - if c, isChoice := n.(*choice); isChoice && c.ownScope != nil { - mark := sc.draws.pushFrame(c.ownScope) - defer sc.draws.popFrame(mark) - } n = resolveChoice(s, n) switch leaf := n.(type) { case *nullItem: diff --git a/todo.md b/todo.md index a3a8ad9..50c92d8 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 71` +`Next ID = 72` | Goal | W | |---|---| @@ -65,6 +65,7 @@ | 12 | 0.1.0 | defect | **Strip the whitespace `data-import/territory.py` copies from its register.** | 1 | 1 | 5 | 7 | 4 | 20.6 | | 5 | 0.1.0 | | **Run the nine-seat comprehension panel after the draw restructure, and file what it names.** | 1 | 3 | 3 | 9 | 3 | 20.4 | | 6 | 0.1.0 | question | **Decide how the comprehension gate treats panel noise: identical code scored 5.7 and 5.4.** | 1 | 1 | 3 | 8 | 3 | 19.1 | +| 71 | 0.1.0 | | **Accept a name as a `calc` operand, so `{calc(n * 2)}` computes from the pick `{n}` prints.** | 3 | 4 | 5 | 7 | 5.3 | 17.2 | | 58 | 0.2.0 | | **Ship prebuilt binaries, a container image, and packages for Homebrew, Scoop and the biggest Linux package managers, so the CLI needs no Go.** | 3 | 7 | 8 | 9 | 1.1, 7 | 29.2 | | 36 | 0.2.0 | | **Report every mistake a load finds in one error, as the decision "A load reports every mistake at once" states.** | 5 | 6 | 7 | 8 | 6 | 19.5 | | 41 | 0.2.0 | | **Key every table by one key column, as the decision "A table selects by one key column, a code, never a free-form name" states.** | 6 | 7 | 7 | 8 | 5.7 | 19.1 | @@ -194,7 +195,7 @@ Shape: T = table, t = template, c = choice. ### 4. Draw every `{…}` afresh, keep a pick only by name, and delete `drawGroup` with the fences that held picks implicitly. -- Needs items 2 and 3. After it, `{/person} ({/person.first})` renders two people, `{w} {uppercase(w)}` renders `b G` where `w` is a choice of letters, and `{/city.name}, {/country[SE].name}` and `{/country[SE].name} / {/country[NO].name}` both load. +- Needs items 2, 3 and 71. After it, `{/person} ({/person.first})` renders two people, `{w} {uppercase(w)}` renders `b G` where `w` is a choice of letters, and `{/city.name}, {/country[SE].name}` and `{/country[SE].name} / {/country[NO].name}` both load. - Deletes the token-order rules (`drawsApart`, `readFold`, `branches`, the pairwise replay), the expansion hold fence, `drawGroup`, the own-family fence and the cell-reference fences, and revises the decisions that point here. The remaining draw checks are names, paths through names, cycles and the repeat cap. - Fixes on the way: `{/sel}|{/sel}` panics out of `Fake` today where two draws land on rows whose cells select different rows of another table; it must render. - Rewrites the README's References, Draw group, Correlated fields, Linked tables and "One draw, one spelling" sections; the Linked tables section says which pick's weights govern a family, since the bound row decides. @@ -251,6 +252,10 @@ Two nine-seat runs on `5cb919e`, same briefs, same model, 2026-09-30: Both runs named the same hardest units, `drawsApart` and `readFold.reads`. Run 1 is the recorded score; run 2 reran the same code to measure noise. A spread of 0.3 is close to the whole 5.7–6.1 range the recorded scores span since 2026-09-20, so `AGENTS.md`'s "no merge lowers the last score" can block or pass a chunk by chance. Until this item is decided, that rule compares against 5.7, from 2026-09-30. +### 71. Accept a name as a `calc` operand, so `{calc(n * 2)}` computes from the pick `{n}` prints. + +`checkCalc` and the value proof read an operand as a sibling field, so `{calc(n * 2)}` is refused with `no field "n"` today. Item 4 stops an operand holding its field for the expansion, after which a name is the one way to show the operand a calc computes from, as the README's Computation example does. + ### 58. Ship prebuilt binaries, a container image, and packages for Homebrew, Scoop and the biggest Linux package managers, so the CLI needs no Go. GoReleaser attaches the binaries to the release the tag workflow publishes, and builds deb and rpm packages, an Alpine APK and an AUR package beside Homebrew and Scoop. A checkout build prints `devel` for `--version`; the binaries carry the stamped tag. v0.1.0 ships first, so it can be tested before it is packaged.