Draw groups: one reference draw per render, a drawGroup option, and a record's fences at load #16

Merged
lilleman merged 14 commits from draw-groups into main 2026-09-16 10:45:45 +02:00
3 changed files with 52 additions and 81 deletions
Showing only changes of commit 0f645d7092 - Show all commits
+9 -4
View File
@@ -522,7 +522,9 @@ token {w} is repeated, and uppercase operand "w" holds "w" to one draw per expan
### Performance
Each file is parsed, validated and weight-indexed once, in `New`. A `Fake` call
Each file is parsed, validated and weight-indexed once, in `New`. Proving the draw
fences adds one pass over the loaded tree, and walks what a render reads only where
data binds a reference, so a set that binds none pays for the pass alone. A `Fake` call
then costs about what its output costs: an unweighted pick is O(1) whatever the
list's length, a weighted one O(log n), and long formats, deep nesting and many
tokens add cost in proportion to the output.
@@ -604,7 +606,9 @@ tokens add cost in proportion to the output.
is two draws, as `{word} {word}` is, while every `{/p.first}` in one render reads
one draw, and a bare `{/p}` beside them is a load error: a bare token
is by contract an independent draw, a path pins its level, and a fresh draw of a
pinned level could show another row.
pinned level could show another row. A builtin's operand holds what it reads for
its expansion, references included, so `{uppercase(/p)} {/p}` is one draw — the
rule every operand follows.
- **Reference sigils follow the filesystem.** `/` is the root, `.` this file's
folder, `..` the folder above — what those spellings already mean to anyone who
has typed a path. A locale's files reach each other without naming the locale,
@@ -695,8 +699,9 @@ tokens add cost in proportion to the output.
columns always read through the render's draws, so making the maps where the set is
declared keeps them on that frame's stack. A `Fake` often reads no reference path at
all, and making them anyway cost about a fifth of the cheapest render, so it makes
them on the first read instead — which the allocation gate prices at two heap
allocations, paid only by a render that shares a draw.
them on the first read instead, at two heap allocations for a render that does share
a draw. The allocation gate over a repeat of a reference path and over a named draw
group prices that, and pins the two measures that keep a draw set off the heap.
- **A category never references itself, and a record's fences run at load.** A category
is one unit: a reference back into it — `{/users.first}` inside `users` — describes a
draw other than the fields beside it, so `New` refuses it and the sibling path stays
+23 -68
View File
@@ -118,7 +118,6 @@ type drawCheck struct {
memo map[readsMemo]bool
}
// readsMemo is one answer reads has given: for a node, and for each place it stops.
type readsMemo struct {
n node
stopAtGroup bool
@@ -155,8 +154,14 @@ func (c *drawCheck) checkRecordDraws(path string, n node) error {
if !ok || !t.record {
return nil
}
// A record's columns are its fields, which its format need not render at all, so the
// reads to weigh are theirs rather than the template's own.
columns := recordColumns(t)
if len(columns) == 0 {
reads := false
for _, name := range columns {
reads = reads || c.readsPath(t.fields[name])
}
if !reads {
return nil
}
if err := checkColumnDraws(t, columns); err != nil {
@@ -182,7 +187,7 @@ func (c *drawCheck) reads(n node, stopAtGroup bool) bool {
}
r := false
for _, e := range renderEdges(n) {
if a, _, isRef := refRead(n, e.label); (isRef && len(a.tail) > 0) || (!repeats(e.to) && !(stopAtGroup && grouped(e.to)) && c.reads(e.to, stopAtGroup)) {
if a, isRef := refRead(n, e.label); (isRef && len(a.tail) > 0) || (!repeats(e.to) && !(stopAtGroup && grouped(e.to)) && c.reads(e.to, stopAtGroup)) {
r = true
break
}
@@ -201,18 +206,17 @@ func repeats(n node) bool {
func grouped(n node) bool {
t, isTemplate := n.(*template)
return isTemplate && t.drawGroup != ""
return isTemplate && t.drawGroupKey != ""
}
// refRead is the reference an edge of n reads, and the node it is bound to; false when the edge
// reads none.
func refRead(n node, label string) (arm, node, bool) {
// refRead is the reference an edge of n reads; false when the edge reads none.
func refRead(n node, label string) (arm, bool) {
t, isTemplate := n.(*template)
if !isTemplate {
return arm{}, nil, false
return arm{}, false
}
a := splitArm(label, t.refs)
return a, t.fields[a.key], isRef(a.key)
return a, isRef(a.key)
}
// checkColumnDraws fences a record's columns as one render.
@@ -224,22 +228,17 @@ func checkColumnDraws(t *template, columns []string) error {
return w.check()
}
// drawWalk gathers what one render reads by reference and what it draws afresh, each by draw group,
// for check to compare.
// drawWalk gathers the references one render reads, by draw group, for check to compare.
type drawWalk struct {
reads []pathRead
read map[drawKey]bool
fresh []freshDraw
seen map[drawVisit]bool
}
// drawAt is where a walk stands: the draw group it draws in, how the render's root reached it, the
// reference it last crossed, and whether it renders inside a reference path's draw.
// drawAt is where a walk stands: the draw group it draws in, and how the render's root reached it.
type drawAt struct {
group string
route drawRoute
via string
held bool
}
// drawRoute is how a render reaches a draw: as its author spells it, and the root edge's label.
@@ -249,23 +248,16 @@ type drawRoute struct{ spelling, label string }
type pathRead struct {
at drawAt
a arm
target node
}
type freshDraw struct {
at drawAt
n node
}
type drawVisit struct {
n node
group string
held bool
}
type drawKey struct {
group string
key any
path string
}
func newDrawWalk() *drawWalk {
@@ -275,14 +267,11 @@ func newDrawWalk() *drawWalk {
// walk follows what rendering n renders. A repeat renders over draws of its own, so the walk stops
// there.
func (w *drawWalk) walk(n node, at drawAt) {
v := drawVisit{n, at.group, at.held}
v := drawVisit{n, at.group}
if w.seen[v] {
return
}
w.seen[v] = true
if !at.held {
w.fresh = append(w.fresh, freshDraw{at, n})
}
if repeats(n) {
return
}
@@ -295,21 +284,18 @@ func (w *drawWalk) walk(n node, at drawAt) {
}
func (w *drawWalk) edge(from node, e renderEdge, at drawAt) {
a, target, reads := refRead(from, e.label)
if !reads {
w.walk(e.to, at)
return
}
if a, reads := refRead(from, e.label); reads {
if k := (drawKey{at.group, a.path}); !w.read[k] {
w.read[k] = true
w.reads = append(w.reads, pathRead{at, a, target})
w.reads = append(w.reads, pathRead{at, a})
}
}
at.via, at.held = a.name, len(a.tail) > 0
w.walk(e.to, at)
}
// check refuses what one draw per reference path cannot answer for, reads compared in path order so
// which pair is reported does not vary.
// check refuses what one draw per reference path cannot answer for: a read of a level beside a path
// another read takes into it. Reads are compared in path order, so which pair is reported does not
// vary.
func (w *drawWalk) check() error {
sort.SliceStable(w.reads, func(i, j int) bool {
if w.reads[i].at.group != w.reads[j].at.group {
@@ -317,14 +303,6 @@ func (w *drawWalk) check() error {
}
return w.reads[i].a.path < w.reads[j].a.path
})
if err := w.checkOverlaps(); err != nil {
return err
}
return w.checkFreshDraws()
}
// checkOverlaps refuses a read of a level beside a path another read takes into it.
func (w *drawWalk) checkOverlaps() error {
for i, level := range w.reads {
for _, into := range w.reads[i+1:] {
if into.at.group == level.at.group && strings.HasPrefix(into.a.path, level.a.path+".") {
@@ -335,29 +313,6 @@ func (w *drawWalk) checkOverlaps() error {
return nil
}
// checkFreshDraws refuses a node drawn afresh where a reference path's draw holds it.
func (w *drawWalk) checkFreshDraws() error {
pins := map[drawKey]pathRead{}
for _, r := range w.reads {
if len(r.a.tail) == 0 {
continue
}
held := map[node]bool{}
coverPath(r.target, r.a.tail, held)
for n := range held {
if _, pinned := pins[drawKey{r.at.group, n}]; !pinned {
pins[drawKey{r.at.group, n}] = r
}
}
}
for _, f := range w.fresh {
if r, pinned := pins[drawKey{f.at.group, f.n}]; pinned {
return overlapError(f.at.route, f.at.via, r)
}
}
return nil
}
func overlapError(route drawRoute, ref string, into pathRead) error {
return fmt.Errorf("%s renders a level that %s reads a path into; name the fields you want instead, or draw them apart with a drawGroup", route.spelled(ref), into.at.route.spelled(into.a.name))
}
+12 -1
View File
@@ -197,15 +197,26 @@ func checkRenders(s nodeScope) error {
return err
}
fence := &drawCheck{}
if err := s(fence.checkDrawGroup); err != nil {
// One pass refuses a draw group that splits nothing and notes whether the scope binds a
// reference at all: weighing what a render's draws hold walks every node it renders, and
// with nothing bound there is no draw to share, so the shipped set pays for neither walk.
refs := false
if err := s(func(path string, n node) error {
if t, ok := n.(*template); ok && len(t.refs) > 0 {
refs = true
}
return fence.checkDrawGroup(path, n)
}); err != nil {
return err
}
if refs {
if err := s(fence.checkDraws); err != nil {
return err
}
if err := s(fence.checkRecordDraws); err != nil {
return err
}
}
return s((&valueProof{}).checkDatatype)
}