diff --git a/CHANGELOG.md b/CHANGELOG.md index 1438131..ddb5031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -494,6 +494,13 @@ because it turns other people's test suites red. again for each one. Both are now done once, and the window uses less memory while you type. +- **Changing a setting of a preset is about four times faster.** With + `upload-validation`, changing one of its settings held the window for + about 0.15 seconds, and so did every key typed on `Several batches` built + on it. Both now take about 0.03 seconds. Switching the base preset on + `Several batches` to `upload-validation` went from about 0.3 to 0.07 + seconds. The sets the presets build are byte for byte the same as before. + - **Typing on the `Presets` screen no longer lags.** With `upload-validation` chosen, every key typed into a box held the window for about 0.3 seconds, and about 0.07 seconds with `tabular-import`, because the preset was worked diff --git a/internal/format/registry.go b/internal/format/registry.go index bac6893..57b81b3 100644 --- a/internal/format/registry.go +++ b/internal/format/registry.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" "strconv" + "strings" "sync" ) @@ -49,31 +50,93 @@ func Register(d Descriptor) { // expansion afresh against 0.51 MB remembered, and a keystroke that cost // 380 ms in the real window (docs/GUI-MEMORY-2026-09-23.md section 2.3). // -// Keyed by id, which is safe because Register refuses a second descriptor -// under one. Here rather than beside its caller because this file is already -// where the registry's reads meet its writes: the window settles from its -// worker as well as from its own goroutine. The size is worked out without -// the lock held, because planning an archive reads the registry itself. +// Keyed by id and request - see SmallestRemembered - which is safe because +// Register refuses a second descriptor under one id. Here rather than beside +// its caller because this file is already where the registry's reads meet its +// writes: the window settles from its worker as well as from its own +// goroutine. The size is worked out without the lock held, because planning an +// archive reads the registry itself. func SmallestWithLabel(d Descriptor) int64 { + return SmallestRemembered(d, Request{Label: true}) +} + +// SmallestRemembered is d.SmallestAccepted(r), worked out once per format and +// request and remembered. +// +// SmallestWithLabel's reasoning, for the questions that carry settings or a +// seed. The presets ask the floor of a file with its dialect, of a sheet with +// its rows and columns, of the boundary set with seed 1 - and asked it afresh +// at every expansion, which on a picture is encoding one. Measured 2026-09-23: +// 29% of expanding tabular-import (docs/GUI-MEMORY-2026-09-23.md section 4j). +// +// The size a request asks for and SizeFromContents are left out, because +// SmallestAccepted sets both itself. A request with contents is worked out +// every time - see RequestKey. +// +// At most smallestCeiling answers are kept. The key grows with values somebody +// types - the rows of a sheet - so a long session would otherwise keep one for +// every number ever typed. Past the ceiling the memory starts again, which +// costs one working out per question and nothing else. +func SmallestRemembered(d Descriptor, r Request) int64 { + r.Bytes, r.SizeFromContents = 0, false + key, ok := RequestKey(d.ID, r) + if !ok { + return d.SmallestAccepted(r) + } smallestMu.Lock() - known, ok := smallestKnown[d.ID] + known, found := smallestKnown[key] smallestMu.Unlock() - if ok { + if found { return known } - size := d.SmallestAccepted(Request{Label: true}) + size := d.SmallestAccepted(r) smallestMu.Lock() - smallestKnown[d.ID] = size + if len(smallestKnown) >= smallestCeiling { + smallestKnown = map[string]int64{} + } + smallestKnown[key] = size smallestMu.Unlock() return size } -// smallestKnown is what SmallestWithLabel has worked out, by format id. +// smallestCeiling is how many answers SmallestRemembered keeps - a few hundred +// bytes each, so the most it holds is about a megabyte. +const smallestCeiling = 4096 + +// smallestKnown is what SmallestRemembered has worked out, by RequestKey. var ( smallestMu sync.Mutex smallestKnown = map[string]int64{} ) +// RequestKey is one request to one format written as text that no other +// request shares, so that something worked out for it can be kept under it. +// +// Every field of Request that can change a plan is in it, and a guard sets +// each field in turn to hold that true when Request grows. The values are +// quoted, because a setting's value is text somebody typed and may hold any +// separator - a CSV delimiter of "|" would otherwise read as the start of a +// second setting, and two requests sharing a key share an answer. +// +// A request with contents has no key. What an archive holds is a list of +// formats with sizes of their own, and nothing asks such a request twice. +func RequestKey(id string, r Request) (string, bool) { + if len(r.Contains) > 0 { + return "", false + } + names := make([]string, 0, len(r.Properties)) + for name := range r.Properties { + names = append(names, name) + } + sort.Strings(names) + var b strings.Builder + fmt.Fprintf(&b, "%q %d %t %t %d", id, r.Bytes, r.SizeFromContents, r.Label, r.Seed) + for _, name := range names { + fmt.Fprintf(&b, " %q=%q", name, r.Properties[name]) + } + return b.String(), true +} + // SortChoices puts a closed set in the order somebody looks for a value in. // // Here rather than in the menu that draws them, and that is the whole point: diff --git a/internal/guard/presetbytes_test.go b/internal/guard/presetbytes_test.go index 0a26aa1..1852b3e 100644 --- a/internal/guard/presetbytes_test.go +++ b/internal/guard/presetbytes_test.go @@ -23,29 +23,60 @@ import ( // was taken by hand on 2026-09-08 and again either side of that move on // 2026-09-22, both times 1298 B and this sum. This is that measurement kept. // +// Every preset since 2026-09-24, and a refusal's words as well as a source's +// bytes. Until then only size-boundaries was pinned, and the change that day +// was to how the other four work their sets out: asking the format for its +// smallest size once rather than for every file, and planning a file the set +// holds twelve times once (docs/GUI-MEMORY-2026-09-23.md section 4j). Neither +// may move a byte, so the gate came first and was measured on the tree before +// the change. The cases are the ones that reach what changed - other formats +// in allow, which ask for other floors, a limit small enough that a refusal +// names the floor, and a spread narrow enough to reach it. +// // What to do when it goes red: decide, rather than update. The sum moving is a // breaking change under D11 - a major, a Breaking entry in the changelog, and // the owner's decision, because untouchable rule 12 says the assistant does not // raise the version. A refactor that moved it is a refactor to undo. func TestEjectingAPresetGivesTheBytesItAlwaysGave(t *testing.T) { + // bytes and sum are the whole document, refused the whole refusal. The + // first row was measured 2026-09-08, the rest on 2026-09-24 before the + // change they guard. pinned := []struct { - id string - args preset.Args - // bytes and sum are the whole document, measured 2026-09-08 and - // unchanged since. - bytes int - sum string + id string + args preset.Args + bytes int + sum string + refused string }{ - { - id: "size-boundaries", - args: preset.Args{"limit": "10mb", "format": "pdf"}, - bytes: 1298, - sum: "2733cf63db40465fb97e26790d668d65ea01f5e94927a44ddf0869399beee2bb", - }, + {id: "size-boundaries", args: preset.Args{"format": "pdf", "limit": "10mb"}, bytes: 1298, sum: "2733cf63db40465fb97e26790d668d65ea01f5e94927a44ddf0869399beee2bb"}, + {id: "size-boundaries", args: preset.Args{"format": "png", "limit": "1mb"}, refused: "the preset size-boundaries cannot build this set - under_1mb would be 0 B, and a file cannot be smaller than nothing. Raise the limit above 1048650 B, narrow the spread, or choose a format with a smaller minimum. The limit asked for was 1048576 B."}, + {id: "size-boundaries", args: preset.Args{"format": "jpg", "limit": "10mb"}, bytes: 1298, sum: "29c7e0a133fb97fdf9d19fb40d0d96ad97c4a1fef67556a9dc734b520f7d9b09"}, + {id: "size-boundaries", args: preset.Args{"format": "jpg", "limit": "2kb", "spread": "1kb"}, bytes: 640, sum: "1346f4a7ae514fe2d15de426b910e308442f66fa97b485932d8fa1f5df56b910"}, + {id: "size-boundaries", args: preset.Args{"format": "jpg", "limit": "300", "spread": "100"}, refused: "the preset size-boundaries cannot build this set - under_100 would be 200 B and the smallest JPG this build makes is 602 B. Raise the limit above 702 B, narrow the spread, or choose a format with a smaller minimum. The limit asked for was 300 B."}, + {id: "upload-validation", args: preset.Args{}, bytes: 4116, sum: "a75039d859ee25d5ea5fd463ac2a774aacb1c45d013cf7ef3b80d46e0688fb78"}, + {id: "upload-validation", args: preset.Args{"limit": "5mb"}, bytes: 4108, sum: "4b7e716e2e199837b3c2bef228921e0087e894c49a8cf868a246c0ac636b9a35"}, + {id: "upload-validation", args: preset.Args{"limit": "3kb"}, refused: "the preset upload-validation cannot build this set - allowed_pdf would be 1536 B and the smallest PDF this build makes is 3415 B. Raise the limit to 6830 B or more, or take pdf out of the allowed types. The limit asked for was 3072 B."}, + {id: "upload-validation", args: preset.Args{"allow": "docx,gif"}, bytes: 3816, sum: "33975195adf794dd9f6e96dbec8a9775bfbda3c4b7dfd3524475f70c39496059"}, + {id: "upload-validation", args: preset.Args{"allow": "xlsx,ico,wav"}, bytes: 4125, sum: "c46aba3ad1aa6bf3ac2442bde058b9802b4d4f72375fd2f7cbffc6fab1620d6f"}, + {id: "upload-validation", args: preset.Args{"bulk": "3", "far-over": "off"}, bytes: 3933, sum: "98bc51d5edb94a80fb764a03915eccc6997b70fa8eac153578320b3128cb6d95"}, + {id: "upload-validation", args: preset.Args{"deny": "exe,js"}, bytes: 3767, sum: "55d9946e7233761716849f52517cd58f7a38c8748f4cdae8620afade0681621c"}, + {id: "tabular-import", args: preset.Args{}, bytes: 3369, sum: "fad20b41756327a6e85da93715fb09b20db394fb23e4b3ce22bdaf6e11b20726"}, + {id: "tabular-import", args: preset.Args{"rows": "100"}, bytes: 3367, sum: "ee28ae5421d4717fb24ee6dfbef53f7a54e3a00105e5015f884d59768181d552"}, + {id: "tabular-import", args: preset.Args{"columns": "5"}, bytes: 3367, sum: "580d526546720b851ac7d834b97c163f5ff50b29caeff5b27fcc1ba00e5bb952"}, + {id: "text-encoding", args: preset.Args{}, bytes: 4570, sum: "de27b9dc6c646baebaa0b16019ba3ce15d0f1d145941d376b47263de242998e6"}, + {id: "text-encoding", args: preset.Args{"sample": "8kb"}, bytes: 4570, sum: "f87c73864e5f517abb08b50393cd9a1681a90a30560c1d14dfddcf31e8037479"}, + {id: "empty-and-minimal", args: preset.Args{}, bytes: 3811, sum: "80641962ac9dfb303f812fd78e0a0d1080f714094d159d7b10448291debb9279"}, + {id: "empty-and-minimal", args: preset.Args{"formats": "jpg,png,txt"}, bytes: 743, sum: "4fd23e4b06a2e27ede987ab48a2cc302cc2accb9f948c7d5d25f675681f31f69"}, } for _, want := range pinned { expanded, err := preset.Expand(want.id, want.args) + if want.refused != "" { + if err == nil || err.Error() != want.refused { + t.Errorf("%s at %v was refused with\n %q\nuntil now, and now gives\n %v", want.id, want.args, want.refused, err) + } + continue + } if err != nil { t.Errorf("%s refused %v: %v", want.id, want.args, err) continue @@ -55,8 +86,7 @@ func TestEjectingAPresetGivesTheBytesItAlwaysGave(t *testing.T) { if len(expanded.Source) == want.bytes && got == want.sum { continue } - t.Errorf("ejecting %s at %v gives %d B and %s, and it has given %d B and %s since "+ - "2026-09-08.\n"+ + t.Errorf("ejecting %s at %v gives %d B and %s, and it has given %d B and %s until now.\n"+ "Every manifest written from this preset carries a hash of these bytes, so this is a "+ "breaking change under D11 rather than a number to update here.\n%s", want.id, want.args, len(expanded.Source), got, want.bytes, want.sum, expanded.Source) diff --git a/internal/guard/presetcost_test.go b/internal/guard/presetcost_test.go new file mode 100644 index 0000000..788027c --- /dev/null +++ b/internal/guard/presetcost_test.go @@ -0,0 +1,149 @@ +package guard + +import ( + "fmt" + "runtime" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" + "github.com/donislawdev/TestingFilesGenerator/internal/preset" + "github.com/donislawdev/TestingFilesGenerator/internal/recipe" +) + +// A new upload limit does not ask the same questions again. +// +// Expanding upload-validation took 137-142 ms and 57 MB whenever a value +// changed, which in the window is every key typed into one of its settings or +// into the batch screen built on it. Two causes, both a question asked again +// with the answer already known: the smallest size of a format, found for +// every file about a name by encoding pictures at growing sizes, and one small +// picture planned under a dozen names (docs/GUI-MEMORY-2026-09-23.md section +// 4j). Measured 2026-09-24, least of five: 9.58 MB with both answered once, +// 18.95 MB with the picture planned under every name, 44.29 MB with the floor +// worked out for every file, 9.67 MB under -race. The line sits between the +// first two. +// +// Asked with a limit this process has not expanded before, because the same +// limit twice is the window's memory's business and says nothing about this. +// The least of several readings, because the counter is the whole process's +// and a reading can only be too high - see +// TestTheMinimalSetIsWorkedOutOnceAndNotAtEveryExpansion. +// +// If it goes red after a change that is not about this, measure the four +// numbers above again before moving the line: a line moved to the new reading +// no longer stands between anything. +func TestANewUploadLimitDoesNotAskTheSameQuestionsAgain(t *testing.T) { + const ceiling = 13<<20 + 1<<19 // 13.5 MB + // The state this is about: files that ask one question under several + // names. A set without them would expand cheaply whatever this code did. + if most := mostFilesAskingOneQuestion(t, "upload-validation", preset.Args{}); most < 5 { + t.Fatalf("the most files of upload-validation asking one question is %d, so the set no longer holds what this guard is about", most) + } + least := leastAllocatedByAnExpansion(t, "upload-validation", func(i int) preset.Args { + return preset.Args{"limit": fmt.Sprintf("%dmb", 21+i)} + }) + if least > ceiling { + t.Errorf("expanding upload-validation at a new limit allocated %d bytes, over %d - "+ + "a format's smallest size or a file already planned is being worked out again", least, ceiling) + } +} + +// A new row count works the sheet's smallest size out once, not twice. +// +// tabular-import asks for its sheet at the smallest size the rows and columns +// allow, and finding that size means building the sheet at growing sizes. The +// size was asked twice per expansion - once to check the file, once to write +// it into the set - and nothing remembered it. A new row count does change the +// sheet, so one working out is the real work and the second was the waste. +// The wide CSV, the other file asked at its floor, is the same at every +// expansion and now comes from memory. +// +// Measured 2026-09-24 at 300 rows, least of five: 19.84 MB remembered, 38.59 MB +// worked out twice, 20.63 MB under -race. The line sits between. The numbers +// grow with the rows - 700 rows allocate 39 MB remembered - so the rows asked +// here stay where they were measured. +func TestANewRowCountWorksTheSheetsSmallestSizeOutOnce(t *testing.T) { + const ceiling = 27 << 20 + if sheets := filesOfFormat(t, "tabular-import", preset.Args{}, "xlsx"); sheets != 1 { + t.Fatalf("tabular-import holds %d sheets, so the set no longer holds the file this guard is about", sheets) + } + least := leastAllocatedByAnExpansion(t, "tabular-import", func(i int) preset.Args { + return preset.Args{"rows": fmt.Sprintf("%d", 300+i)} + }) + if least > ceiling { + t.Errorf("expanding tabular-import at a new row count allocated %d bytes, over %d - "+ + "the sheet's smallest size is being worked out more than once", least, ceiling) + } +} + +// leastAllocatedByAnExpansion expands a preset once, then five times with +// values it has not been given, and returns the least any of the five +// allocated. +func leastAllocatedByAnExpansion(t *testing.T, id string, fresh func(int) preset.Args) uint64 { + t.Helper() + if _, err := preset.Expand(id, preset.Args{}); err != nil { + t.Fatalf("%s did not expand, so nothing was asked: %v", id, err) + } + least := ^uint64(0) + for i := 0; i < 5; i++ { + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + if _, err := preset.Expand(id, fresh(i)); err != nil { + t.Fatalf("%s at %v: %v", id, fresh(i), err) + } + runtime.ReadMemStats(&after) + if spent := after.TotalAlloc - before.TotalAlloc; spent < least { + least = spent + } + } + return least +} + +// mostFilesAskingOneQuestion is how many targets of an expanded set ask their +// format the same thing, at most - one format, one size, one set of settings. +func mostFilesAskingOneQuestion(t *testing.T, id string, args preset.Args) int { + t.Helper() + same := map[string]int{} + most := 0 + for _, target := range expandedTargets(t, id, args) { + if len(target.Sizes) == 0 { + continue + } + key, ok := format.RequestKey(target.Format, format.Request{ + Label: true, Properties: target.Properties, Bytes: target.Sizes[0], + }) + if !ok { + continue + } + same[key]++ + if same[key] > most { + most = same[key] + } + } + return most +} + +func filesOfFormat(t *testing.T, id string, args preset.Args, formatID string) int { + t.Helper() + n := 0 + for _, target := range expandedTargets(t, id, args) { + if target.Format == formatID { + n++ + } + } + return n +} + +func expandedTargets(t *testing.T, id string, args preset.Args) []recipe.Target { + t.Helper() + expanded, err := preset.Expand(id, args) + if err != nil { + t.Fatalf("%s did not expand: %v", id, err) + } + rec, err := recipe.Parse(expanded.Source, id) + if err != nil { + t.Fatalf("%s expanded into a recipe that does not read: %v", id, err) + } + return rec.Targets +} diff --git a/internal/guard/requestkey_test.go b/internal/guard/requestkey_test.go new file mode 100644 index 0000000..10adb04 --- /dev/null +++ b/internal/guard/requestkey_test.go @@ -0,0 +1,77 @@ +package guard + +import ( + "reflect" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// Two requests that can be planned differently never share a key. +// +// format.RequestKey keys what was worked out for a request - the smallest size +// a format takes, whether a file of a set was already planned. Two requests +// under one key share one answer, so a field of Request left out of the key is +// a preset laid out on another request's floor, and a manifest carrying a hash +// of bytes that should not have been. Nothing would say so: the answer is a +// number, and a wrong number looks like a right one. +// +// Asked of every field of Request by reflection rather than of a list written +// here, so the day Request grows a field, this goes red until the key carries +// it. A list copied by hand is the kind that goes stale green. +func TestEveryFieldOfARequestIsInItsKey(t *testing.T) { + base, ok := format.RequestKey("png", format.Request{}) + if !ok { + t.Fatal("an empty request has no key, so nothing below compares against anything") + } + typ := reflect.TypeOf(format.Request{}) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + var r format.Request + v := reflect.ValueOf(&r).Elem().Field(i) + switch field.Type.Kind() { + case reflect.Int64: + v.SetInt(1) + case reflect.Uint64: + v.SetUint(1) + case reflect.Bool: + v.SetBool(true) + case reflect.Map: + v.Set(reflect.ValueOf(map[string]string{"delimiter": ";"})) + case reflect.Slice: + v.Set(reflect.MakeSlice(field.Type, 1, 1)) + // A request with contents is worked out every time rather than + // keyed, and that has to stay true rather than become a key that + // ignores them. + if _, keyed := format.RequestKey("png", r); keyed { + t.Errorf("a request with %s set has a key, and the key cannot tell what is inside one archive from another", field.Name) + } + continue + default: + t.Fatalf("Request.%s is a %s, which this guard does not know how to set - teach it, and the key, before anything is keyed by it", + field.Name, field.Type.Kind()) + } + got, keyed := format.RequestKey("png", r) + if !keyed { + t.Errorf("a request with only %s set has no key", field.Name) + continue + } + if got == base { + t.Errorf("a request with %s set has the key of an empty one, so the two would share an answer", field.Name) + } + } + + // The format as well, which is not a field. + if other, _ := format.RequestKey("jpg", format.Request{}); other == base { + t.Error("two formats asked the same thing share a key") + } + + // And a value holding what the key separates settings with - a space and + // an equals sign. A value is text somebody typed, so it can hold anything, + // and quoting is what keeps one setting from reading as two. + two, _ := format.RequestKey("csv", format.Request{Properties: map[string]string{"a": "x", "b": "y"}}) + one, _ := format.RequestKey("csv", format.Request{Properties: map[string]string{"a": "x b=y"}}) + if two == one { + t.Errorf("one setting whose value holds the separators has the key of two settings: %s", one) + } +} diff --git a/internal/preset/build.go b/internal/preset/build.go index 46eaafb..b62b49a 100644 --- a/internal/preset/build.go +++ b/internal/preset/build.go @@ -160,9 +160,13 @@ type setFile struct { } // bytes is the size this file is asked for. +// +// A floor is the remembered one, because this is asked more than once for +// every file - by refused and by draft - and working it out plans the format +// at growing sizes. See format.SmallestRemembered. func (f setFile) bytes() int64 { if f.atFloor { - return f.desc.SmallestAccepted(format.Request{Label: true, Properties: f.props}) + return format.SmallestRemembered(f.desc, format.Request{Label: true, Properties: f.props}) } return f.size } @@ -174,12 +178,16 @@ func (f setFile) bytes() int64 { // one level down: a set missing the three files the run was about still looks // like a set. func (f setFile) refused() error { - r := format.Request{Label: true, Properties: f.props} - r.Bytes = f.bytes() - _, err := f.desc.Generator.Plan(r) + _, err := f.desc.Generator.Plan(f.request()) return err } +// request is what refused asks the format - one place, so that a set that +// skips a question it has already asked keys it by the question itself. +func (f setFile) request() format.Request { + return format.Request{Label: true, Properties: f.props, Bytes: f.bytes()} +} + func (f setFile) draft() recipe.TargetDraft { count := "1" if f.count > 1 { diff --git a/internal/preset/limitset.go b/internal/preset/limitset.go index 2c3d229..79e8f24 100644 --- a/internal/preset/limitset.go +++ b/internal/preset/limitset.go @@ -83,7 +83,7 @@ func (s limitSet) steps() []step { // files still looks like a set, and the three that are missing are the ones the // run was about - the ones nearest the limit. func (s limitSet) reachable(set []step) error { - floor := s.desc.SmallestAccepted(format.Request{Seed: 1, Label: true}) + floor := format.SmallestRemembered(s.desc, format.Request{Seed: 1, Label: true}) for _, one := range set { if one.size >= floor { continue diff --git a/internal/preset/textencoding.go b/internal/preset/textencoding.go index 983f7a4..be18018 100644 --- a/internal/preset/textencoding.go +++ b/internal/preset/textencoding.go @@ -254,7 +254,7 @@ func lineEndingCells() []textFile { // about the settings and not about the room they need. func refusedOutright(desc format.Descriptor, props map[string]string) string { r := format.Request{Label: true, Properties: props} - r.Bytes = desc.SmallestAccepted(r) + r.Bytes = format.SmallestRemembered(desc, r) _, err := desc.Generator.Plan(r) var bad *format.PropertyValueError if errors.As(err, &bad) { @@ -331,7 +331,7 @@ func roomEnough(files []textFile, size int64) error { if _, err := f.desc.Generator.Plan(r); err == nil { continue } - if need := f.desc.SmallestAccepted(r); need > floor { + if need := format.SmallestRemembered(f.desc, r); need > floor { floor, tallest = need, f } } diff --git a/internal/preset/uploadset.go b/internal/preset/uploadset.go index 4dc37e7..096ca9c 100644 --- a/internal/preset/uploadset.go +++ b/internal/preset/uploadset.go @@ -317,7 +317,18 @@ func (s uploadSet) files() []setFile { // file is. func (s uploadSet) reachable(files []setFile) error { var worst shortfall + // A file asking what an earlier one asked is not planned again. The set + // holds one small picture under a dozen names - a name with spaces, one + // with no extension, one outside ASCII - and planning is encoding it, so + // that was a dozen encodings of one question (docs/GUI-MEMORY-2026-09-23.md + // section 4j). Skipping changes no answer: the same request gets the same + // refusal and the same need, and the deepest shortfall keeps the FIRST + // file that reached it, because the comparison below is strict. + asked := map[string]bool{} for _, f := range files { + if !firstTimeAsked(asked, f) { + continue + } short, err := s.shortfallOf(f) if err != nil { return err @@ -336,6 +347,21 @@ func (s uploadSet) reachable(files []setFile) error { return s.cannotReach(worst) } +// firstTimeAsked says whether a set is asking this file's question for the +// first time, and remembers that it now has. A question with no key - a file +// with contents - counts as asked for the first time, every time. +func firstTimeAsked(asked map[string]bool, f setFile) bool { + key, ok := format.RequestKey(f.desc.ID, f.request()) + if !ok { + return true + } + if asked[key] { + return false + } + asked[key] = true + return true +} + // shortfall is one file that is smaller than its format will write, and the // limit at which it would stop being. type shortfall struct { @@ -394,8 +420,14 @@ func wouldReach(floor, size, limit int64) int64 { // // The larger of the two rather than the sample, so that a format with a floor // above it cannot turn a file about a NAME into a refusal about a size. +// +// The remembered floor rather than one worked out here. It is the same +// question - the label on, nothing else - and it was asked once for every +// file about a name or an insides, each time encoding pictures to find the +// answer: 35% of expanding upload-validation, measured 2026-09-23 +// (docs/GUI-MEMORY-2026-09-23.md section 4j). func sampleFor(desc format.Descriptor) int64 { - if floor := desc.SmallestAccepted(format.Request{Label: true}); floor > uploadSample { + if floor := format.SmallestWithLabel(desc); floor > uploadSample { return floor } return uploadSample