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

This commit is contained in:
2026-10-03 17:38:26 +02:00
committed by Lillem4n
parent c3b462ec0a
commit 3bb8628e39
9 changed files with 51 additions and 61 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Rules
- Go runs only through `docker compose run --rm <test|vet|fmt|build|cyclo>`; the merge gate is `docker build .` plus CI's changelog check.
- Go runs only through `docker compose run --rm <test|vet|fmt|build|cyclo|generate>`; 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.
+2 -2
View File
@@ -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
+7 -4
View File
@@ -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
+1 -1
View File
@@ -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))
}
+1 -1
View File
@@ -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)
+1 -6
View File
@@ -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
}
+19 -13
View File
@@ -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]) {
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], "."))
}
child, ok := g.children[segments[i]]
n, ok := g.children[segments[i]]
if !ok {
return nil, nil, nil, fmt.Errorf("no entry %q", segments[i])
}
n = child
return segments[:i+1], n, segments[i+1:], nil
}
if _, ok := n.(*folder); ok {
return nil, nil, nil, fmt.Errorf("names a folder, not a value")
// 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
}
return segments[:i], n, segments[i:], nil
g = sub
}
return g, len(segs)
}
// refTokens returns the reference names a format reads, as tokens or as operands, or binds.
+11 -25
View File
@@ -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
}
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})
}
+2 -2
View File
@@ -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)
}