Precompute each choice's shared paths so a dotted path stays O(1)
This commit is contained in:
committed by
lilleman-tw
parent
af5b8c9abd
commit
1665b37cad
@@ -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
@@ -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"]}]`,
|
||||
|
||||
@@ -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 != "" {
|
||||
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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
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
@@ -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) }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user