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`.
|
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
|
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
|
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
|
*through* a choice only where every variant carries the rest of it — `misc`'s
|
||||||
`currency.symbol` works across all 16 currency variants), which keeps a path from
|
`currency` has 16 variants and every one carries `symbol`, so `currency.symbol`
|
||||||
rendering on one call and failing on the next.
|
resolves — which keeps a path from rendering on one call and failing on the next.
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -89,10 +89,8 @@ func TestDescendIntoLiteralErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPathThroughChoice pins the rule that makes a dotted path predictable: a
|
// TestPathThroughChoice pins the rule that keeps a dotted path from rendering on
|
||||||
// choice consumes no segment, so a path may step through one only when every
|
// one call and failing on the next: every variant must carry the rest of the path.
|
||||||
// variant carries the rest of it. Otherwise the same path would render or fail
|
|
||||||
// depending on which variant the rng picked.
|
|
||||||
func TestPathThroughChoice(t *testing.T) {
|
func TestPathThroughChoice(t *testing.T) {
|
||||||
dir := writeData(t, map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"every": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"]}]`,
|
"every": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"]}]`,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ func (f *Fakes) List() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out
|
return slices.Compact(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// paths lists the dot paths addressable from n, relative to it, where "" is n
|
// 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 {
|
if len(n.items) == 1 {
|
||||||
return paths(n.items[0])
|
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:
|
case literal:
|
||||||
return []string{""}
|
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
|
// sharedPaths is the sub-paths every item carries — the only ones a path may step
|
||||||
// through a multi-variant choice to reach.
|
// through a multi-variant choice to reach. An item is counted once per path, since
|
||||||
func sharedPaths(items []node) []string {
|
// one item offering a path twice does not make it shared.
|
||||||
|
func sharedPaths(items []node) map[string]bool {
|
||||||
count := map[string]int{}
|
count := map[string]int{}
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
|
seen := map[string]bool{}
|
||||||
for _, p := range paths(it) {
|
for _, p := range paths(it) {
|
||||||
if p != "" {
|
if p == "" || seen[p] {
|
||||||
count[p]++
|
continue
|
||||||
}
|
}
|
||||||
|
seen[p] = true
|
||||||
|
count[p]++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var out []string
|
shared := map[string]bool{}
|
||||||
for p, n := range count {
|
for p, n := range count {
|
||||||
if n == len(items) {
|
if n == len(items) {
|
||||||
out = append(out, p)
|
shared[p] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(out)
|
return shared
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func join(prefix, name string) string {
|
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"]}`,
|
"person": `{"format":"{first} {last}","first":["A"],"last":["B"]}`,
|
||||||
"word": `["x", "y"]`,
|
"word": `["x", "y"]`,
|
||||||
"geo/city": `["Z"]`,
|
"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.
|
// 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"]}]`,
|
"coin": `[{"format":"{code}","code":["A"],"name":["Aa"]},{"format":"{code}","code":["B"],"name":["Bb"],"extra":["x"]}]`,
|
||||||
})
|
})
|
||||||
got := newFakes(t, dir, WithSeed(1)).List()
|
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) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("List() = %v, want %v", got, want)
|
t.Fatalf("List() = %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
@@ -141,9 +143,6 @@ func TestMultiPathMergesFolders(t *testing.T) {
|
|||||||
func TestListedPathsAllRender(t *testing.T) {
|
func TestListedPathsAllRender(t *testing.T) {
|
||||||
f := newFakes(t, "data", WithSeed(4))
|
f := newFakes(t, "data", WithSeed(4))
|
||||||
paths := f.List()
|
paths := f.List()
|
||||||
if len(paths) < 50 {
|
|
||||||
t.Fatalf("List() = %d paths, want the whole shipped tree", len(paths))
|
|
||||||
}
|
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
for i := 0; i < 20; i++ {
|
for i := 0; i < 20; i++ {
|
||||||
if _, err := f.Fake(p); err != nil {
|
if _, err := f.Fake(p); err != nil {
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ type group struct{ children map[string]node }
|
|||||||
func (*group) isNode() {}
|
func (*group) isNode() {}
|
||||||
|
|
||||||
// choice picks one of its items. cum holds cumulative weights for a weighted
|
// 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 {
|
type choice struct {
|
||||||
items []node
|
items []node
|
||||||
cum []float64
|
cum []float64
|
||||||
|
shared map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*choice) isNode() {}
|
func (*choice) isNode() {}
|
||||||
@@ -88,6 +91,7 @@ func compileChoice(items []any) (node, error) {
|
|||||||
}
|
}
|
||||||
c.cum = cum
|
c.cum = cum
|
||||||
}
|
}
|
||||||
|
c.shared = sharedPaths(c.items)
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -7,8 +7,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// refPrefix marks a {..path} token: a reference to a node elsewhere in the data
|
// 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
|
// root rather than a sibling field. The path is resolved across every loaded
|
||||||
// takes, resolved across every loaded directory (see linkRefs).
|
// 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 = ".."
|
const refPrefix = ".."
|
||||||
|
|
||||||
func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) }
|
func isRef(name string) bool { return strings.HasPrefix(name, refPrefix) }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package fakes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"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
|
// 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
|
// 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
|
// that does not exist. A choice consumes no segment, so the rest of the path must
|
||||||
// the rest of the path before one is picked at random — a path that resolves at
|
// be one every variant carries (the set compile stored) before a variant is picked
|
||||||
// all resolves on every call. A nil session validates without picking.
|
// — a path that resolves at all resolves on every call.
|
||||||
func descend(s *session, n node, segments []string) (node, error) {
|
func descend(s *session, n node, segments []string) (node, error) {
|
||||||
if len(segments) == 0 {
|
if len(segments) == 0 {
|
||||||
return n, nil
|
return n, nil
|
||||||
@@ -51,18 +52,9 @@ func descend(s *session, n node, segments []string) (node, error) {
|
|||||||
}
|
}
|
||||||
return descend(s, child, segments[1:])
|
return descend(s, child, segments[1:])
|
||||||
case *choice:
|
case *choice:
|
||||||
for i, item := range n.items {
|
want := strings.Join(segments, ".")
|
||||||
_, err := descend(nil, item, segments)
|
if !n.shared[want] {
|
||||||
if err == nil {
|
return nil, unreachableInChoice(n, want)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return descend(s, pick(s, n), segments)
|
return descend(s, pick(s, n), segments)
|
||||||
case literal:
|
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
|
// render evaluates a compiled node to a string. compile validates every node up
|
||||||
// front, so rendering a compiled tree cannot fail.
|
// front, so rendering a compiled tree cannot fail.
|
||||||
func render(s *session, n node) string {
|
func render(s *session, n node) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user