From 3bb8628e3930444fcf3c9616b83df0388a9bb521 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 3 Oct 2026 17:38:26 +0200 Subject: [PATCH] Answer architecture round 2: one folder walk and one sorted-keys helper, REGENERATE for the index, and the held decisions naming the items that end them --- AGENTS.md | 2 +- README.md | 4 ++-- docs/decisions.md | 11 +++++++---- fejkdata.go | 2 +- graph.go | 2 +- node.go | 7 +------ reference.go | 42 ++++++++++++++++++++++++------------------ shipped.go | 38 ++++++++++++-------------------------- shipped_test.go | 4 ++-- 9 files changed, 51 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 60731c5..ecae776 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Rules -- Go runs only through `docker compose run --rm `; the merge gate is `docker build .` plus CI's changelog check. +- Go runs only through `docker compose run --rm `; the merge gate is `docker build .` plus CI's changelog check. - Tests first, in their own commit; the implementation follows in the next. A re-pin of seeded output or of `testdata/shipped_shape.txt` is its own commit. - A change under `data/` or to the shape pin adds its `CHANGELOG.md` entry under `Unreleased` in the same PR, and so does a change to a flag, an exit code, an exported name, a fence, a builtin or the lowest Go; what is major is the README's Versioning table. - One-line commit messages: no ticket prefix, no repo name, no authorship trailers. diff --git a/README.md b/README.md index 7992aa8..86e1fa7 100644 --- a/README.md +++ b/README.md @@ -816,8 +816,8 @@ The target is about a microsecond per value (goal 12). Each file is parsed, validated and weight-indexed once, bar a table's name and parent columns, which are mapped on the first `Fake` that selects by name or descends through it. With the shipped set alone, a category loads on the first call reaching it, with -every category it reads, and `List` reads an index; beside a `--data-path`, every -category loads in `New`. Proving the draw fences adds a pass over the loaded tree per +every category it reads and every table linked to it, and `List` reads an index for what +has not loaded yet; beside a `--data-path`, every category loads in `New`. Proving the draw fences adds a pass over the loaded tree per fence, and walks what a render reads only where data holds a reference, so a set that holds none pays for the passes 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 diff --git a/docs/decisions.md b/docs/decisions.md index 56e5613..bf4669b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -171,7 +171,7 @@ release instead. Before `v1.0.0` a minor carries what a major would. Any edit to a category shifts its stream and everything drawn after it, so a promise across versions would freeze every shipped list; a fixture is re-pinned on a bump, as -this repo's own are. +this repo's own are. Goal 9 reads wider than this until `todo.md` item 85 rewords it. ## An error is a contract by what it names, not its bytes @@ -259,7 +259,8 @@ assumes a 64-bit int; on a 32-bit target it could overflow and panic. load error; the fold stops where an operand varies, so `a/(b*c)` with `b` fixed at `0` and `c` varying loads and prints `Inf` every draw — catching it needs zero-absorbing algebra for a shape nobody writes. A [typed column](../README.md#datatype) bounds its operands -instead and refuses a divisor it cannot keep from zero. +instead and refuses a divisor it cannot keep from zero. `Inf` in a string column works +against goals 4 and 6; `todo.md` item 81 asks the maintainer whether to refuse it. ## In data, a default written out and a constant spelled as a sample are load errors @@ -466,7 +467,8 @@ The benchmark suite (the README's Development) reports time for a human, not as objects would cost it a second; a TSV is one allocation whose cells are substrings, and the JSON says only how a row is composed. The TSV sits beside its category file, named by `rows`, so a data directory stays a directory of categories, and one nothing names is -a load error rather than a file silently ignored. +a load error rather than a file silently ignored. A TSV works against goal 8's "Data is +JSON files" until `todo.md` item 84 rewords the goal or ends this decision. ## A selector is bracketed, and a dot inside it is literal @@ -909,7 +911,8 @@ mode field, leaked the check's leaves and errors with the draw's pins, arm and m since Go tracks a struct's fields as one, and a repeat of a reference path rose from 66 to 106 allocations. `drawSteps` takes its state as parameters and a step holds no node, so no benchmark gained an allocation. Valid while Go's escape analysis tracks -a struct's fields as one. +a struct's fields as one. The two loops have drifted, against DRY, until `todo.md` item 80 +walks both through one per-step function. ## A name lives in the category binding it, or in the repeat binding it, and is drawn on its first read diff --git a/fejkdata.go b/fejkdata.go index d2e8887..cfccc77 100644 --- a/fejkdata.go +++ b/fejkdata.go @@ -144,7 +144,7 @@ func paths(n node) []string { out = append(out, join(name, p)) } } - for _, name := range sortedEntries(n.unloaded) { + for _, name := range sortedNames(n.unloaded) { for _, p := range n.unloaded[name].paths { out = append(out, join(name, p)) } diff --git a/graph.go b/graph.go index 3d40630..a9cf92c 100644 --- a/graph.go +++ b/graph.go @@ -76,7 +76,7 @@ func namedNodes(m map[string]node) []namedNode { return out } -func sortedNames(m map[string]node) []string { +func sortedNames[V any](m map[string]V) []string { names := make([]string, 0, len(m)) for name := range m { names = append(names, name) diff --git a/node.go b/node.go index 822ab3a..01fbf58 100644 --- a/node.go +++ b/node.go @@ -406,12 +406,7 @@ func readOptions(m map[string]any, pos position) (templateOptions, error) { // compileFields compiles every non-option key of a template object. func compileFields(m map[string]any, pos position) (map[string]node, error) { fields := make(map[string]node, len(m)) - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { + for _, k := range sortedNames(m) { if isOption(k) { continue } diff --git a/reference.go b/reference.go index c6c3af9..fc717c6 100644 --- a/reference.go +++ b/reference.go @@ -214,26 +214,32 @@ func eachTemplate(sites []categorySite, fn func(s categorySite, path string, t * // resolveCategory walks a dotted path through the folders to the category it // names, returning that category's segments, the node, and the tail left to read into it. func resolveCategory(root map[string]node, segments []string) (categorySegs []string, target node, tail []string, err error) { - var n node = &folder{children: root} - i := 0 - for ; i < len(segments); i++ { - g, ok := n.(*folder) - if !ok { - break - } - if isSelector(segments[i]) { - return nil, nil, nil, fmt.Errorf("%s is a folder, not a table, so it has no row to select", strings.Join(segments[:i], ".")) - } - child, ok := g.children[segments[i]] - if !ok { - return nil, nil, nil, fmt.Errorf("no entry %q", segments[i]) - } - n = child - } - if _, ok := n.(*folder); ok { + g, i := folderAt(&folder{children: root}, segments) + switch { + case i == len(segments): return nil, nil, nil, fmt.Errorf("names a folder, not a value") + case isSelector(segments[i]): + return nil, nil, nil, fmt.Errorf("%s is a folder, not a table, so it has no row to select", strings.Join(segments[:i], ".")) } - return segments[:i], n, segments[i:], nil + n, ok := g.children[segments[i]] + if !ok { + return nil, nil, nil, fmt.Errorf("no entry %q", segments[i]) + } + return segments[:i+1], n, segments[i+1:], nil +} + +// folderAt is the deepest folder a path from root walks into, and the index of the first +// segment naming no folder in it, len(segs) where every segment does. +func folderAt(root *folder, segs []string) (*folder, int) { + g := root + for i, seg := range segs { + sub, isFolder := g.children[seg].(*folder) + if !isFolder { + return g, i + } + g = sub + } + return g, len(segs) } // refTokens returns the reference names a format reads, as tokens or as operands, or binds. diff --git a/shipped.go b/shipped.go index 5e6336d..0783f4c 100644 --- a/shipped.go +++ b/shipped.go @@ -9,7 +9,7 @@ import ( "strings" ) -//go:generate env REPIN=1 go test -run ^TestShippedIndexIsCurrent$ . +//go:generate env REGENERATE=1 go test -run ^TestShippedIndexIsCurrent$ . //go:embed data var shippedFS embed.FS @@ -17,7 +17,8 @@ var shippedFS embed.FS var shippedSource = dataSource{fsys: shippedFS, baseDir: "data"} // shippedEntry is a shipped category's entry in shippedindex.go: the table beside it that -// it links to, "" for none, and the paths List advertises below it. +// it links to, "" for none, and the paths List advertises below it. After changing it, empty +// shippedIndex's literal by hand so the package builds, then run generate. type shippedEntry struct { parent string paths []string @@ -45,15 +46,6 @@ func unloadedTree() folder { return root } -func sortedEntries(m map[string]shippedEntry) []string { - names := make([]string, 0, len(m)) - for name := range m { - names = append(names, name) - } - sort.Strings(names) - return names -} - // unloadedCategory is a shipped category not loaded yet: the folder holding it, that // folder's path, and its name there. type unloadedCategory struct { @@ -64,25 +56,19 @@ type unloadedCategory struct { // unloadedAt is the unloaded shipped category a path from root names, or descends into. func unloadedAt(root *folder, segs []string) (unloadedCategory, bool) { - g := root - for i, seg := range segs { - switch n := g.children[seg].(type) { - case *folder: - g = n - continue - case nil: - if _, unloaded := g.unloaded[seg]; unloaded { - return unloadedCategory{dir: segs[:i:i], in: g, name: seg}, true - } - } + g, i := folderAt(root, segs) + if i == len(segs) { return unloadedCategory{}, false } - return unloadedCategory{}, false + if _, unloaded := g.unloaded[segs[i]]; !unloaded { + return unloadedCategory{}, false + } + return unloadedCategory{dir: segs[:i:i], in: g, name: segs[i]}, true } // loadShippedAt loads the shipped category a caller's path names or descends into, unless -// it is loaded already. An entry point walking root calls it first: a walk that loaded -// would load mid-render. +// it is loaded already. An entry point calls it before walking root: a walk is a query and +// loads nothing. func (f *Generator) loadShippedAt(segs []string) { if u, unloaded := unloadedAt(&f.root, segs); unloaded { loadShipped(&f.root, []unloadedCategory{u}) @@ -163,7 +149,7 @@ func (u unloadedCategory) linkedTables(e shippedEntry) []unloadedCategory { if e.parent != "" { out = append(out, unloadedCategory{dir: u.dir, in: u.in, name: e.parent}) } - for _, name := range sortedEntries(u.in.unloaded) { + for _, name := range sortedNames(u.in.unloaded) { if u.in.unloaded[name].parent == u.name { out = append(out, unloadedCategory{dir: u.dir, in: u.in, name: name}) } diff --git a/shipped_test.go b/shipped_test.go index 3c8819c..61c9f45 100644 --- a/shipped_test.go +++ b/shipped_test.go @@ -39,13 +39,13 @@ func loadedCategories(f *Generator) []string { return out } -// REPIN=1 rewrites the index. +// REGENERATE=1, which go generate sets, rewrites the index. func TestShippedIndexIsCurrent(t *testing.T) { got, err := shippedIndexSource(newShippedWhole(t)) if err != nil { t.Fatal(err) } - if os.Getenv("REPIN") == "1" { + if os.Getenv("REGENERATE") == "1" { if err := os.WriteFile(shippedIndexFile, got, 0o644); err != nil { t.Fatal(err) }