Precompute each choice's shared paths so a dotted path stays O(1)

This commit is contained in:
Mikael Göransson
2026-08-27 23:45:08 +02:00
committed by lilleman-tw
parent af5b8c9abd
commit 1665b37cad
7 changed files with 57 additions and 41 deletions
+3 -3
View File
@@ -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
+2 -4
View File
@@ -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"]}]`,
+18 -10
View File
@@ -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 {
+3 -4
View File
@@ -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 {
+7 -3
View File
@@ -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
}
+4 -2
View File
@@ -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) }
+20 -15
View File
@@ -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 {