From 1665b37cad062ac36bf2e8363683b4c51887e315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20G=C3=B6ransson?= Date: Thu, 27 Aug 2026 23:45:08 +0200 Subject: [PATCH] Precompute each choice's shared paths so a dotted path stays O(1) --- README.md | 6 +++--- edge_test.go | 6 ++---- fakes.go | 28 ++++++++++++++++++---------- loading_test.go | 7 +++---- node.go | 10 +++++++--- reference.go | 6 ++++-- render.go | 35 ++++++++++++++++++++--------------- 7 files changed, 57 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index aa3ed00..c115648 100644 --- a/README.md +++ b/README.md @@ -364,9 +364,9 @@ no tokens at all, use a bare string node (`"100 Main St"`), emitted verbatim. This yields e.g. `Anna Eriksson`, `Erik Berg`, or rarely `dr Astrid von Flemming`. Any field is reachable by dotted path — `Fake("person.last")` renders just a surname; choices along the path are resolved at random. A path may continue -*through* a choice only where every variant carries the rest of it (so -`currency.symbol` works across all 16 currency variants), which keeps a path from -rendering on one call and failing on the next. +*through* a choice only where every variant carries the rest of it — `misc`'s +`currency` has 16 variants and every one carries `symbol`, so `currency.symbol` +resolves — which keeps a path from rendering on one call and failing on the next. ### Performance diff --git a/edge_test.go b/edge_test.go index 105c341..ce30c31 100644 --- a/edge_test.go +++ b/edge_test.go @@ -89,10 +89,8 @@ func TestDescendIntoLiteralErrors(t *testing.T) { } } -// TestPathThroughChoice pins the rule that makes a dotted path predictable: a -// choice consumes no segment, so a path may step through one only when every -// variant carries the rest of it. Otherwise the same path would render or fail -// depending on which variant the rng picked. +// TestPathThroughChoice pins the rule that keeps a dotted path from rendering on +// one call and failing on the next: every variant must carry the rest of the path. func TestPathThroughChoice(t *testing.T) { dir := writeData(t, map[string]string{ "every": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"]}]`, diff --git a/fakes.go b/fakes.go index cebb08f..abdb618 100644 --- a/fakes.go +++ b/fakes.go @@ -21,6 +21,7 @@ import ( "encoding/binary" "fmt" "math/rand/v2" + "slices" "sort" ) @@ -89,7 +90,7 @@ func (f *Fakes) List() []string { } } sort.Strings(out) - return out + return slices.Compact(out) } // paths lists the dot paths addressable from n, relative to it, where "" is n @@ -119,7 +120,11 @@ func paths(n node) []string { if len(n.items) == 1 { return paths(n.items[0]) } - return append([]string{""}, sharedPaths(n.items)...) + out := []string{""} + for p := range n.shared { + out = append(out, p) + } + return out case literal: return []string{""} } @@ -127,24 +132,27 @@ func paths(n node) []string { } // sharedPaths is the sub-paths every item carries — the only ones a path may step -// through a multi-variant choice to reach. -func sharedPaths(items []node) []string { +// through a multi-variant choice to reach. An item is counted once per path, since +// one item offering a path twice does not make it shared. +func sharedPaths(items []node) map[string]bool { count := map[string]int{} for _, it := range items { + seen := map[string]bool{} for _, p := range paths(it) { - if p != "" { - count[p]++ + if p == "" || seen[p] { + continue } + seen[p] = true + count[p]++ } } - var out []string + shared := map[string]bool{} for p, n := range count { if n == len(items) { - out = append(out, p) + shared[p] = true } } - sort.Strings(out) - return out + return shared } func join(prefix, name string) string { diff --git a/loading_test.go b/loading_test.go index 4646935..992ff04 100644 --- a/loading_test.go +++ b/loading_test.go @@ -15,11 +15,13 @@ func TestList(t *testing.T) { "person": `{"format":"{first} {last}","first":["A"],"last":["B"]}`, "word": `["x", "y"]`, "geo/city": `["Z"]`, + // A bound {..path} reference is a render edge, not an addressable field. + "greeting": `{"format":"hej {..person.first} and {own}","own":["x"]}`, // Only the fields every variant carries are addressable, so "extra" is not. "coin": `[{"format":"{code}","code":["A"],"name":["Aa"]},{"format":"{code}","code":["B"],"name":["Bb"],"extra":["x"]}]`, }) got := newFakes(t, dir, WithSeed(1)).List() - want := []string{"coin", "coin.code", "coin.name", "geo.city", "person", "person.first", "person.last", "word"} + want := []string{"coin", "coin.code", "coin.name", "geo.city", "greeting", "greeting.own", "person", "person.first", "person.last", "word"} if !reflect.DeepEqual(got, want) { t.Fatalf("List() = %v, want %v", got, want) } @@ -141,9 +143,6 @@ func TestMultiPathMergesFolders(t *testing.T) { func TestListedPathsAllRender(t *testing.T) { f := newFakes(t, "data", WithSeed(4)) paths := f.List() - if len(paths) < 50 { - t.Fatalf("List() = %d paths, want the whole shipped tree", len(paths)) - } for _, p := range paths { for i := 0; i < 20; i++ { if _, err := f.Fake(p); err != nil { diff --git a/node.go b/node.go index 7f0f573..d721291 100644 --- a/node.go +++ b/node.go @@ -24,10 +24,13 @@ type group struct{ children map[string]node } func (*group) isNode() {} // choice picks one of its items. cum holds cumulative weights for a weighted -// pick; when nil the choice is uniform and selection is O(1). +// pick; when nil the choice is uniform and selection is O(1). shared is the set of +// relative dot paths every item can address, so descend and List both read the one +// answer to what a path may reach through this choice. type choice struct { - items []node - cum []float64 + items []node + cum []float64 + shared map[string]bool } func (*choice) isNode() {} @@ -88,6 +91,7 @@ func compileChoice(items []any) (node, error) { } c.cum = cum } + c.shared = sharedPaths(c.items) return c, nil } diff --git a/reference.go b/reference.go index 0cdb4bc..179ac40 100644 --- a/reference.go +++ b/reference.go @@ -7,8 +7,10 @@ import ( ) // refPrefix marks a {..path} token: a reference to a node elsewhere in the data -// root rather than a sibling field. The path after it is the same dot path Fake -// takes, resolved across every loaded directory (see linkRefs). +// root rather than a sibling field. The path is resolved across every loaded +// directory (see linkRefs), and is stricter than the one Fake takes: a reference +// binds one node, so it cannot step through a multi-variant choice even where Fake +// and List can. const refPrefix = ".." func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) } diff --git a/render.go b/render.go index dd555ec..29fabd1 100644 --- a/render.go +++ b/render.go @@ -2,6 +2,7 @@ package fakes import ( "fmt" + "slices" "sort" "strings" ) @@ -30,9 +31,9 @@ func (f *Fakes) Fake(path string) (string, error) { // descend walks named fields to the node a path names. It is the one render-side // step that can fail, because the path comes from the caller and may name a field -// that does not exist. A choice consumes no segment, so every variant must carry -// the rest of the path before one is picked at random — a path that resolves at -// all resolves on every call. A nil session validates without picking. +// that does not exist. A choice consumes no segment, so the rest of the path must +// be one every variant carries (the set compile stored) before a variant is picked +// — a path that resolves at all resolves on every call. func descend(s *session, n node, segments []string) (node, error) { if len(segments) == 0 { return n, nil @@ -51,18 +52,9 @@ func descend(s *session, n node, segments []string) (node, error) { } return descend(s, child, segments[1:]) case *choice: - for i, item := range n.items { - _, err := descend(nil, item, segments) - if err == nil { - continue - } - if len(n.items) == 1 { // a single-variant choice is a transparent wrapper - return nil, err - } - return nil, fmt.Errorf("variant %d of a %d-way choice: %w", i+1, len(n.items), err) - } - if s == nil { - return n, nil + want := strings.Join(segments, ".") + if !n.shared[want] { + return nil, unreachableInChoice(n, want) } return descend(s, pick(s, n), segments) case literal: @@ -72,6 +64,19 @@ func descend(s *session, n node, segments []string) (node, error) { } } +// unreachableInChoice names the first variant that cannot address want. It walks +// the variants, which only a failing path does. +func unreachableInChoice(c *choice, want string) error { + if len(c.items) > 1 { + for i, item := range c.items { + if !slices.Contains(paths(item), want) { + return fmt.Errorf("variant %d of a %d-way choice cannot reach %q", i+1, len(c.items), want) + } + } + } + return fmt.Errorf("cannot reach %q", want) +} + // render evaluates a compiled node to a string. compile validates every node up // front, so rendering a compiled tree cannot fail. func render(s *session, n node) string {