From 1d0fe9f55504495c34ecb382163de48db337241b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 5 Oct 2026 11:52:10 +0200 Subject: [PATCH] Answer architecture round 1: read the module path from go.mod, keep the randomness behind State's methods, rename the field drawState and the counter Seq, and file items 108 and 109 --- AGENTS.md | 2 +- builtins.go | 2 +- docs/decisions.md | 12 ++++++------ fejkdata.go | 14 +++++++------- fejkdata_test.go | 4 ++-- format.go | 2 +- imports_test.go | 17 +++++++++++++++-- inline.go | 2 +- internal/drawstate/drawstate.go | 16 ++++++++++------ internal/drawstate/drawstate_test.go | 20 ++++++++++---------- path_test.go | 2 +- record.go | 6 +++--- render.go | 9 ++++----- render_test.go | 4 ++-- struct.go | 2 +- todo.md | 16 +++++++++++++--- 16 files changed, 78 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f5c4ddc..a41cbc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,4 +112,4 @@ In [docs/decisions.md](docs/decisions.md): - A path draws through its compiled steps - A name lives in the category binding it, or in the repeat binding it, and is drawn on its first read - comprehension floor: every dimension and the overall at 7.0 or above; the change making every `{…}` draw afresh, the move of the draw state and items 98 to 106 ship with no scoring run, and item 5 scores them -- The template engine stays the root package until item 107; the draw state and items 98 to 103 move what stands alone into `internal/` +- The template engine stays the root package until item 107; the draw state sits in `internal/`, and items 98 to 103 move what else stands alone there diff --git a/builtins.go b/builtins.go index 2bf910d..3b2155a 100644 --- a/builtins.go +++ b/builtins.go @@ -76,7 +76,7 @@ var builtins = withTransforms(map[string]builtin{ key = a[0] } return func(s *drawstate.State, _ string, _ []string) string { - return strconv.FormatUint(s.Next(key), 10) + return strconv.FormatUint(s.Seq(key), 10) } }, prints: DataTypeInteger, proveNumber: func(token string, prints DataType, _ []string) proven { return printing(token, prints, bounded(1, math.MaxInt64, true)) diff --git a/docs/decisions.md b/docs/decisions.md index b816e16..0367ad0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -829,15 +829,15 @@ Valid while names are read only inside the category binding them. 2026-10-04, Lilleman auf Larv. Serves goal 3. The nine-seat panels in `todo.md`'s git history, 2026-09-22 to 2026-09-30, held the score between 5.2 and 6.1, with Locality lowest at every seat, and the hardest units were the draw fences that change deleted. The change making every `{…}` draw afresh, the move of the draw state into `internal/drawstate` and `todo.md` items 98 to 106 delete or move what the earlier chunks of the below-floor round touched, so a scoring run before item 106 merges would score code about to go; the calc change accepting a name as an operand shipped unscored on 2026-10-05 for that reason. With the change making every `{…}` draw afresh, the move of the draw state and items 98 to 106 are the restructure the maintainer approved, which `AGENTS.md`'s comprehension rule lets merge below the floor. No scoring run or panel runs until item 5, which runs the nine-seat panel and larv-review's scoring run. The scoring run writes this entry's first scores. Valid until `todo.md` item 5 runs. -## The template engine stays the root package until item 107; the draw state and items 98 to 103 move what stands alone into `internal/` +## The template engine stays the root package until item 107; the draw state sits in `internal/`, and items 98 to 103 move what else stands alone there 2026-10-05, Lilleman auf Larv. Serves goals 3.2 and 3.4. Two architect reviews found the engine's types, `template`, `table`, `arm`, `op` and `nameBinding`, bound in one cycle because several passes each write part of the same structs; the template language's recursion reads few of those types. Moving the engine before items 101 and 104 to 106 give each value one writer would spread that knot across packages, behind forwarding calls from -every public method. The draw state and `DataType` read no engine type, and `DataType` moves -with `proven`, what a proof knows of a value, which is keyed by it; the grammar, the reading -of data files, `proven`, the builtins and a table's rows stop reading any engine type once -items 98 to 103 remove those reads, so they move now, each behind an import list a test -checks. Valid until `todo.md` item 107 moves the engine. +every public method. The draw state read no engine type, so it moved into +`internal/drawstate` first, behind an import list a test checks. `DataType` reads none either, +and moves with `proven`, what a proof knows of a value, which is keyed by it; the grammar, the +reading of data files, `proven`, the builtins and a table's rows stop reading any engine type +once items 98 to 103 remove those reads, so they move now, each added to that list. Valid until `todo.md` item 107 moves the engine. diff --git a/fejkdata.go b/fejkdata.go index 99526bb..effd449 100644 --- a/fejkdata.go +++ b/fejkdata.go @@ -27,13 +27,13 @@ var ErrNoData = errors.New("no data: WithoutShippedData needs at least one WithD // It is safe for concurrent use; a seeded sequence is reproducible only when drawn // from one goroutine. type Generator struct { - // mu guards draws, records, structs and root, which gains a shipped category on the + // mu guards drawState, records, structs and root, which gains a shipped category on the // first call reaching it. - mu sync.Mutex - draws *drawstate.State - root folder // the categories as the node a path walks from, owned here so a walk allocates none - records map[node]recordShape - structs map[reflect.Type]structResult + mu sync.Mutex + drawState *drawstate.State + root folder // the categories as the node a path walks from, owned here so a walk allocates none + records map[node]recordShape + structs map[reflect.Type]structResult } type config struct { @@ -90,7 +90,7 @@ func New(opts ...Option) (*Generator, error) { if err != nil { return nil, fmt.Errorf("fejkdata: %w", err) } - return &Generator{draws: drawstate.New(seed), root: root}, nil + return &Generator{drawState: drawstate.New(seed), root: root}, nil } // load is the tree New starts from. diff --git a/fejkdata_test.go b/fejkdata_test.go index d66f3c3..161f4ef 100644 --- a/fejkdata_test.go +++ b/fejkdata_test.go @@ -98,7 +98,7 @@ func fakeTemplate(t *testing.T, f *Generator, s string) string { // engine builds a seeded generator with no loaded categories, for rendering tests. func engine(seed uint64) *Generator { - return &Generator{draws: drawstate.New(seed)} + return &Generator{drawState: drawstate.New(seed)} } // parse unmarshals a JSON template fragment into its dynamic form. @@ -133,5 +133,5 @@ func compiled(t *testing.T, s string) node { func mustRender(t *testing.T, f *Generator, s string) string { t.Helper() - return renderOnce(f.draws, compiled(t, s)) + return renderOnce(f.drawState, compiled(t, s)) } diff --git a/format.go b/format.go index 9fd33d2..39f531f 100644 --- a/format.go +++ b/format.go @@ -123,7 +123,7 @@ func parseFormat(format string) ([]formatToken, error) { // builtin is a format-string function invoked as {name(args)}. It receives the // draw state, the output emitted so far in the current expansion (for derivations // such as a checksum over preceding digits), and the values of the operands it named -// (calc and the transforms name them). All must stay pure over (rng, emitted, args) so +// (calc and the transforms name them). All must stay pure over (draw state, emitted, operands) so // seeded output is reproducible; seq advances per-generator counter state, which is // itself deterministic. arity is the exact arg count, or -1 for variadic (then // checkArgs does all the validation). diff --git a/imports_test.go b/imports_test.go index 4174ca7..ac3c4b9 100644 --- a/imports_test.go +++ b/imports_test.go @@ -4,6 +4,7 @@ import ( "errors" "go/build" "io/fs" + "os" "path/filepath" "slices" "strings" @@ -12,14 +13,26 @@ import ( // TestPackageImports holds each package in the module to the module packages it may import. func TestPackageImports(t *testing.T) { - const module = "github.com/larvit/fejkdata" + mod, err := os.ReadFile("go.mod") + if err != nil { + t.Fatal(err) + } + module, found := "", false + for _, line := range strings.Split(string(mod), "\n") { + if module, found = strings.CutPrefix(strings.TrimSpace(line), "module "); found { + break + } + } + if !found { + t.Fatal("go.mod names no module") + } allowed := map[string][]string{ ".": {"internal/drawstate"}, "cmd/fejkdata": {"."}, "internal/drawstate": nil, } seen := map[string]bool{} - err := filepath.WalkDir(".", func(dir string, d fs.DirEntry, err error) error { + err = filepath.WalkDir(".", func(dir string, d fs.DirEntry, err error) error { if err != nil || !d.IsDir() { return err } diff --git a/inline.go b/inline.go index 42dd50f..e6038ce 100644 --- a/inline.go +++ b/inline.go @@ -20,7 +20,7 @@ type Template struct { func (t *Template) Fake() string { t.g.mu.Lock() defer t.g.mu.Unlock() - return renderOnce(t.g.draws, t.n) + return renderOnce(t.g.drawState, t.n) } // NewTemplate compiles an inline template — a format string or a JSON value — and diff --git a/internal/drawstate/drawstate.go b/internal/drawstate/drawstate.go index 74cc539..98fec3a 100644 --- a/internal/drawstate/drawstate.go +++ b/internal/drawstate/drawstate.go @@ -1,22 +1,26 @@ -// Package drawstate holds what a generator draws through: the seeded randomness and the -// {seq()} counters. +// Package drawstate holds a generator's seeded randomness, which its draws and builtin +// samples read, and its {seq()} counters. package drawstate import "math/rand/v2" // State is one generator's draw state, kept across its renders. type State struct { - *rand.Rand + rand *rand.Rand counters map[string]uint64 } // New builds the state a seed reproduces. func New(seed uint64) *State { - return &State{Rand: rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)), counters: map[string]uint64{}} + return &State{rand: rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)), counters: map[string]uint64{}} } -// Next advances the counter key names, the one {seq()} or {seq(key)} reads, and returns it. -func (s *State) Next(key string) uint64 { +func (s *State) IntN(n int) int { return s.rand.IntN(n) } + +func (s *State) Float64() float64 { return s.rand.Float64() } + +// Seq advances the counter {seq(key)} reads, or {seq()} for an empty key, and returns it. +func (s *State) Seq(key string) uint64 { s.counters[key]++ return s.counters[key] } diff --git a/internal/drawstate/drawstate_test.go b/internal/drawstate/drawstate_test.go index 0c79192..1225951 100644 --- a/internal/drawstate/drawstate_test.go +++ b/internal/drawstate/drawstate_test.go @@ -5,7 +5,7 @@ import "testing" func TestOneSeedDrawsOneSequence(t *testing.T) { a, b := New(42), New(42) for i := 0; i < 50; i++ { - if x, y := a.Uint64(), b.Uint64(); x != y { + if x, y := a.IntN(1<<30), b.IntN(1<<30); x != y { t.Fatalf("seed 42 diverged at draw %d: %d != %d", i, x, y) } } @@ -14,7 +14,7 @@ func TestOneSeedDrawsOneSequence(t *testing.T) { func TestTwoSeedsDrawTwoSequences(t *testing.T) { a, b := New(1), New(2) for i := 0; i < 50; i++ { - if a.Uint64() != b.Uint64() { + if a.IntN(1<<30) != b.IntN(1<<30) { return } } @@ -24,17 +24,17 @@ func TestTwoSeedsDrawTwoSequences(t *testing.T) { func TestCountersCountFromOneEach(t *testing.T) { s := New(1) for want := uint64(1); want <= 3; want++ { - if got := s.Next(""); got != want { - t.Fatalf("Next(\"\") = %d, want %d", got, want) + if got := s.Seq(""); got != want { + t.Fatalf("Seq(\"\") = %d, want %d", got, want) } } - if got := s.Next("orders"); got != 1 { - t.Fatalf("Next(orders) = %d, want its own count from 1", got) + if got := s.Seq("orders"); got != 1 { + t.Fatalf("Seq(orders) = %d, want its own count from 1", got) } - if got := s.Next(""); got != 4 { - t.Fatalf("Next(\"\") after Next(orders) = %d, want 4", got) + if got := s.Seq(""); got != 4 { + t.Fatalf("Seq(\"\") after Seq(orders) = %d, want 4", got) } - if got := New(1).Next(""); got != 1 { - t.Fatalf("a new state's Next = %d, want 1", got) + if got := New(1).Seq(""); got != 1 { + t.Fatalf("a new state's Seq = %d, want 1", got) } } diff --git a/path_test.go b/path_test.go index aa2425a..a6729de 100644 --- a/path_test.go +++ b/path_test.go @@ -35,7 +35,7 @@ func TestDrawStepsPanicsOnAStepItsNodeLacks(t *testing.T) { t.Errorf("drawSteps(plain, f) recovered %v, want a panic naming the missing field", r) } }() - drawSteps(engine(1).draws, compiled(t, `"plain"`), []pathStep{{kind: stepField, name: "f"}}, &pinSet{}, nil, nil) + drawSteps(engine(1).drawState, compiled(t, `"plain"`), []pathStep{{kind: stepField, name: "f"}}, &pinSet{}, nil, nil) } func TestDeepDottedPath(t *testing.T) { diff --git a/record.go b/record.go index fe99890..604a3a1 100644 --- a/record.go +++ b/record.go @@ -135,7 +135,7 @@ func (f *Generator) FakeRecord(path string) (*Record, error) { var frames frameStack sc := renderScope{frames: &frames} if t, isTable := n.(*table); isTable { - if n, sc.row, err = tableRecord(f.draws, t, tail); err != nil { + if n, sc.row, err = tableRecord(f.drawState, t, tail); err != nil { return nil, fmt.Errorf("fejkdata: %s: %w", path, err) } } else if len(tail) > 0 { @@ -149,7 +149,7 @@ func (f *Generator) FakeRecord(path string) (*Record, error) { if shape.err != nil { return nil, fmt.Errorf("fejkdata: %s %w", path, shape.err) } - return renderRecord(f.draws, shape.template, shape.columns, sc), nil + return renderRecord(f.drawState, shape.template, shape.columns, sc), nil } // tableRecord walks a path's tail from a table to the table whose row is the record, and that @@ -206,7 +206,7 @@ func (t *RecordTemplate) Fake() *Record { t.g.mu.Lock() defer t.g.mu.Unlock() var frames frameStack - return renderRecord(t.g.draws, t.template, t.columns, renderScope{frames: &frames}) + return renderRecord(t.g.drawState, t.template, t.columns, renderScope{frames: &frames}) } // NewRecordTemplate compiles an inline record — a JSON object with a format and diff --git a/render.go b/render.go index 3cdf593..047a450 100644 --- a/render.go +++ b/render.go @@ -7,9 +7,8 @@ import ( "github.com/larvit/fejkdata/internal/drawstate" ) -// rng is the randomness a builtin sample draws from; *rand.Rand satisfies it. The -// render path takes the concrete *drawstate.State instead, which keeps the draws of the walk -// drawing through it off the heap. +// rng is the randomness a builtin sample draws from, which *drawstate.State satisfies. The +// render path takes the concrete type instead, which keeps the draws of the walk off the heap. type rng interface { IntN(n int) int Float64() float64 @@ -27,7 +26,7 @@ func (f *Generator) Fake(path string) (string, error) { return "", fmt.Errorf("fejkdata: %w", err) } f.loadShippedAt(segments) - n, pins, err := descend(f.draws, &f.root, segments) + n, pins, err := descend(f.drawState, &f.root, segments) if err != nil { return "", fmt.Errorf("fejkdata: %s: %w", path, err) } @@ -36,7 +35,7 @@ func (f *Generator) Fake(path string) (string, error) { } var frames frameStack sc, _ := renderScope{frames: &frames}.at(n, &pins).enter(n, nil) - return render(f.draws, n, sc), nil + return render(f.drawState, n, sc), nil } // descend walks a caller's path to the node it names, returning the rows its leaf renders in. diff --git a/render_test.go b/render_test.go index d9ac367..b61e041 100644 --- a/render_test.go +++ b/render_test.go @@ -43,7 +43,7 @@ func TestGrowIsALowerBound(t *testing.T) { } for i := 0; i < 50; i++ { var frames frameStack - if got := len(expand(f.draws, tmpl, renderScope{frames: &frames})); got < tmpl.compiled.grow { + if got := len(expand(f.drawState, tmpl, renderScope{frames: &frames})); got < tmpl.compiled.grow { t.Errorf("format %q: expand emitted %d bytes, below grow %d", format, got, tmpl.compiled.grow) } } @@ -113,7 +113,7 @@ func TestNodeSwitchesHandleEveryKind(t *testing.T) { "prove": func(n node) { (&valueProof{}).prove(n) }, "render": func(n node) { var frames frameStack - render(engine(1).draws, n, renderScope{frames: &frames, row: renderedRow{tbl, 0}}) + render(engine(1).drawState, n, renderScope{frames: &frames, row: renderedRow{tbl, 0}}) }, "renderEdges": func(n node) { renderEdges(n) }, "stepInto": func(n node) { _, _ = stepInto(n, "x") }, diff --git a/struct.go b/struct.go index 7023e15..f41e684 100644 --- a/struct.go +++ b/struct.go @@ -29,7 +29,7 @@ func (f *Generator) FakeStruct(v any) error { if err != nil { return fmt.Errorf("fejkdata: %w", err) } - shape.fill(f.draws, p.Elem()) + shape.fill(f.drawState, p.Elem()) return nil } diff --git a/todo.md b/todo.md index d6dcad7..b12cb61 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 108` +`Next ID = 110` | Goal | W | |---|---| @@ -113,6 +113,7 @@ | 46 | 0.2.0 | | **Report the same error every load for a table with two bad options, and for a folder with two unnamed rows files.** | 2 | 2 | 3 | 7 | 6, 10 | 14.0 | | 107 | 0.2.0 | decision | **Move the template engine into `internal/`, leaving the root the public API and its options.** | 6 | 9 | 3 | 8 | 3.2, 3.4 | 12.2 | | 94 | 0.2.0 | | **Refuse a name whose every read passes one deeper level, naming the binding of that level: `{place as p}{p.geo.town.name} {p.geo.town.zip}` is `{place.geo.town as t}{t.name} {t.zip}`.** | 3 | 3 | 3 | 5 | 5.7 | 10.2 | +| 108 | 0.2.0 | defect | **Name item 26 in the decision "No builtin reads the clock, so a date is bounded by days, never by an age", as the item that ends its conflict with goal 4.** | 1 | 1 | 1 | 4 | 3.4 | 8.0 | | 14 | 0.3.0 | | **Spell `misc.creditcard`'s digit runs `{digits(n)}`.** | 2 | 2 | 4 | 6 | 5.7 | 15.1 | | 38 | 0.3.0 | defect | **Compare `calcParser.binary`'s operator as a rune.** | 1 | 1 | 3 | 6 | 6 | 13.8 | | 45 | 0.3.0 | | **Name the node a selector follows in `stepInto`'s refusal.** | 1 | 1 | 3 | 6 | 6 | 13.8 | @@ -136,6 +137,7 @@ | 95 | 0.3.0 | principle | **Hold a struct's columns and the field each fills in one slice.** | 1 | 2 | 1 | 5 | 3.2 | 9.5 | | 19 | 0.3.0 | | **Fill the 398 Swedish localities weighted 200 from SCB småorter.** | 2 | 4 | 4 | 6 | 14.1 | 9.2 | | 56 | 0.3.0 | defect | **Build on a manual run of `test.yml`.** | 1 | 1 | 1 | 4 | 1 | 8.8 | +| 109 | 0.3.0 | principle | **Indent every script under `data-import/` with hard tabs, as `AGENTS.md` and `release-tooling/` do.** | 2 | 4 | 1 | 4 | 3 | 6.2 | | 63 | 0.4.0 | | **Pair a street with its exact postnummer.** | 3 | 6 | 5 | 8 | 4.1 | 19.1 | | 62 | 0.4.0 | | **Add `{btc()}` and `{eth()}`.** | 2 | 4 | 4 | 6 | 4 | 15.2 | | 59 | 0.4.0 | | **Promise in `Generator`'s godoc that its renders run one at a time.** | 1 | 1 | 5 | 5 | 12 | 13.2 | @@ -267,7 +269,7 @@ Pure code that reads strings: `scanUnit`, `formatToken` and `parseFormat`; the s ### 100. Move the builtins, with their checksums, layouts and transforms, into `internal/builtinfunc`, and check `calc`'s operands where its template links. -Needs item 99. `checkArgs` drops its `fields` parameter: `calc`, the one builtin reading it, checks its operands where the template links, and a load with two mistakes reports the same one first as today. `ibanArg` joins the builtins, and `callFn` takes `internal/drawstate`'s type. +Needs item 99. `checkArgs` drops its `fields` parameter: `calc`, the one builtin reading it, checks its operands where the template links, and a load with two mistakes reports the same one first as today. `ibanArg` joins the builtins. ### 106. Settle each record's shape when its template links, so `Generator.records` goes. @@ -392,12 +394,16 @@ Require the path step to reach a sibling category. ### 107. Move the template engine into `internal/`, leaving the root the public API and its options. -Needs item 5. The engine returns column values and the root builds `Record`. Revises the decision "The template engine stays the root package until item 107; the draw state and items 98 to 103 move what stands alone into `internal/`", and, if the vocabulary moves, "The vocabulary sits below `doc.go`'s package clause, not in the package doc". +Needs item 5. The engine returns column values and the root builds `Record`. Revises the decision "The template engine stays the root package until item 107; the draw state sits in `internal/`, and items 98 to 103 move what else stands alone there", and, if the vocabulary moves, "The vocabulary sits below `doc.go`'s package clause, not in the package doc". ### 94. Refuse a name whose every read passes one deeper level, naming the binding of that level: `{place as p}{p.geo.town.name} {p.geo.town.zip}` is `{place.geo.town as t}{t.name} {t.zip}`. Both spellings load and draw alike, against goal 5.7. Widen the read-once refusal in `namefence.go` to the longest prefix every read of a name shares. +### 108. Name item 26 in the decision "No builtin reads the clock, so a date is bounded by days, never by an age", as the item that ends its conflict with goal 4. + +Found by the goals audit of item 97's chunk. Without the clock, `sv_SE.personnummer` never takes the `+` Skatteverket writes from the year the holder turns 100, so the oldest draws turn invalid from 2030. Item 26 revises the decision, but the decision does not point back to it. + ### 14. Spell `misc.creditcard`'s digit runs `{digits(n)}`. A `"repeat": 14` over `{digits(1)}` draws every 14-digit run with the odds `{digits(14)}` does. Both load, since neither has a part that changes nothing; the shipped data writes the obvious one. Re-pin seeded output in its own commit. @@ -470,6 +476,10 @@ Its doc says render failure, and it wraps what `NewTemplate` and `NewRecordTempl `workflow_dispatch` leaves `github.event.before` empty, so the diff compares `HEAD` with itself and skips `docker build`. +### 109. Indent every script under `data-import/` with hard tabs, as `AGENTS.md` and `release-tooling/` do. + +Found by the technical-principles audit of item 97's chunk. Technical principle: indent with hard tabs. All 19 scripts indent with four spaces, so an edit there either keeps the wrong style or rewrites the whole file. + ### 63. Pair a street with its exact postnummer. Needs an application to Lantmäteriet; today a street goes to the nearest postal code centroid. It rewrites shipped rows, so it is a major once 1.0 is cut and a minor before that.