diff --git a/go.mod b/go.mod index 73c7f9d3550dda843c691dbcf034230aebbf4818..5d7a533b1440c7aa1c60b053f4a6762592f144b2 100644 --- a/go.mod +++ b/go.mod @@ -7,19 +7,19 @@ replace git.autistici.org/ale/lb => 0xacab.org/leap/lb v0.0.0-20210225193050-570 require ( 0xacab.org/leap/bitmask-core v0.0.0-20241127074806-6cb14d2eb811 git.autistici.org/ale/lb v0.0.0-20210301105648-288f2f5853b7 - github.com/emirpasic/gods v1.18.1 github.com/emirpasic/gods/v2 v2.0.0-alpha github.com/go-openapi/errors v0.20.4 github.com/go-openapi/runtime v0.26.0 github.com/go-openapi/strfmt v0.21.7 github.com/go-openapi/swag v0.22.4 github.com/go-openapi/validate v0.22.1 + github.com/jmcvetta/randutil v0.0.0-20150817122601-2bb1b664bcff github.com/jmoiron/sqlx v1.3.5 github.com/kellydunn/golang-geo v0.7.0 github.com/labstack/echo/v4 v4.11.1 github.com/magiconair/properties v1.8.7 github.com/mattn/go-sqlite3 v1.14.22 - github.com/mroth/weightedrand v1.0.0 + github.com/mroth/weightedrand/v2 v2.1.0 github.com/prometheus/client_golang v1.11.1 github.com/rs/zerolog v1.33.0 github.com/spf13/cobra v1.8.1 diff --git a/go.sum b/go.sum index 4daf85f81e653954952848cf93308ddb4bc8df1b..2ea8e03d4d158b4caa06749f1a82dc48590337a2 100644 --- a/go.sum +++ b/go.sum @@ -83,8 +83,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -253,6 +251,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1: github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmcvetta/randutil v0.0.0-20150817122601-2bb1b664bcff h1:6NvhExg4omUC9NfA+l4Oq3ibNNeJUdiAF3iBVB0PlDk= +github.com/jmcvetta/randutil v0.0.0-20150817122601-2bb1b664bcff/go.mod h1:ddfPX8Z28YMjiqoaJhNBzWHapTHXejnB5cDCUWDwriw= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= @@ -328,8 +328,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/mroth/weightedrand v1.0.0 h1:V8JeHChvl2MP1sAoXq4brElOcza+jxLkRuwvtQu8L3E= -github.com/mroth/weightedrand v1.0.0/go.mod h1:3p2SIcC8al1YMzGhAIoXD+r9olo/g/cdJgAD905gyNE= +github.com/mroth/weightedrand/v2 v2.1.0 h1:o1ascnB1CIVzsqlfArQQjeMy1U0NcIbBO5rfd5E/OeU= +github.com/mroth/weightedrand/v2 v2.1.0/go.mod h1:f2faGsfOGOwc1p94wzHKKZyTpcJUW7OJ/9U4yfiNAOU= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= diff --git a/pkg/api/endpoints.go b/pkg/api/endpoints.go index 619d0ac4358aa9e19115bb2ba6889bd8cc491a04..c568c589fe4a77a2838b91413b5ddd7296a79fbf 100644 --- a/pkg/api/endpoints.go +++ b/pkg/api/endpoints.go @@ -11,8 +11,9 @@ import ( "0xacab.org/leap/menshen/pkg/geolocate" "0xacab.org/leap/menshen/pkg/latency" m "0xacab.org/leap/menshen/pkg/models" + wr "0xacab.org/leap/menshen/pkg/weightedrand" rbt "github.com/emirpasic/gods/v2/trees/redblacktree" - wr "github.com/mroth/weightedrand" + "github.com/labstack/echo/v4" ) func filter[T any](f func(t T) bool, items []T) []T { @@ -67,28 +68,29 @@ func sortEndpoints[T m.Bridge | m.Gateway](cc string, endpoints []*T, locations } for range list { // append random element from current list to result - element := chooser.Pick().(*T) - if element != nil { - result = append(result, chooser.Pick().(*T)) + element, err := chooser.PickRandom() + if err != nil { + return nil, fmt.Errorf("failed to sort endpoint list: %v", err) } + result = append(result, *element) } } return result, nil } -func createLocationTree[T m.Bridge | m.Gateway](endpoints []*T, locationWeights map[string]float64) *rbt.Tree[float64, []wr.Choice] { - tree := rbt.New[float64, []wr.Choice]() +func createLocationTree[T m.Bridge | m.Gateway](endpoints []*T, locationWeights map[string]float64) *rbt.Tree[float64, []wr.Choice[*T]] { + tree := rbt.New[float64, []wr.Choice[*T]]() for _, endpoint := range endpoints { - var list []wr.Choice + var list []wr.Choice[*T] found := false locationWeight := locationWeights[getLocation(endpoint)] if list, found = tree.Get(locationWeight); !found { // TODO: we want to set weights per gateways, so that gateways with higher capacities // are priotized, the wr.Choice wrapper is a preparation for that - list = []wr.Choice{{Item: endpoint, Weight: 1}} + list = []wr.Choice[*T]{{Item: endpoint, Weight: 1}} } else { - list = append(list, wr.Choice{Item: endpoint, Weight: 1}) + list = append(list, wr.Choice[*T]{Item: endpoint, Weight: 1}) } tree.Put(locationWeight, list) } @@ -96,18 +98,26 @@ func createLocationTree[T m.Bridge | m.Gateway](endpoints []*T, locationWeights } func createWeightedRandomList[T m.Bridge | m.Gateway](endpoints []*T) ([]*T, error) { - var list []wr.Choice = make([]wr.Choice, len(endpoints)) + list := []wr.Choice[T]{} for _, endpoint := range endpoints { - list = append(list, wr.Choice{Item: endpoint, Weight: 1}) + if endpoint == nil { + continue + } + list = append(list, wr.Choice[T]{Item: *endpoint, Weight: 1}) } choser, err := wr.NewChooser(list...) if err != nil { return nil, err } - for i := 0; i < len(endpoints); i++ { - endpoints[i] = choser.Pick().(*T) + result := []*T{} + for range endpoints { + choice, err := choser.PickRandom() + if err != nil { + return nil, fmt.Errorf("failed to create weighted random endpoint list: %v", err) + } + result = append(result, choice) } - return endpoints, nil + return result, nil } func getLocation(endpoint interface{}) string { diff --git a/pkg/api/endpoints_test.go b/pkg/api/endpoints_test.go index c2850fb2cdc78ba98fe8bc65c4750afdbbca7fee..5397235f8805fc10894c629b9bc3740cde48338f 100644 --- a/pkg/api/endpoints_test.go +++ b/pkg/api/endpoints_test.go @@ -1,13 +1,18 @@ package api import ( + "encoding/json" "errors" "fmt" + "net/http" + "net/http/httptest" "testing" "0xacab.org/leap/menshen/pkg/latency" m "0xacab.org/leap/menshen/pkg/models" + "github.com/labstack/echo/v4" "github.com/magiconair/properties/assert" + "github.com/stretchr/testify/require" ) func TestSortEndpoints(t *testing.T) { @@ -80,12 +85,14 @@ func TestSortEndpoints(t *testing.T) { } for ti, test := range testTable { - sortedList, err := sortEndpoints(test.cc, gateways, locationmap, lm) - assert.Equal(t, err, test.expextedErr) - assert.Equal(t, len(sortedList), len(test.expected)) - for i, entry := range sortedList { - assert.Equal(t, entry.Location, test.expected[i], fmt.Sprintf("test %d: item %d", ti+1, i+1)) - } + t.Run(test.name, func(t *testing.T) { + sortedList, err := sortEndpoints(test.cc, gateways, locationmap, lm) + assert.Equal(t, err, test.expextedErr) + assert.Equal(t, len(sortedList), len(test.expected)) + for i, entry := range sortedList { + assert.Equal(t, entry.Location, test.expected[i], fmt.Sprintf("test %d: item %d", ti+1, i+1)) + } + }) } } @@ -176,16 +183,16 @@ func TestSortBridges_RandomOrderWithinLocation(t *testing.T) { Lon: "-73.56", } - bridge1 := &m.Gateway{Location: locations[0], Host: "gw1_ny"} - bridge2 := &m.Gateway{Location: locations[0], Host: "gw2_ny"} - bridge3 := &m.Gateway{Location: locations[1], Host: "gw3_par"} - bridge4 := &m.Gateway{Location: locations[1], Host: "gw4_par"} - bridge5 := &m.Gateway{Location: locations[2], Host: "gw5_mo"} - bridge6 := &m.Gateway{Location: locations[2], Host: "gw6_mo"} + bridge1 := &m.Bridge{Location: locations[0], Host: "gw1_ny"} + bridge2 := &m.Bridge{Location: locations[0], Host: "gw2_ny"} + bridge3 := &m.Bridge{Location: locations[1], Host: "gw3_par"} + bridge4 := &m.Bridge{Location: locations[1], Host: "gw4_par"} + bridge5 := &m.Bridge{Location: locations[2], Host: "gw5_mo"} + bridge6 := &m.Bridge{Location: locations[2], Host: "gw6_mo"} lm, _ := latency.NewMetric() locationmap := locationMap{locations[0]: locationStruct1, locations[1]: locationStruct2, locations[2]: locationStruct3} - bridges := []*m.Gateway{bridge1, bridge2, bridge3, bridge4, bridge5, bridge6} + bridges := []*m.Bridge{bridge1, bridge2, bridge3, bridge4, bridge5, bridge6} firstResult, _ := sortEndpoints("US", bridges, locationmap, lm) differentHosts := map[string]bool{"New York": false, "Paris": false, "Montreal": false} @@ -237,9 +244,99 @@ func TestSanitizePort(t *testing.T) { } func TestSanitizeTransport(t *testing.T) { - assert.Equal(t, sanitizeTransport("udp", []string{"tcp", "udp"}), nil) - assert.Equal(t, sanitizeTransport("udp", []string{"tcp"}), errors.New("unknown transport")) - assert.Equal(t, sanitizeTransport("", []string{"tcp", "udp"}), nil) + testTable := []struct { + name string + url string + allowedTransports []string + expectedParam string + expextedErr error + }{ + { + "transport allowed", + "/dummyendpoint?tr=udp", + []string{"tcp", "udp"}, + "udp", + nil, + }, + { + "transport not allowed return error", + "/dummyendpoint?tr=udp", + []string{"tcp"}, + "udp", + errors.New("unknown value udp for query param tr"), + }, + { + "no transport parameter", + "/dummyendpoint", + []string{"tcp", "udp"}, + "", + nil, + }, + { + "uppercase transport parameter to lowercase", + "/dummyendpoint?tr=UDP", + []string{"tcp", "udp"}, + "udp", + nil, + }, + } + for _, tc := range testTable { + t.Run(tc.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, tc.url, nil) + c := e.NewContext(req, nil) + assert.Equal(t, sanitizeTransport(c, tc.allowedTransports), tc.expextedErr) + assert.Equal(t, c.QueryParam("tr"), tc.expectedParam) + }) + } +} + +func TestSanitizeType(t *testing.T) { + testTable := []struct { + name string + url string + allowedTransports []string + expectedParam string + expextedErr error + }{ + { + "transport allowed", + "/dummyendpoint?type=obfs4", + []string{"obfs4-hop", "obfs4"}, + "obfs4", + nil, + }, + { + "transport not allowed return error", + "/dummyendpoint?type=kcp", + []string{"obfs4-hop", "obfs4"}, + "kcp", + errors.New("unknown value kcp for query param type"), + }, + { + "no transport parameter", + "/dummyendpoint", + []string{"obfs4-hop", "obfs4"}, + "", + nil, + }, + { + "uppercase transport parameter to lowercase", + "/dummyendpoint?type=OBFS4-HOP", + []string{"obfs4-hop", "obfs4"}, + "obfs4-hop", + nil, + }, + } + for _, tc := range testTable { + t.Run(tc.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, tc.url, nil) + c := e.NewContext(req, nil) + assert.Equal(t, sanitizeType(c, tc.allowedTransports), tc.expextedErr) + assert.Equal(t, c.QueryParam("type"), tc.expectedParam) + }) + } } func TestSanitizeLocation(t *testing.T) { @@ -274,3 +371,52 @@ func TestSanitizeLocation(t *testing.T) { assert.Equal(t, sanitizeLocations("Paris", locationmap), nil) assert.Equal(t, sanitizeLocations("", locationmap), nil) } + +func TestCreateWeightedRandomList(t *testing.T) { + bridge1 := &m.Bridge{Location: "ny", Host: "gw1_ny"} + bridge2 := &m.Bridge{Location: "ny", Host: "gw2_ny"} + list := []*m.Bridge{bridge1, bridge2} + + bridge1First := false + bridge2First := false + for range 100 { + randomlist, err := createWeightedRandomList(list) + assert.Equal(t, nil, err) + bridge1Found := false + bridge2Found := false + for i, element := range randomlist { + // "deep" comparison of pointers content via marshalling work-around + jsonElement, err := toJson(*element) + require.NoError(t, err) + jsonBridge1, err := toJson(*bridge1) + require.NoError(t, err) + jsonBridge2, err := toJson(*bridge2) + require.NoError(t, err) + + if jsonElement == jsonBridge1 { + bridge1Found = true + if i == 0 { + bridge1First = true + } + } else if jsonElement == jsonBridge2 { + bridge2Found = true + if i == 0 { + bridge2First = true + } + } + } + assert.Equal(t, bridge1Found, true, "bridge 1 found in randomized list") + assert.Equal(t, bridge2Found, true, "bridge 2 found in randomized list") + } + assert.Equal(t, bridge1First, true, "bridge 1 at least once at first position in randomized lsit") + assert.Equal(t, bridge2First, true, "bridge 2 at least once at first position in randomized lsit") + +} + +func toJson(model any) (string, error) { + res, err := json.Marshal(model) + if err != nil { + return "", err + } + return string(res), nil +} diff --git a/pkg/weightedrand/weightedrand.go b/pkg/weightedrand/weightedrand.go new file mode 100644 index 0000000000000000000000000000000000000000..a452ca25732484c661487cf7045e0f7a1dddb6c5 --- /dev/null +++ b/pkg/weightedrand/weightedrand.go @@ -0,0 +1,82 @@ +// Package weightedrand contains a data structure and algorithm used +// to randomly select an element from some kind of list, where the chances of +// each element to be selected not being equal, but defined by relative +// "weights" (or probabilities). This is called weighted random selection. +// +// API inspied by mroth's https://github.com/mroth/weightedrand/ +// adapted to avoid duplicate picks from the source list and opptimized for +// smaller sets (up to 1e4) + +package weightedrand + +import ( + "fmt" + "math/rand" + "sort" + + "errors" +) + +// Choice is a generic wrapper that can be used to add weights for any item. +type Choice[T any] struct { + Item T + Weight int +} + +// NewChoice creates a new Choice with specified item and weight. +func NewChoice[T any](item *T, weight int) Choice[*T] { + return Choice[*T]{Item: item, Weight: weight} +} + +// A Chooser caches many possible Choices in a structure designed to improve +// performance on repeated calls for weighted random selection. +type Chooser[T any] struct { + items []Choice[T] + totalWeight int +} + +// NewChooser initializes a new Chooser for picking from the provided choices. +func NewChooser[T any](choices ...Choice[T]) (*Chooser[T], error) { + sort.Slice(choices, func(i, j int) bool { + return choices[i].Weight < choices[j].Weight + }) + chooser := Chooser[T]{items: choices} + chooser.totalWeight = 0 + for _, item := range chooser.items { + if item.Weight < 1 { + return nil, errors.New("item weight cannot be < 1") + } + chooser.totalWeight += int(item.Weight) + } + return &chooser, nil +} + +// PickRandom removes a random item from the list based on weights and returns it. +// This method is not thread safe. +func (c *Chooser[T]) PickRandom() (*T, error) { + if len(c.items) == 0 { + return nil, fmt.Errorf("no items left to pick") + } + if len(c.items) == 1 { + return &c.items[0].Item, nil + } + + // Generate a random number between 0 and totalWeight + r := rand.Intn(c.totalWeight) + + // Find the item corresponding to the random number + for i, item := range c.items { + if r < item.Weight { + // Remove the item from the list, that's ok + // within the loop since we're returning here + chosenItem := c.items[i] + c.items = append(c.items[:i], c.items[i+1:]...) + // keep total weight in sync with sum of item weights + c.totalWeight -= chosenItem.Weight + return &chosenItem.Item, nil + } + r -= item.Weight + } + + return nil, fmt.Errorf("failed to pick an item") +} diff --git a/pkg/weightedrand/weightedrand_test.go b/pkg/weightedrand/weightedrand_test.go new file mode 100644 index 0000000000000000000000000000000000000000..33d4db79822f0068200b205cf5ae9645178b14a0 --- /dev/null +++ b/pkg/weightedrand/weightedrand_test.go @@ -0,0 +1,131 @@ +package weightedrand + +import ( + "fmt" + "math" + "math/rand" + "testing" + + "github.com/jmcvetta/randutil" + mrothwr "github.com/mroth/weightedrand/v2" +) + +const BMMinChoices = 10 +const BMMaxChoices = 10_000_000 + +func BenchmarkMultiple(b *testing.B) { + for n := BMMinChoices; n <= BMMaxChoices; n *= 10 { + b.Run(fmt.Sprintf("size=%s", fmt1eN(n)), func(b *testing.B) { + wr_choices := mockChoices(b, n) + ru_choices := convertChoices(b, wr_choices) + wr_menshen_choices := convertChoicesToMenshenWeightedRand(b, wr_choices) + + b.Run("concurrency=single", func(b *testing.B) { + b.Run("lib=randutil", func(b *testing.B) { + for i := 0; i < b.N; i++ { + randutil.WeightedChoice(ru_choices) + } + }) + + b.Run("lib=mrothwr weightedrand", func(b *testing.B) { + chs, err := mrothwr.NewChooser(wr_choices...) + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + + for i := 0; i < b.N; i++ { + chs.Pick() + } + }) + + b.Run("lib=menshen weightedrand", func(b *testing.B) { + chs, err := NewChooser(wr_menshen_choices...) + if err != nil { + b.Fatal(err) + } + b.ResetTimer() + + for i := 0; i < b.N; i++ { + chs.PickRandom() + } + }) + }) + }) + } +} + +// THE SINGLE USAGE CASE IS AN ANTI-PATTERN FOR THE INTENDED USAGE OF THIS +// LIBRARY. Provide some optional benchmarks for that to illustrate the point. +func BenchmarkSingle(b *testing.B) { + if testing.Short() { + b.Skip() + } + + for n := BMMinChoices; n <= BMMaxChoices; n *= 10 { + b.Run(fmt.Sprintf("size=%s", fmt1eN(n)), func(b *testing.B) { + wr_choices := mockChoices(b, n) + ru_choices := convertChoices(b, wr_choices) + wr_menshen_choices := convertChoicesToMenshenWeightedRand(b, wr_choices) + + b.Run("lib=randutil", func(b *testing.B) { + for i := 0; i < b.N; i++ { + randutil.WeightedChoice(ru_choices) + } + }) + + b.Run("lib=weightedrand", func(b *testing.B) { + for i := 0; i < b.N; i++ { + // never actually do this, this is not how the library is used + chs, _ := mrothwr.NewChooser(wr_choices...) + chs.Pick() + } + }) + + b.Run("lib=menshen weightedrand", func(b *testing.B) { + for i := 0; i < b.N; i++ { + // never actually do this, this is not how the library is used + chs, _ := NewChooser(wr_menshen_choices...) + chs.PickRandom() + } + }) + }) + } +} + +func mockChoices(tb testing.TB, n int) []mrothwr.Choice[rune, uint] { + tb.Helper() + choices := make([]mrothwr.Choice[rune, uint], 0, n) + for i := 0; i < n; i++ { + s := '🥑' + w := rand.Intn(10) + 1 + c := mrothwr.NewChoice(s, uint(w)) + choices = append(choices, c) + } + return choices +} + +func convertChoices(tb testing.TB, cs []mrothwr.Choice[rune, uint]) []randutil.Choice { + tb.Helper() + res := make([]randutil.Choice, len(cs)) + for i, c := range cs { + res[i] = randutil.Choice{Weight: int(c.Weight), Item: c.Item} + } + return res +} + +func convertChoicesToMenshenWeightedRand[T rune](tb testing.TB, cs []mrothwr.Choice[rune, uint]) []Choice[rune] { + tb.Helper() + res := make([]Choice[rune], len(cs)) + for i, c := range cs { + res[i] = Choice[rune]{Weight: int(c.Weight), Item: c.Item} + + } + return res +} + +// fmt1eN returns simplified order of magnitude scientific notation for n, +// e.g. "1e2" for 100, "1e7" for 10 million. +func fmt1eN(n int) string { + return fmt.Sprintf("1e%d", int(math.Log10(float64(n)))) +}