diff --git a/README.md b/README.md index ef8690d..f1fb4fc 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,43 @@ gosec -exclude-dir=pkg/pb ./... ### Run ```bash - docker run -p 8080:3401 fasttrack/random grpc + docker run -p 8080:3401 -e SEED_HEX=0000000000000000000000000000000000000000000000000000000000000000 fasttrack/random grpc ``` ```bash - docker run -p 8081:3402 fasttrack/random http + docker run -p 8081:3402 -e SEED_HEX=0000000000000000000000000000000000000000000000000000000000000000 fasttrack/random http +``` + +## Other + +### Example HTTP Requests +```http + GET http://localhost:8081/getRandomFloat64 +``` +```http + GET http://localhost:8081/getRandomInt64?min=0&max=10 + + Querystring parameters: + min - minimum number (inclusive) + max - maximum number (inclusive) +``` +```http + GET http://localhost:8081/getDeterministicRandom?s=42&p=0.01,0.4,0.59 + + Querystring parameters: + (s)equence - the sequence number of the random number + (p)robabilities - the set of probabilities to select an index from +``` + +### Generating a seed +There are several sites where a hex code can be generated. + +Example: https://codebeautify.org/generate-random-hexadecimal-numbers + +Simply set 'length of hex number' to 64 and generate one. + +### Validating deterministic results +The results from function DeterministicRandom can be tested for consistency by using the simulator to generate results +and then hashing the result of two runs with the same parameters. +```bash + shasum -a 256 cmd/simulator/results/DeterministicRandom-X.csv ``` diff --git a/cmd/grpc/main.go b/cmd/grpc/main.go index 20ade55..a556d09 100644 --- a/cmd/grpc/main.go +++ b/cmd/grpc/main.go @@ -17,6 +17,13 @@ import ( ) func main() { + seed := *config.SEEDHEX + if len(seed) != 64 { + panic("seed must be 64 hex characters") + } else if seed == "0000000000000000000000000000000000000000000000000000000000000000" { + panic("a unique seed value is required") + } + recoveryOpts := []grpc_recovery.Option{ grpc_recovery.WithRecoveryHandler(func(p interface{}) (err error) { slog.Error("[PANIC] recovered panic", "error", p, "stacktrace", string(debug.Stack())) @@ -37,7 +44,7 @@ func main() { reflection.Register(s) - randomServer := NewRandomGRPCServer() + randomServer := NewRandomGRPCServer(seed) pb.RegisterRandomServer(s, randomServer) lis, errListen := net.Listen("tcp", fmt.Sprintf(":%v", *config.GRPCPort)) @@ -57,10 +64,13 @@ func main() { type RandomGRPCServer struct { pb.UnimplementedRandomServer + seed string } -func NewRandomGRPCServer() *RandomGRPCServer { - return &RandomGRPCServer{} +func NewRandomGRPCServer(seed string) *RandomGRPCServer { + return &RandomGRPCServer{ + seed: seed, + } } func (rs *RandomGRPCServer) GetRandomInt64(ctx context.Context, req *pb.GetRandomInt64Request) (*pb.GetRandomInt64Response, error) { @@ -88,3 +98,14 @@ func (rs *RandomGRPCServer) GetRandomFloat64(ctx context.Context, req *pb.GetRan Number: number, }, nil } + +func (rs *RandomGRPCServer) GetDeterministicRandom(ctx context.Context, req *pb.GetDeterministicRandomRequest) (*pb.GetDeterministicRandomResponse, error) { + number, err := random.DeterministicRandom(rs.seed, req.Sequence, req.Probabilities) + if err != nil { + return nil, err + } + + return &pb.GetDeterministicRandomResponse{ + Number: number, + }, nil +} diff --git a/cmd/http/main.go b/cmd/http/main.go index 6aed326..9d89610 100644 --- a/cmd/http/main.go +++ b/cmd/http/main.go @@ -9,12 +9,19 @@ import ( "math" "net/http" "strconv" + "strings" "time" ) func main() { - gin.SetMode(gin.ReleaseMode) + seed := *config.SEEDHEX + if len(seed) != 64 { + panic("seed must be 64 hex characters") + } else if seed == "0000000000000000000000000000000000000000000000000000000000000000" { + panic("a unique seed value is required") + } + gin.SetMode(gin.ReleaseMode) ginEngine := gin.New() ginEngine.Use(gin.Recovery()) @@ -94,6 +101,66 @@ func main() { c.String(http.StatusOK, fmt.Sprintf("%v", number)) }) + ginEngine.GET("/getDeterministicRandom", func(c *gin.Context) { + sequence := int64(0) + sequenceAsStr := c.Query("s") + if len(sequenceAsStr) == 0 { + c.String(http.StatusBadRequest, "sequence is missing") + c.Abort() + return + } else { + sequenceAsNumber, errParseInt := strconv.ParseInt(sequenceAsStr, 10, 64) + if errParseInt != nil { + c.String(http.StatusBadRequest, "unable to parse sequence as number") + c.Abort() + return + } else if sequenceAsNumber < 0 || sequenceAsNumber >= math.MaxInt64 { + c.String(http.StatusBadRequest, "sequence must be between 0 and 9,223,372,036,854,775,806") + c.Abort() + return + } else { + sequence = sequenceAsNumber + } + } + + var probabilities []float64 + probabilitiesAsStr := c.Query("p") + if len(probabilitiesAsStr) == 0 { + c.String(http.StatusBadRequest, "probabilities are missing") + c.Abort() + return + } else if len(probabilitiesAsStr) > 300 { + c.String(http.StatusBadRequest, "string of probabilities must be less than 300 characters") + c.Abort() + return + } else { + probabilitiesAsStrList := strings.Split(probabilitiesAsStr, ",") + if len(probabilitiesAsStrList) == 0 { + c.String(http.StatusBadRequest, "invalid probabilities, use comma separated list (i.e. 0.01,0.09,0.9") + c.Abort() + return + } + + for _, v := range probabilitiesAsStrList { + probability, errParse := strconv.ParseFloat(strings.TrimSpace(v), 64) + if errParse != nil { + c.String(http.StatusBadRequest, fmt.Sprintf("invalid probability: %s", strings.TrimSpace(v))) + c.Abort() + return + } + probabilities = append(probabilities, probability) + } + } + + number, errDeterministicRandom := random.DeterministicRandom(seed, sequence, probabilities) + if errDeterministicRandom != nil { + c.String(http.StatusBadRequest, errDeterministicRandom.Error()) + c.Abort() + return + } + c.String(http.StatusOK, fmt.Sprintf("%v", number)) + }) + // start server slog.Info(fmt.Sprintf("http server listening on %v", *config.HTTPPort)) errRun := ginEngine.Run(fmt.Sprintf(":%v", *config.HTTPPort)) diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index 483c22e..6121e25 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -17,6 +17,7 @@ func main() { menu.AddItem("Random Uniform Float64 with range (0,1]", "UniformFloat64") menu.AddItem("Random Uniform Int with range (min, max)", "UniformInt64") + menu.AddItem("Deterministic Random with seed and probabilities", "DeterministicRandom") menu.AddItem("Exit", "Exit") choice := menu.Display() @@ -33,24 +34,26 @@ func main() { res, errPrompt := stringPrompt("how many results do you want to generate?") if errPrompt != nil { fmt.Println("error getting results from prompt:", errPrompt) - } else { - numbersToGenerate, errParseInt := strconv.ParseInt(res, 10, 32) - if errParseInt != nil { - fmt.Println(res, "is an invalid number, try again") - } else { - fmt.Print("generating ", res, " random numbers... ") - - fileName, errGenerate := generateUniformFloat64(int32(numbersToGenerate)) - if errGenerate != nil { - fmt.Print("error: ", errGenerate) - return - } - - fmt.Println("file with results was generated:", fileName) - - break - } + continue + } + + numbersToGenerate, errParseInt := strconv.Atoi(res) + if errParseInt != nil { + fmt.Println(res, "is an invalid number, try again") + continue + } + + fmt.Print("generating ", res, " random numbers... ") + + fileName, errGenerate := generateUniformFloat64(numbersToGenerate) + if errGenerate != nil { + fmt.Print("error: ", errGenerate) + return } + + fmt.Println("file with results was generated:", fileName) + + break } case "UniformInt64": @@ -58,47 +61,113 @@ func main() { res, errPrompt := stringPrompt("how many results do you want to generate?") if errPrompt != nil { fmt.Println("error getting results from prompt:", errPrompt) - } else { - numbersToGenerate, errParseIntCount := strconv.ParseInt(res, 10, 32) - if errParseIntCount != nil { - fmt.Println(res, "is an invalid number, try again") - } else { - res, errPrompt = stringPrompt("whats the minimum number?") - if errPrompt != nil { - fmt.Println("error getting results from prompt:", errPrompt) - } else { - minimumNumber, errParseIntMin := strconv.ParseInt(res, 10, 32) - if errParseIntMin != nil { - fmt.Println(res, "is an invalid number, try again") - } else { - res, errPrompt = stringPrompt("whats the maximum number?") - if errPrompt != nil { - fmt.Println("error getting results from prompt:", errPrompt) - } else { - maximumNumber, errParseIntMax := strconv.ParseInt(res, 10, 32) - if errParseIntMax != nil { - fmt.Println(res, "is an invalid number, try again") - } else if maximumNumber <= minimumNumber { - fmt.Println("maximum cannot be less than or equal to minimum number, try again") - } else { - - fmt.Print("generating ", numbersToGenerate, " random numbers between ", minimumNumber, " and ", maximumNumber, "... ") - - fileName, errGenerate := generateUniformInt64(int32(numbersToGenerate), int32(minimumNumber), int32(maximumNumber)) - if errGenerate != nil { - fmt.Print("error: ", errGenerate) - return - } - - fmt.Println("file with results was generated:", fileName) - - break - } - } - } - } + continue + } + + numbersToGenerate, errParseIntCount := strconv.Atoi(res) + if errParseIntCount != nil { + fmt.Println(res, "is an invalid number, try again") + continue + } + + res, errPrompt = stringPrompt("whats the minimum number?") + if errPrompt != nil { + fmt.Println("error getting results from prompt:", errPrompt) + continue + } + + minimumNumber, errParseIntMin := strconv.ParseInt(res, 10, 32) + if errParseIntMin != nil { + fmt.Println(res, "is an invalid number, try again") + continue + } + + res, errPrompt = stringPrompt("whats the maximum number?") + if errPrompt != nil { + fmt.Println("error getting results from prompt:", errPrompt) + continue + } + + maximumNumber, errParseIntMax := strconv.ParseInt(res, 10, 32) + if errParseIntMax != nil { + fmt.Println(res, "is an invalid number, try again") + continue + } else if maximumNumber <= minimumNumber { + fmt.Println("maximum cannot be less than or equal to minimum number, try again") + continue + } + + fmt.Print("generating ", numbersToGenerate, " random numbers between ", minimumNumber, " and ", maximumNumber, "... ") + + fileName, errGenerate := generateUniformInt64(numbersToGenerate, int32(minimumNumber), int32(maximumNumber)) + if errGenerate != nil { + fmt.Print("error: ", errGenerate) + return + } + + fmt.Println("file with results was generated:", fileName) + + break + } + + case "DeterministicRandom": + for { + res, errPrompt := stringPrompt("how many results do you want to generate?") + if errPrompt != nil { + fmt.Println("error getting results from prompt:", errPrompt) + continue + } + + numbersToGenerate, errParseIntCount := strconv.ParseInt(res, 10, 64) + if errParseIntCount != nil { + fmt.Println(res, "is an invalid number, try again") + continue + } + + res, errPrompt = stringPrompt("what seed should be used (i.e. 9912f3bcf715a55ae5c9d47f9f6562599912f3bcf715a55ae5c9d47f9f656259)?") + if errPrompt != nil { + fmt.Println("error getting results from prompt:", errPrompt) + continue + } + + seedHex := res + if len(seedHex) != 64 { + fmt.Println("the seed needs to be 64 characters [a-f0-9], try again") + continue + } + + res, errPrompt = stringPrompt("what probabilities should be used (i.e. 0.3, 0.5, 0.2)?") + if errPrompt != nil { + fmt.Println("error getting results from prompt:", errPrompt) + continue + } + + probabilitiesAsStrings := strings.Split(res, ",") + if len(probabilitiesAsStrings) == 0 { + fmt.Println("no probabilities set, try again") + continue + } + + probabilities := make([]float64, len(probabilitiesAsStrings)) + for i, v := range probabilitiesAsStrings { + probabilities[i], err = strconv.ParseFloat(strings.TrimSpace(v), 64) + if err != nil { + fmt.Println("invalid probability, try again:", err) + continue } } + + fmt.Print("generating ", numbersToGenerate, " random numbers... ") + + fileName, errGenerate := generateDeterministicRandom(numbersToGenerate, seedHex, probabilities) + if errGenerate != nil { + fmt.Print("error: ", errGenerate) + return + } + + fmt.Println("file with results was generated:", fileName) + + break } case "Exit": @@ -127,7 +196,7 @@ func stringPrompt(label string) (string, error) { return strings.TrimSpace(s), nil } -func generateUniformFloat64(numbersToGenerate int32) (string, error) { +func generateUniformFloat64(numbersToGenerate int) (string, error) { fileName := fmt.Sprintf("cmd/simulator/results/UniformFloat64-%v.csv", time.Now().UnixMilli()) f, err := os.Create(filepath.Clean(fileName)) @@ -142,7 +211,7 @@ func generateUniformFloat64(numbersToGenerate int32) (string, error) { return "", errWriteString } - for i := int32(0); i < numbersToGenerate; i++ { + for i := 0; i < numbersToGenerate; i++ { rnd, errRnr := random.UniformFloat64() if errRnr != nil { return "", errRnr @@ -157,7 +226,7 @@ func generateUniformFloat64(numbersToGenerate int32) (string, error) { return fileName, nil } -func generateUniformInt64(numbersToGenerate int32, min int32, max int32) (string, error) { +func generateUniformInt64(numbersToGenerate int, min int32, max int32) (string, error) { fileName := fmt.Sprintf("cmd/simulator/results/UniformInt64-%v.csv", time.Now().UnixMilli()) f, err := os.Create(filepath.Clean(fileName)) @@ -172,7 +241,7 @@ func generateUniformInt64(numbersToGenerate int32, min int32, max int32) (string return "", errWriteString } - for i := int32(0); i < numbersToGenerate; i++ { + for i := 0; i < numbersToGenerate; i++ { rnd, errRnr := random.UniformInt64(min, max) if errRnr != nil { return "", errRnr @@ -186,3 +255,38 @@ func generateUniformInt64(numbersToGenerate int32, min int32, max int32) (string return fileName, nil } + +func generateDeterministicRandom(numbersToGenerate int64, seed string, probabilities []float64) (string, error) { + fileName := fmt.Sprintf("cmd/simulator/results/DeterministicRandom-%v.csv", time.Now().UnixMilli()) + + f, err := os.Create(filepath.Clean(fileName)) + if err != nil { + return "", err + } + + defer f.Close() + + _, errWriteString := f.WriteString(fmt.Sprintf("DeterministicRandom (%v %v)\n", seed, probabilities)) + if errWriteString != nil { + return "", errWriteString + } + + _, errWriteString = f.WriteString(fmt.Sprintf("SequenceNr, SelectedIndex\n")) + if errWriteString != nil { + return "", errWriteString + } + + for i := int64(0); i < numbersToGenerate; i++ { + rnd, errRnr := random.DeterministicRandom(seed, i, probabilities) + if errRnr != nil { + return "", errRnr + } + + _, errWriteString = f.WriteString(fmt.Sprintf("%v, %v\n", i, rnd)) + if errWriteString != nil { + return "", errWriteString + } + } + + return fileName, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index de9dc0a..597136e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ import ( var ( GRPCPort = flag.Int("grpc-port", 3401, "Port for gRPC server") HTTPPort = flag.Int("http-port", 3402, "Port for HTTP server") + SEEDHEX = flag.String("seed-hex", "0000000000000000000000000000000000000000000000000000000000000000", "Seed for the deterministic random number") ) func init() { diff --git a/pkg/pb/service.pb.go b/pkg/pb/service.pb.go index 063cfa8..948522a 100644 --- a/pkg/pb/service.pb.go +++ b/pkg/pb/service.pb.go @@ -197,6 +197,102 @@ func (x *GetRandomInt64Response) GetNumber() int64 { return 0 } +type GetDeterministicRandomRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sequence int64 `protobuf:"varint,1,opt,name=sequence,proto3" json:"sequence,omitempty"` + Probabilities []float64 `protobuf:"fixed64,2,rep,packed,name=probabilities,proto3" json:"probabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetDeterministicRandomRequest) Reset() { + *x = GetDeterministicRandomRequest{} + mi := &file_pkg_pb_service_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDeterministicRandomRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDeterministicRandomRequest) ProtoMessage() {} + +func (x *GetDeterministicRandomRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_service_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetDeterministicRandomRequest.ProtoReflect.Descriptor instead. +func (*GetDeterministicRandomRequest) Descriptor() ([]byte, []int) { + return file_pkg_pb_service_proto_rawDescGZIP(), []int{4} +} + +func (x *GetDeterministicRandomRequest) GetSequence() int64 { + if x != nil { + return x.Sequence + } + return 0 +} + +func (x *GetDeterministicRandomRequest) GetProbabilities() []float64 { + if x != nil { + return x.Probabilities + } + return nil +} + +type GetDeterministicRandomResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetDeterministicRandomResponse) Reset() { + *x = GetDeterministicRandomResponse{} + mi := &file_pkg_pb_service_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDeterministicRandomResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDeterministicRandomResponse) ProtoMessage() {} + +func (x *GetDeterministicRandomResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_service_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetDeterministicRandomResponse.ProtoReflect.Descriptor instead. +func (*GetDeterministicRandomResponse) Descriptor() ([]byte, []int) { + return file_pkg_pb_service_proto_rawDescGZIP(), []int{5} +} + +func (x *GetDeterministicRandomResponse) GetNumber() int64 { + if x != nil { + return x.Number + } + return 0 +} + var File_pkg_pb_service_proto protoreflect.FileDescriptor const file_pkg_pb_service_proto_rawDesc = "" + @@ -209,10 +305,16 @@ const file_pkg_pb_service_proto_rawDesc = "" + "\x03min\x18\x01 \x01(\x05R\x03min\x12\x10\n" + "\x03max\x18\x02 \x01(\x05R\x03max\"0\n" + "\x16GetRandomInt64Response\x12\x16\n" + - "\x06number\x18\x01 \x01(\x03R\x06number2\xb0\x01\n" + + "\x06number\x18\x01 \x01(\x03R\x06number\"a\n" + + "\x1dGetDeterministicRandomRequest\x12\x1a\n" + + "\bsequence\x18\x01 \x01(\x03R\bsequence\x12$\n" + + "\rprobabilities\x18\x02 \x03(\x01R\rprobabilities\"8\n" + + "\x1eGetDeterministicRandomResponse\x12\x16\n" + + "\x06number\x18\x01 \x01(\x03R\x06number2\x99\x02\n" + "\x06Random\x12U\n" + "\x10GetRandomFloat64\x12\x1f.random.GetRandomFloat64Request\x1a .random.GetRandomFloat64Response\x12O\n" + - "\x0eGetRandomInt64\x12\x1d.random.GetRandomInt64Request\x1a\x1e.random.GetRandomInt64ResponseB\x80\x01\n" + + "\x0eGetRandomInt64\x12\x1d.random.GetRandomInt64Request\x1a\x1e.random.GetRandomInt64Response\x12g\n" + + "\x16GetDeterministicRandom\x12%.random.GetDeterministicRandomRequest\x1a&.random.GetDeterministicRandomResponseB\x80\x01\n" + "\n" + "com.randomB\fServiceProtoP\x01Z,github.com/fasttrack-solutions/random/pkg/pb\xa2\x02\x03RXX\xaa\x02\x06Random\xca\x02\x06Random\xe2\x02\x12Random\\GPBMetadata\xea\x02\x06Randomb\x06proto3" @@ -228,20 +330,24 @@ func file_pkg_pb_service_proto_rawDescGZIP() []byte { return file_pkg_pb_service_proto_rawDescData } -var file_pkg_pb_service_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_pkg_pb_service_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_pkg_pb_service_proto_goTypes = []any{ - (*GetRandomFloat64Request)(nil), // 0: random.GetRandomFloat64Request - (*GetRandomFloat64Response)(nil), // 1: random.GetRandomFloat64Response - (*GetRandomInt64Request)(nil), // 2: random.GetRandomInt64Request - (*GetRandomInt64Response)(nil), // 3: random.GetRandomInt64Response + (*GetRandomFloat64Request)(nil), // 0: random.GetRandomFloat64Request + (*GetRandomFloat64Response)(nil), // 1: random.GetRandomFloat64Response + (*GetRandomInt64Request)(nil), // 2: random.GetRandomInt64Request + (*GetRandomInt64Response)(nil), // 3: random.GetRandomInt64Response + (*GetDeterministicRandomRequest)(nil), // 4: random.GetDeterministicRandomRequest + (*GetDeterministicRandomResponse)(nil), // 5: random.GetDeterministicRandomResponse } var file_pkg_pb_service_proto_depIdxs = []int32{ 0, // 0: random.Random.GetRandomFloat64:input_type -> random.GetRandomFloat64Request 2, // 1: random.Random.GetRandomInt64:input_type -> random.GetRandomInt64Request - 1, // 2: random.Random.GetRandomFloat64:output_type -> random.GetRandomFloat64Response - 3, // 3: random.Random.GetRandomInt64:output_type -> random.GetRandomInt64Response - 2, // [2:4] is the sub-list for method output_type - 0, // [0:2] is the sub-list for method input_type + 4, // 2: random.Random.GetDeterministicRandom:input_type -> random.GetDeterministicRandomRequest + 1, // 3: random.Random.GetRandomFloat64:output_type -> random.GetRandomFloat64Response + 3, // 4: random.Random.GetRandomInt64:output_type -> random.GetRandomInt64Response + 5, // 5: random.Random.GetDeterministicRandom:output_type -> random.GetDeterministicRandomResponse + 3, // [3:6] is the sub-list for method output_type + 0, // [0:3] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -258,7 +364,7 @@ func file_pkg_pb_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_pkg_pb_service_proto_rawDesc), len(file_pkg_pb_service_proto_rawDesc)), NumEnums: 0, - NumMessages: 4, + NumMessages: 6, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/pb/service.proto b/pkg/pb/service.proto index 87f7d92..5fbabd7 100644 --- a/pkg/pb/service.proto +++ b/pkg/pb/service.proto @@ -8,6 +8,7 @@ option go_package = "github.com/fasttrack-solutions/random/pkg/pb"; service Random { rpc GetRandomFloat64(GetRandomFloat64Request) returns (GetRandomFloat64Response); rpc GetRandomInt64(GetRandomInt64Request) returns (GetRandomInt64Response); + rpc GetDeterministicRandom(GetDeterministicRandomRequest) returns (GetDeterministicRandomResponse); } message GetRandomFloat64Request {} @@ -24,3 +25,12 @@ message GetRandomInt64Request { message GetRandomInt64Response { int64 number = 1; } + +message GetDeterministicRandomRequest { + int64 sequence = 1; + repeated double probabilities = 2; +} + +message GetDeterministicRandomResponse { + int64 number = 1; +} diff --git a/pkg/pb/service_grpc.pb.go b/pkg/pb/service_grpc.pb.go index 69d1228..0074fe3 100644 --- a/pkg/pb/service_grpc.pb.go +++ b/pkg/pb/service_grpc.pb.go @@ -19,8 +19,9 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - Random_GetRandomFloat64_FullMethodName = "/random.Random/GetRandomFloat64" - Random_GetRandomInt64_FullMethodName = "/random.Random/GetRandomInt64" + Random_GetRandomFloat64_FullMethodName = "/random.Random/GetRandomFloat64" + Random_GetRandomInt64_FullMethodName = "/random.Random/GetRandomInt64" + Random_GetDeterministicRandom_FullMethodName = "/random.Random/GetDeterministicRandom" ) // RandomClient is the client API for Random service. @@ -29,6 +30,7 @@ const ( type RandomClient interface { GetRandomFloat64(ctx context.Context, in *GetRandomFloat64Request, opts ...grpc.CallOption) (*GetRandomFloat64Response, error) GetRandomInt64(ctx context.Context, in *GetRandomInt64Request, opts ...grpc.CallOption) (*GetRandomInt64Response, error) + GetDeterministicRandom(ctx context.Context, in *GetDeterministicRandomRequest, opts ...grpc.CallOption) (*GetDeterministicRandomResponse, error) } type randomClient struct { @@ -59,12 +61,23 @@ func (c *randomClient) GetRandomInt64(ctx context.Context, in *GetRandomInt64Req return out, nil } +func (c *randomClient) GetDeterministicRandom(ctx context.Context, in *GetDeterministicRandomRequest, opts ...grpc.CallOption) (*GetDeterministicRandomResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetDeterministicRandomResponse) + err := c.cc.Invoke(ctx, Random_GetDeterministicRandom_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // RandomServer is the server API for Random service. // All implementations should embed UnimplementedRandomServer // for forward compatibility. type RandomServer interface { GetRandomFloat64(context.Context, *GetRandomFloat64Request) (*GetRandomFloat64Response, error) GetRandomInt64(context.Context, *GetRandomInt64Request) (*GetRandomInt64Response, error) + GetDeterministicRandom(context.Context, *GetDeterministicRandomRequest) (*GetDeterministicRandomResponse, error) } // UnimplementedRandomServer should be embedded to have @@ -80,6 +93,9 @@ func (UnimplementedRandomServer) GetRandomFloat64(context.Context, *GetRandomFlo func (UnimplementedRandomServer) GetRandomInt64(context.Context, *GetRandomInt64Request) (*GetRandomInt64Response, error) { return nil, status.Errorf(codes.Unimplemented, "method GetRandomInt64 not implemented") } +func (UnimplementedRandomServer) GetDeterministicRandom(context.Context, *GetDeterministicRandomRequest) (*GetDeterministicRandomResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetDeterministicRandom not implemented") +} func (UnimplementedRandomServer) testEmbeddedByValue() {} // UnsafeRandomServer may be embedded to opt out of forward compatibility for this service. @@ -136,6 +152,24 @@ func _Random_GetRandomInt64_Handler(srv interface{}, ctx context.Context, dec fu return interceptor(ctx, in, info, handler) } +func _Random_GetDeterministicRandom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetDeterministicRandomRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RandomServer).GetDeterministicRandom(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Random_GetDeterministicRandom_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RandomServer).GetDeterministicRandom(ctx, req.(*GetDeterministicRandomRequest)) + } + return interceptor(ctx, in, info, handler) +} + // Random_ServiceDesc is the grpc.ServiceDesc for Random service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -151,6 +185,10 @@ var Random_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetRandomInt64", Handler: _Random_GetRandomInt64_Handler, }, + { + MethodName: "GetDeterministicRandom", + Handler: _Random_GetDeterministicRandom_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "pkg/pb/service.proto", diff --git a/random.go b/random.go index f3b21d7..2d366fa 100644 --- a/random.go +++ b/random.go @@ -2,7 +2,9 @@ package random import ( "crypto/rand" + "crypto/sha256" "encoding/binary" + "encoding/hex" "errors" "fmt" "math" @@ -54,3 +56,73 @@ func Truncate(val float64, precision int) float64 { multiplier := math.Pow(10, float64(precision)) return math.Floor(val*multiplier) / multiplier } + +// DeterministicRandom creates deterministic random numbers using a seed. +// The same seed, sequence number and probabilities generate the same outcome. +func DeterministicRandom(seedHex string, sequence int64, probabilities []float64) (int64, error) { + // Validate input + if len(seedHex) != 64 { + return 0, errors.New("seedHex must be 64 bytes") + } else if sequence < 0 { + return 0, errors.New("sequence must be larger than than or equal to 0") + } else if len(probabilities) == 0 { + return 0, errors.New("probabilities must not be empty") + } + + // Decode the seed + seed, err := hex.DecodeString(seedHex) + if err != nil { + return 0, fmt.Errorf("invalid seed hex: %w", err) + } else if len(seed) != 32 { + return 0, errors.New("seed must decode to exactly 32 bytes") + } + + // Validate and sum probabilities + sum := 0.0 + for _, p := range probabilities { + if p < 0 || p > 1 { + return 0, fmt.Errorf("invalid input %v; valid range 0 <= p <= 1", p) + } + sum += p + } + + const epsilon = 1e-12 // allow for minor float faults + if math.Abs(sum-1.0) > epsilon { + return 0, fmt.Errorf("sum of probabilities %v; must be exactly 1.0", sum) + } + + // Build cumulative thresholds + thresholds := make([]uint64, len(probabilities)) + cumulative := 0.0 + for i, p := range probabilities { + cumulative += p + if i == len(probabilities)-1 { + thresholds[i] = math.MaxUint64 // ensure full coverage + } else { + thresholds[i] = uint64(cumulative * math.Pow(2, 64)) + } + } + + // Compute random number + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], uint64(sequence)) + + h := sha256.New() + h.Write(seed) + h.Write(buf[:]) + hash := h.Sum(nil) + x := binary.BigEndian.Uint64(hash[:8]) + + // Find the selected index + for i, t := range thresholds { + if x < t { + if i < math.MinInt64 || i > math.MaxInt64 { + return 0, fmt.Errorf("threshold index out of range for Int32") + } + return int64(i), nil + } + } + + // Should never happen if sum == 1.0 + return 0, errors.New("unexpected: no prize selected despite sum == 1.0") +} diff --git a/random_test.go b/random_test.go index 2495267..727959d 100644 --- a/random_test.go +++ b/random_test.go @@ -59,3 +59,50 @@ func Test_Truncate(t *testing.T) { number = Truncate(0.1234567891, 9) assert.Equal(t, 0.123456789, number) } + +func Test_DeterministicRandom(t *testing.T) { + testCases := []struct { + seedHex string + sequence int64 + probabilities []float64 + expectedIndex int64 + }{ + { + seedHex: "9912f3bcf715a55ae5c9d47f9f6562599912f3bcf715a55ae5c9d47f9f656259", + sequence: 0, + probabilities: []float64{0.2, 0.2, 0.2, 0.2, 0.2}, + expectedIndex: 0, + }, + { + seedHex: "9912f3bcf715a55ae5c9d47f9f6562599912f3bcf715a55ae5c9d47f9f656259", + sequence: 1, + probabilities: []float64{0.2, 0.2, 0.2, 0.2, 0.2}, + expectedIndex: 2, + }, + { + seedHex: "9912f3bcf715a55ae5c9d47f9f6562599912f3bcf715a55ae5c9d47f9f656259", + sequence: 2, + probabilities: []float64{0.2, 0.2, 0.2, 0.2, 0.2}, + expectedIndex: 1, + }, + { + seedHex: "0000000000000000000000000000000000000000000000000000000000000000", + sequence: 0, + probabilities: []float64{0.1, 0.9}, + expectedIndex: 1, + }, + + { + seedHex: "9912f3bcf715a55ae5c9d47f9f6562599912f3bcf715a55ae5c9d47f9f656259", + sequence: 9, + probabilities: []float64{0.3, 0.5, 0.2}, + expectedIndex: 2, + }, + } + + for _, testCase := range testCases { + selectedIndex, err := DeterministicRandom(testCase.seedHex, testCase.sequence, testCase.probabilities) + assert.Nil(t, err) + assert.Equal(t, testCase.expectedIndex, selectedIndex) + } +}