From 9b4334c52722f5aa232da8554e411f10634bc344 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:45:17 +0000 Subject: [PATCH] Add evaluator fast paths: cached field plans, reflect-space index chains, typed binary ops Four per-Run optimizations, validated against the existing bench suite (baseline -> after, Linux/amd64, go1.26): - Cache Go-name field plans for untagged struct lookups instead of calling reflect's FieldByName per access (it re-walks the field set every time and dominated struct-env CPU profiles at ~30%). envStruct 145ns -> 84ns, structTagsWide/go_names 559ns -> 202ns, largeStructAccess 310ns -> 220ns. - Walk ident-rooted index/selector chains (a[0][1], a.b[i].c) in reflect space, materializing only the leaf. The general path boxes every intermediate through any, copying aggregate data: indexing a struct field holding a 10 MiB array memcpy'd the array per access. largeNestedArrayAccess 1.52ms / 10.5MB per op -> 167ns / 65B per op. - Dispatch exact int64/int/float64/string/bool operand pairs in applyBinary through typed helpers, skipping the reflect-based coercion funnel (asString called reflect.ValueOf on every non-string operand). Integer equality keeps looseEqual's float64 comparison so results are bit-for-bit identical. filter 90us -> 70us, envMap 101ns -> 68ns. - Stream higher-order forms through an itemSeq view instead of materializing typed slices into []any before iteration, and hoist the error reflect.Type to a package var in finishCall/prepareFunc. filter 40,840B -> 24,456B per op, filterMap 4,752B -> 2,960B per op. Untagged field lookup now reuses the same VisibleFields-based plan as tagged lookup: ambiguous promoted names resolve to "not found" (same as FieldByName), and traversing an embedded nil pointer returns an ErrEvaluate instead of panicking. New index-chain tests pin the fast path to the general path's values and error messages. https://claude.ai/code/session_018MkLtLrR1u8FEo1LgxyhrN --- higher_order.go | 43 +++--- index_chain_test.go | 119 +++++++++++++++++ prepared.go | 3 +- program.go | 318 ++++++++++++++++++++++++++++++++++++++++++++ reflect.go | 13 +- struct_tags.go | 31 ++++- 6 files changed, 503 insertions(+), 24 deletions(-) create mode 100644 index_chain_test.go diff --git a/higher_order.go b/higher_order.go index 085abfb..d77d19a 100644 --- a/higher_order.go +++ b/higher_order.go @@ -101,32 +101,44 @@ func init() { higherOrderForms[tryIndexFormName] = formTryIndex } -// iterItems evaluates collExpr and converts the result to a []any for +// itemSeq is an indexable view over a list-shaped collection. Typed +// slices stay behind a reflect.Value and box elements lazily in at(), +// so forms never materialize an intermediate []any just to iterate. +type itemSeq struct { + items []any // set when the collection already is a []any + rv reflect.Value // used otherwise (typed slices and arrays) + n int +} + +func (s itemSeq) at(i int) any { + if s.items != nil { + return s.items[i] + } + return s.rv.Index(i).Interface() +} + +// iterItems evaluates collExpr and wraps the result in an itemSeq for // predicate iteration. nil is treated as an empty list so // `map(nil, it)` / `filter(nil, it > 0)` return empty without error. // Maps and other non-list shapes return a user-friendly error naming // the form, so users do not have to guess which argument was wrong. -func (p *Program) iterItems(ctx context.Context, name string, collExpr ast.Expr, env any, depth int) ([]any, error) { +func (p *Program) iterItems(ctx context.Context, name string, collExpr ast.Expr, env any, depth int) (itemSeq, error) { coll, err := p.eval(ctx, collExpr, env, depth) if err != nil { - return nil, err + return itemSeq{}, err } if coll == nil { - return nil, nil + return itemSeq{}, nil } if s, ok := coll.([]any); ok { - return s, nil + return itemSeq{items: s, n: len(s)}, nil } rv := reflect.ValueOf(coll) switch rv.Kind() { case reflect.Slice, reflect.Array: - out := make([]any, rv.Len()) - for i := 0; i < rv.Len(); i++ { - out[i] = rv.Index(i).Interface() - } - return out, nil + return itemSeq{rv: rv, n: rv.Len()}, nil } - return nil, fmt.Errorf("%w: %s expects a list as its first argument, got %T", + return itemSeq{}, fmt.Errorf("%w: %s expects a list as its first argument, got %T", ErrEvaluate, name, coll) } @@ -152,14 +164,15 @@ func checkFormArity(name string, got int) error { func (p *Program) forEach( ctx context.Context, name string, - items []any, + items itemSeq, predicate ast.Expr, env any, depth int, body func(item any, result any) (stop bool, err error), ) error { scope := &itEnv{parent: env} - for i, item := range items { + for i := 0; i < items.n; i++ { + item := items.at(i) scope.it = item scope.index = int64(i) v, err := p.eval(ctx, predicate, scope, depth) @@ -361,7 +374,7 @@ func formMap(p *Program, ctx context.Context, n *ast.CallExpr, env any, depth in if err != nil { return nil, err } - out := make([]any, 0, len(items)) + out := make([]any, 0, items.n) err = p.forEach(ctx, "map", items, n.Args[1], env, depth, func(_ any, v any) (bool, error) { out = append(out, v) return false, nil @@ -380,7 +393,7 @@ func formFilter(p *Program, ctx context.Context, n *ast.CallExpr, env any, depth if err != nil { return nil, err } - out := make([]any, 0, len(items)) + out := make([]any, 0, items.n) err = p.forEach(ctx, "filter", items, n.Args[1], env, depth, func(item any, v any) (bool, error) { if isTruthy(v) { out = append(out, item) diff --git a/index_chain_test.go b/index_chain_test.go new file mode 100644 index 0000000..2bc1566 --- /dev/null +++ b/index_chain_test.go @@ -0,0 +1,119 @@ +package expr + +import ( + "strings" + "testing" + + "github.com/deepnoodle-ai/expr/internal/require" +) + +// These tests pin the reflect-space index-chain fast path +// (evalIndexChainRV) to the general path's behavior. Struct envs route +// ident-rooted chains like Grid[1][2] or Inner.Vals[0] through the fast +// path, so values and error messages must match what indexValue and +// selectField produce for the same shapes. + +type indexChainEnv struct { + Grid [2][3]int + Items []string + Meta map[string]int + Mixed map[string]any + Inner struct { + Vals []int + } + Text string +} + +func newIndexChainEnv() indexChainEnv { + env := indexChainEnv{ + Grid: [2][3]int{{1, 2, 3}, {4, 5, 6}}, + Items: []string{"a", "b", "c"}, + Meta: map[string]int{"x": 7}, + Mixed: map[string]any{"list": []any{int64(10), int64(20)}}, + Text: "héllo", + } + env.Inner.Vals = []int{42, 43} + return env +} + +func TestIndexChainStructEnv(t *testing.T) { + env := newIndexChainEnv() + cases := []struct { + src string + want any + }{ + {src: `Grid[1][2]`, want: 6}, + {src: `Grid[0][0]`, want: 1}, + {src: `Items[1]`, want: "b"}, + {src: `Meta["x"]`, want: 7}, + {src: `Mixed["list"][1]`, want: int64(20)}, + {src: `Inner.Vals[0]`, want: 42}, + {src: `Text[1]`, want: "é"}, + {src: `Items[Grid[0][0]]`, want: "b"}, + } + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + got, err := evalExpr(t.Context(), tc.src, env) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestIndexChainStructEnvErrors(t *testing.T) { + env := newIndexChainEnv() + cases := []struct { + src string + wantErr string + }{ + {src: `Items[5]`, wantErr: "index 5 out of range [0, 3)"}, + {src: `Grid[0][9]`, wantErr: "index 9 out of range [0, 3)"}, + {src: `Items[-1]`, wantErr: "index -1 out of range"}, + // Non-map[string]any maps format the missing key with %v, + // matching indexValue's general map branch. + {src: `Meta["nope"]`, wantErr: `key nope not found`}, + {src: `Mixed[0]`, wantErr: "map index must be string, got int64"}, + {src: `Grid[0]["x"]`, wantErr: "index must be integer"}, + {src: `Meta["x"][0]`, wantErr: "cannot index int"}, + {src: `Inner.Nope[0]`, wantErr: `field "Nope" not found`}, + {src: `Text[99]`, wantErr: "index 99 out of range [0, 5)"}, + } + for _, tc := range cases { + t.Run(tc.src, func(t *testing.T) { + _, err := evalExpr(t.Context(), tc.src, env) + require.Error(t, err) + require.ErrorIs(t, err, ErrEvaluate) + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErr) + } + }) + } +} + +// The same expressions evaluated against a map env take the general +// path (single hops) or enter the chain with a boxed root (multi hop); +// results must agree with the struct-env fast path. +func TestIndexChainMapEnvParity(t *testing.T) { + structEnv := newIndexChainEnv() + mapEnv := map[string]any{ + "Grid": structEnv.Grid, + "Items": structEnv.Items, + "Meta": structEnv.Meta, + "Mixed": structEnv.Mixed, + "Inner": structEnv.Inner, + "Text": structEnv.Text, + } + srcs := []string{ + `Grid[1][2]`, `Items[1]`, `Meta["x"]`, `Mixed["list"][1]`, + `Inner.Vals[0]`, `Text[1]`, `Items[Grid[0][0]]`, + } + for _, src := range srcs { + t.Run(src, func(t *testing.T) { + fromStruct, err := evalExpr(t.Context(), src, structEnv) + require.NoError(t, err) + fromMap, err := evalExpr(t.Context(), src, mapEnv) + require.NoError(t, err) + require.Equal(t, fromStruct, fromMap) + }) + } +} diff --git a/prepared.go b/prepared.go index 1148f3d..4077016 100644 --- a/prepared.go +++ b/prepared.go @@ -80,8 +80,7 @@ func prepareFunc(name string, fn any) (*preparedFunc, error) { p.paramTypes[i] = ft.In(p.paramOff + i) } if p.numOut == 2 { - errType := reflect.TypeOf((*error)(nil)).Elem() - if !ft.Out(1).Implements(errType) { + if !ft.Out(1).Implements(errValType) { return nil, fmt.Errorf("function %q: second return must be error, got %v", name, ft.Out(1)) } p.hasErrRet = true diff --git a/program.go b/program.go index f3c8229..f750be2 100644 --- a/program.go +++ b/program.go @@ -570,6 +570,71 @@ func (p *Program) evalBinary(ctx context.Context, n *ast.BinaryExpr, env any, de } func applyBinary(op token.Token, lhs, rhs any) (any, error) { + // Fast path: exact int64/int/float64/string/bool operand pairs cover + // the vast majority of expressions and skip the reflect-based + // coercion helpers below. Unsupported operators fall through so the + // general path produces its usual error (with the original types). + switch l := lhs.(type) { + case int64: + switch r := rhs.(type) { + case int64: + if v, err, ok := applyInt64Binary(op, l, r); ok { + return v, err + } + case int: + if v, err, ok := applyInt64Binary(op, l, int64(r)); ok { + return v, err + } + case float64: + if v, err, ok := applyFloat64Binary(op, float64(l), r); ok { + return v, err + } + } + case int: + switch r := rhs.(type) { + case int64: + if v, err, ok := applyInt64Binary(op, int64(l), r); ok { + return v, err + } + case int: + if v, err, ok := applyInt64Binary(op, int64(l), int64(r)); ok { + return v, err + } + case float64: + if v, err, ok := applyFloat64Binary(op, float64(l), r); ok { + return v, err + } + } + case float64: + switch r := rhs.(type) { + case int64: + if v, err, ok := applyFloat64Binary(op, l, float64(r)); ok { + return v, err + } + case int: + if v, err, ok := applyFloat64Binary(op, l, float64(r)); ok { + return v, err + } + case float64: + if v, err, ok := applyFloat64Binary(op, l, r); ok { + return v, err + } + } + case string: + if r, ok := rhs.(string); ok { + return applyStringBinary(op, l, r) + } + case bool: + if r, ok := rhs.(bool); ok { + switch op { + case token.EQL: + return l == r, nil + case token.NEQ: + return l != r, nil + } + } + } + // String concatenation and comparison. if ls, lok := asString(lhs); lok { if rs, rok := asString(rhs); rok { @@ -666,6 +731,93 @@ func applyBinary(op token.Token, lhs, rhs any) (any, error) { return nil, fmt.Errorf("%w: operator %v not supported for %T and %T", ErrEvaluate, op, lhs, rhs) } +// applyInt64Binary handles a binary op on two integral operands. ok is +// false for operators the numeric paths do not support, so the caller +// can fall through to the general path. Equality intentionally compares +// as float64 to match looseEqual, which the general path consults before +// its integer branch. +func applyInt64Binary(op token.Token, l, r int64) (any, error, bool) { + switch op { + case token.EQL: + return float64(l) == float64(r), nil, true + case token.NEQ: + return float64(l) != float64(r), nil, true + case token.ADD: + if v, ok := checkedAddInt64(l, r); ok { + return v, nil, true + } + return nil, fmt.Errorf("%w: integer overflow", ErrEvaluate), true + case token.SUB: + if v, ok := checkedSubInt64(l, r); ok { + return v, nil, true + } + return nil, fmt.Errorf("%w: integer overflow", ErrEvaluate), true + case token.MUL: + if v, ok := checkedMulInt64(l, r); ok { + return v, nil, true + } + return nil, fmt.Errorf("%w: integer overflow", ErrEvaluate), true + case token.QUO: + if r == 0 { + return nil, fmt.Errorf("%w: division by zero", ErrEvaluate), true + } + if l == math.MinInt64 && r == -1 { + return nil, fmt.Errorf("%w: integer overflow", ErrEvaluate), true + } + return l / r, nil, true + case token.REM: + if r == 0 { + return nil, fmt.Errorf("%w: modulo by zero", ErrEvaluate), true + } + return l % r, nil, true + case token.LSS: + return l < r, nil, true + case token.GTR: + return l > r, nil, true + case token.LEQ: + return l <= r, nil, true + case token.GEQ: + return l >= r, nil, true + } + return nil, nil, false +} + +// applyFloat64Binary mirrors the general path's float branch, with +// equality matching looseEqual's cross-numeric comparison. +func applyFloat64Binary(op token.Token, l, r float64) (any, error, bool) { + switch op { + case token.EQL: + return l == r, nil, true + case token.NEQ: + return l != r, nil, true + case token.ADD: + return l + r, nil, true + case token.SUB: + return l - r, nil, true + case token.MUL: + return l * r, nil, true + case token.QUO: + if r == 0 { + return nil, fmt.Errorf("%w: division by zero", ErrEvaluate), true + } + return l / r, nil, true + case token.REM: + if r == 0 { + return nil, fmt.Errorf("%w: modulo by zero", ErrEvaluate), true + } + return math.Mod(l, r), nil, true + case token.LSS: + return l < r, nil, true + case token.GTR: + return l > r, nil, true + case token.LEQ: + return l <= r, nil, true + case token.GEQ: + return l >= r, nil, true + } + return nil, nil, false +} + func applyStringBinary(op token.Token, lhs, rhs string) (any, error) { switch op { case token.ADD: @@ -944,6 +1096,9 @@ func (p *Program) evalIndex(ctx context.Context, n *ast.IndexExpr, env any, dept if v, ok, err := p.tryFilterIndex(ctx, n, env, depth); ok { return v, err } + if v, ok, err := p.evalIndexChainRV(ctx, n, env, depth); ok { + return v, err + } recv, err := p.eval(ctx, n.X, env, depth) if err != nil { return nil, err @@ -955,6 +1110,169 @@ func (p *Program) evalIndex(ctx context.Context, n *ast.IndexExpr, env any, dept return indexValue(recv, idx) } +// indexChainStep is one hop of an ident-rooted index/selector chain: +// either a field/key selection (sel) or an index whose operand is +// evaluated lazily (idx). +type indexChainStep struct { + sel string + idx ast.Expr +} + +// evalIndexChainRV walks an ident-rooted chain of index and selector +// hops (a[0][1], a.b[i].c[0], ...) in reflect space, materializing only +// the leaf via Interface(). The general path boxes every intermediate +// result through any, which copies aggregate data — indexing into a +// struct holding a 10 MiB array memcpy's the array on each access. ok +// is true when the fast path was taken (regardless of error); when +// false the caller must use the general path, which also owns the +// unknown-identifier error so suggestions stay consistent. +func (p *Program) evalIndexChainRV(ctx context.Context, n *ast.IndexExpr, env any, depth int) (any, bool, error) { + // A single hop on a map env gains nothing: the value is already + // boxed behind any, so the typed lookup in the general path is + // faster than going through reflect here. Checked before the chain + // walk so the common arr[i] shape skips it entirely. + if _, isIdent := n.X.(*ast.Ident); isIdent { + if _, isMap := env.(map[string]any); isMap { + return nil, false, nil + } + } + // Collect hops leaf-first and find the root. + var steps []indexChainStep + cur := ast.Expr(n) +walk: + for { + switch s := cur.(type) { + case *ast.IndexExpr: + steps = append(steps, indexChainStep{idx: s.Index}) + cur = s.X + case *ast.SelectorExpr: + steps = append(steps, indexChainStep{sel: s.Sel.Name}) + cur = s.X + default: + break walk + } + } + ident, ok := cur.(*ast.Ident) + if !ok { + return nil, false, nil + } + switch ident.Name { + case "true", "false", "nil": + return nil, false, nil + } + if depth+len(steps) > MaxEvalDepth { + return nil, true, fmt.Errorf("%w: expression nested too deeply (limit %d)", ErrEvaluate, MaxEvalDepth) + } + rv, ok, err := lookupEnvRV(env, ident.Name, p.fieldTags) + if err != nil { + return nil, true, err + } + if !ok { + return nil, false, nil + } + for i := len(steps) - 1; i >= 0; i-- { + step := steps[i] + if step.idx == nil { + next, err := selectFieldRV(rv, displayIdent(step.sel), p.fieldTags) + if err != nil { + return nil, true, err + } + rv = next + continue + } + idx, err := p.eval(ctx, step.idx, env, depth) + if err != nil { + return nil, true, err + } + next, err := indexValueRV(rv, idx) + if err != nil { + return nil, true, err + } + rv = next + } + if !rv.IsValid() { + return nil, true, nil + } + if !rv.CanInterface() { + return nil, true, fmt.Errorf("%w: value not accessible", ErrEvaluate) + } + return rv.Interface(), true, nil +} + +// mapStringAnyType lets indexValueRV mirror indexValue's dedicated +// map[string]any branch, keeping error messages identical between the +// fast and general index paths. +var mapStringAnyType = reflect.TypeOf(map[string]any(nil)) + +// indexValueRV mirrors indexValue but keeps the receiver in reflect +// space so intermediate hops of a chain are never boxed through any. +func indexValueRV(rv reflect.Value, idx any) (reflect.Value, error) { + for rv.Kind() == reflect.Interface && !rv.IsNil() { + rv = rv.Elem() + } + if !rv.IsValid() || (rv.Kind() == reflect.Interface && rv.IsNil()) { + return reflect.Value{}, fmt.Errorf("%w: cannot index nil", ErrEvaluate) + } + if rv.Type() == mapStringAnyType { + key, ok := idx.(string) + if !ok { + return reflect.Value{}, fmt.Errorf("%w: map index must be string, got %T", ErrEvaluate, idx) + } + mv := rv.MapIndex(reflect.ValueOf(key)) + if !mv.IsValid() { + return reflect.Value{}, fmt.Errorf("%w: key %q not found%s", + ErrEvaluate, key, fieldHint(rv.Interface(), key, nil)) + } + return mv, nil + } + switch rv.Kind() { + case reflect.Slice, reflect.Array: + i, err := toIndexInt(idx) + if err != nil { + return reflect.Value{}, err + } + if i < 0 || i >= int64(rv.Len()) { + return reflect.Value{}, fmt.Errorf("%w: index %d out of range [0, %d)", ErrEvaluate, i, rv.Len()) + } + return rv.Index(int(i)), nil + case reflect.String: + i, err := toIndexInt(idx) + if err != nil { + return reflect.Value{}, err + } + runes := []rune(rv.String()) + if i < 0 || i >= int64(len(runes)) { + return reflect.Value{}, fmt.Errorf("%w: index %d out of range [0, %d)", ErrEvaluate, i, len(runes)) + } + return reflect.ValueOf(string(runes[i])), nil + case reflect.Map: + keyType := rv.Type().Key() + if idx == nil { + return reflect.Value{}, fmt.Errorf("%w: cannot use nil as map key %v", ErrEvaluate, keyType) + } + kv := reflect.ValueOf(idx) + if !kv.Type().AssignableTo(keyType) { + if isNumericKind(keyType.Kind()) && isNumericKind(kv.Kind()) { + converted, err := safeNumericConvert(kv, keyType) + if err != nil { + return reflect.Value{}, fmt.Errorf("%w: map key conversion: %v", ErrEvaluate, err) + } + kv = converted + } else if kv.Type().ConvertibleTo(keyType) { + kv = kv.Convert(keyType) + } else { + return reflect.Value{}, fmt.Errorf("%w: cannot use %T as map key %v", ErrEvaluate, idx, keyType) + } + } + mv := rv.MapIndex(kv) + if !mv.IsValid() { + return reflect.Value{}, fmt.Errorf("%w: key %v not found", ErrEvaluate, idx) + } + return mv, nil + } + return reflect.Value{}, fmt.Errorf("%w: cannot index %v", ErrEvaluate, rv.Type()) +} + // tryFilterIndex recognises `filter(xs, predicate)[N]` where N is a // non-negative integer literal and `filter` is the built-in special // form (not shadowed by env or funcs). When matched, it iterates xs diff --git a/reflect.go b/reflect.go index 9d9a5f6..00c0249 100644 --- a/reflect.go +++ b/reflect.go @@ -8,9 +8,13 @@ import ( "runtime" ) -// ctxType is the reflect.Type of context.Context; cached at package init so -// buildCallArgs doesn't allocate a fresh one on every call. -var ctxType = reflect.TypeOf((*context.Context)(nil)).Elem() +// ctxType and errValType are the reflect.Types of context.Context and +// error; cached at package init so buildCallArgs and finishCall don't +// construct fresh ones on every call. +var ( + ctxType = reflect.TypeOf((*context.Context)(nil)).Elem() + errValType = reflect.TypeOf((*error)(nil)).Elem() +) // callFunction invokes fn with args using reflection. Argument values are // converted to the function's declared parameter types where possible. @@ -91,8 +95,7 @@ func finishCall(name string, ft reflect.Type, out []reflect.Value) (any, error) case 1: return out[0].Interface(), nil case 2: - errType := reflect.TypeOf((*error)(nil)).Elem() - if !ft.Out(1).Implements(errType) { + if !ft.Out(1).Implements(errValType) { return nil, fmt.Errorf("%w: %q: second return must be error, got %v", ErrEvaluate, name, ft.Out(1)) } if !out[1].IsNil() { diff --git a/struct_tags.go b/struct_tags.go index 6eaa270..11706d2 100644 --- a/struct_tags.go +++ b/struct_tags.go @@ -47,10 +47,37 @@ func cleanFieldTags(names []string) []string { return out } +// goNamePlans caches Go-name field plans for untagged lookups, keyed by +// struct type. reflect's FieldByName re-walks the field set (including +// embedded promotion) on every call and dominates struct-env profiles; +// resolving each name to an index path once per type and reusing it via +// fieldByIndex makes repeated lookups O(depth). Types are finite per +// process, so the cache is unbounded by design, mirroring the per-config +// cache used for tagged lookups. +var goNamePlans sync.Map // map[reflect.Type]*structFieldPlan + func structFieldByName(rv reflect.Value, name string, fieldTags *structTagConfig) (reflect.Value, bool, error) { if fieldTags == nil { - fv := rv.FieldByName(name) - return fv, fv.IsValid(), nil + t := rv.Type() + var plan *structFieldPlan + if p, ok := goNamePlans.Load(t); ok { + plan = p.(*structFieldPlan) + } else { + p, _ := goNamePlans.LoadOrStore(t, buildStructFieldPlan(t, nil)) + plan = p.(*structFieldPlan) + } + entry, ok := plan.fields[name] + if !ok || entry.ambiguous { + // Ambiguous promoted names resolve to "not found", matching + // reflect.Value.FieldByName. + return reflect.Value{}, false, nil + } + fv, ok := fieldByIndex(rv, entry.index) + if !ok { + return reflect.Value{}, false, fmt.Errorf("%w: cannot access %q on nil pointer", + ErrEvaluate, name) + } + return fv, true, nil } plan := fieldTags.plan(rv.Type())