diff --git a/config.json b/config.json index 058b1d03..1bb72fdf 100644 --- a/config.json +++ b/config.json @@ -675,6 +675,14 @@ "prerequisites": [], "difficulty": 5 }, + { + "slug": "robot-name", + "name": "Robot Name", + "uuid": "88eac1aa-a4ae-405a-88ab-0dedd5ac7ae2", + "practices": [], + "prerequisites": [], + "difficulty": 5 + }, { "slug": "acronym", "name": "Acronym", diff --git a/config/generator_macros.j2 b/config/generator_macros.j2 index 1d9b779d..94a0ce20 100644 --- a/config/generator_macros.j2 +++ b/config/generator_macros.j2 @@ -29,6 +29,8 @@ app [main] { json: "https://github.com/lukewilliamboswell/roc-json/releases/download/0.10.2/FH4N0Sw-JSFXJfG3j54VEDPtXOoN-6I9v_IA8S18IGk.tar.br" {%- elif name == "parser" -%} parser: "https://github.com/lukewilliamboswell/roc-parser/releases/download/0.7.2/1usTzOOACTpnkarBX0ED3gFESzR4ROdAlt1Llf4WFzo.tar.br" + {%- elif name == "rand" -%} + rand: "https://github.com/lukewilliamboswell/roc-random/releases/download/0.3.0/hPlOciYUhWMU7BefqNzL89g84-30fTE6l2_6Y3cxIcE.tar.br" {%- endif -%} {%- endfor -%} {%- endif %} diff --git a/exercises/practice/robot-name/.docs/instructions.md b/exercises/practice/robot-name/.docs/instructions.md new file mode 100644 index 00000000..fca3a41a --- /dev/null +++ b/exercises/practice/robot-name/.docs/instructions.md @@ -0,0 +1,14 @@ +# Instructions + +Manage robot factory settings. + +When a robot comes off the factory floor, it has no name. + +The first time you turn on a robot, a random name is generated in the format of two uppercase letters followed by three digits, such as RX837 or BC811. + +Every once in a while we need to reset a robot to its factory settings, which means that its name gets wiped. +The next time you ask, that robot will respond with a new random name. + +The names must be random: they should not follow a predictable sequence. +Using random names means a risk of collisions. +Your solution must ensure that every existing robot has a unique name. diff --git a/exercises/practice/robot-name/.meta/Example.roc b/exercises/practice/robot-name/.meta/Example.roc new file mode 100644 index 00000000..473cf6af --- /dev/null +++ b/exercises/practice/robot-name/.meta/Example.roc @@ -0,0 +1,89 @@ +module [createFactory, createRobot, boot, reset, getName, getFactory] + +import rand.Random + +## A factory is used to create robots, and hold state such as the existing robot +## names and the current random state +Factory := { + existingNames : Set Str, + state : Random.State, +} + +## A robot must either have no name or a name composed of two letters followed +## by three digits +Robot := { + maybeName : Result Str [NoName], + factory : Factory, +} + +createFactory : { seed : U32 } -> Factory +createFactory = \{ seed } -> + @Factory { state: Random.seed seed, existingNames: Set.empty {} } + +createRobot : Factory -> Robot +createRobot = \factory -> + @Robot { maybeName: Err NoName, factory } + +boot : Robot -> Robot +boot = \robot -> + when robot |> getName is + Ok _ -> robot + Err NoName -> robot |> generateRandomName + +reset : Robot -> Robot +reset = \robot -> + resetRobot = + when robot |> getName is + Err NoName -> robot + Ok nameToRemove -> + factory = robot |> getFactory |> removeName nameToRemove + @Robot { maybeName: Err NoName, factory } + + resetRobot |> boot + +getName : Robot -> Result Str [NoName] +getName = \@Robot { maybeName } -> + maybeName + +getFactory : Robot -> Factory +getFactory = \@Robot { factory } -> + factory + +generateRandomName : Robot -> Robot +generateRandomName = \@Robot { maybeName, factory } -> + (@Factory { state, existingNames }) = factory + { updatedState, string: twoLetters } = randomString { state, generator: Random.boundedU32 'A' 'Z', length: 2 } + { updatedState: updatedState2, string: threeDigits } = randomString { state: updatedState, generator: Random.boundedU32 '0' '9', length: 3 } + possibleName = "$(twoLetters)$(threeDigits)" + + if existingNames |> Set.contains possibleName then + numberOfPossibleNames = 26 * 26 * 10 * 10 * 10 + if existingNames |> Set.len == numberOfPossibleNames then + # better crash than run into an infinite loop + crash "Too many robots, we have run out of possible names!" + else + updatedFactory = @Factory { existingNames, state: updatedState2 } + generateRandomName (@Robot { maybeName, factory: updatedFactory }) + else + updatedFactory = @Factory { + existingNames: existingNames |> Set.insert possibleName, + state: updatedState2, + } + @Robot { maybeName: Ok possibleName, factory: updatedFactory } + +removeName : Factory, Str -> Factory +removeName = \@Factory { state, existingNames }, robotName -> + @Factory { state, existingNames: existingNames |> Set.remove robotName } + +randomString : { state : Random.State, generator : Random.Generator U32, length : U64 } -> { updatedState : Random.State, string : Str } +randomString = \{ state, generator, length } -> + List.range { start: At 0, end: Before length } + |> List.walk { state, characters: [] } \walk, _ -> + random = generator walk.state + updatedState = random.state + characters = walk.characters |> List.append (random.value |> Num.toU8) + { state: updatedState, characters } + |> \{ state: updatedState, characters } -> + when characters |> Str.fromUtf8 is + Ok string -> { updatedState, string } + Err (BadUtf8 _ _) -> crash "Unreachable: characters are all ASCII" diff --git a/exercises/practice/robot-name/.meta/config.json b/exercises/practice/robot-name/.meta/config.json new file mode 100644 index 00000000..2e9fddc8 --- /dev/null +++ b/exercises/practice/robot-name/.meta/config.json @@ -0,0 +1,18 @@ +{ + "authors": [ + "ageron" + ], + "files": { + "solution": [ + "RobotName.roc" + ], + "test": [ + "robot-name-test.roc" + ], + "example": [ + ".meta/Example.roc" + ] + }, + "blurb": "Manage robot factory settings.", + "source": "A debugging session with Paul Blackwell at gSchool." +} diff --git a/exercises/practice/robot-name/RobotName.roc b/exercises/practice/robot-name/RobotName.roc new file mode 100644 index 00000000..e64f8510 --- /dev/null +++ b/exercises/practice/robot-name/RobotName.roc @@ -0,0 +1,47 @@ +module [createFactory, createRobot, boot, reset, getName, getFactory] + +import rand.Random + +## A factory is used to create robots, and hold state such as the existing robot +## names and the current random state +Factory := { + # TODO: change this opaque type however you need + todo1 : U64, + todo2 : U64, + todo3 : U64, + # etc. +} + +## A robot must either have no name or a name composed of two letters followed +## by three digits +Robot := { + # TODO: change this opaque type however you need + todo4 : U64, + todo5 : U64, + todo6 : U64, + # etc. +} + +createFactory : { seed : U32 } -> Factory +createFactory = \{ seed } -> + crash "Please implement the 'createFactory' function" + +createRobot : Factory -> Robot +createRobot = \factory -> + crash "Please implement the 'createRobot' function" + +boot : Robot -> Robot +boot = \robot -> + crash "Please implement the 'boot' function" + +reset : Robot -> Robot +reset = \robot -> + crash "Please implement the 'reset' function" + +getName : Robot -> Result Str _ +getName = \robot -> + crash "Please implement the 'getName' function" + +getFactory : Robot -> Factory +getFactory = \robot -> + crash "Please implement the 'getFactory' function" diff --git a/exercises/practice/robot-name/robot-name-test.roc b/exercises/practice/robot-name/robot-name-test.roc new file mode 100644 index 00000000..82f12357 --- /dev/null +++ b/exercises/practice/robot-name/robot-name-test.roc @@ -0,0 +1,227 @@ +app [main] { + pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.15.0/SlwdbJ-3GR7uBWQo6zlmYWNYOxnvo8r6YABXD-45UOw.tar.br", + rand: "https://github.com/lukewilliamboswell/roc-random/releases/download/0.3.0/hPlOciYUhWMU7BefqNzL89g84-30fTE6l2_6Y3cxIcE.tar.br", +} + +main = + Task.ok {} + +import RobotName exposing [createFactory, createRobot, boot, reset, getName, getFactory] + +### Let's start by testing the basic robot workflow + +# A new robot must not have a name +expect + factory = createFactory { seed: 0 } + robot = factory |> createRobot + result = robot |> getName + result |> Result.isErr + +# After the first boot, a robot must have a name +expect + factory = createFactory { seed: 0 } + robot = factory |> createRobot |> boot + result = robot |> getName + result |> Result.isOk + +# Rebooting a robot should leave its name unchanged +expect + factory = createFactory { seed: 0 } + robot = factory |> createRobot |> boot + name1 = robot |> getName + name2 = robot |> boot |> getName + name1 == name2 + +# After it is factory reset (which also reboots), a robot must have a name +expect + factory = createFactory { seed: 0 } + robot = factory |> createRobot |> boot |> reset + result = robot |> getName + result |> Result.isOk + +# After it is factory reset, a robot must have a new name. If by chance it +# is the same (you should buy a lottery ticket today), we can try again to get +# a new name. If it's the same again we can be pretty confident that there's a +# problem. +expect + factory = createFactory { seed: 0 } + robot = factory |> createRobot |> boot + name1 = robot |> getName + name2 = robot |> reset |> getName + name3 = robot |> reset |> reset |> getName + name1 != name2 || name1 != name3 + +# If you factory reset a new robot, since this includes a boot, the robot +# should have a name +expect + factory = createFactory { seed: 0 } + robot = factory |> createRobot |> reset + result = robot |> getName + result |> Result.isOk + +# Once created, a robot's name must be 5 characters long +expect + factory = createFactory { seed: 0 } + robot = factory |> createRobot |> boot + result = robot |> getName |> Result.try \n -> n |> Str.toUtf8 |> List.len |> Ok + result == Ok 5 + +### Next we will try to ensure that the random names are sufficiently diverse. +### For this, we will first create many robot names. + +## Create many robots using a given random seed, and return their names +## encoded using Str.toUtf8. +## The default quantity is 1,000, which is enough to offer strong statistical +## guarantees in the tests below, for example the probability that any letter +## or digit is absent from all names is negligible. +generateRobotNames : { seed : U32, quantity ? U64 } -> List (List U8) +generateRobotNames = \{ seed, quantity ? 1000 } -> + factory = createFactory { seed } + List.range { start: At 0, end: Before quantity } + |> List.walk { names: [], factory } \state, _ -> + robot = state.factory |> createRobot |> boot + nameUtf8 = + when robot |> getName is + Ok name -> name |> Str.toUtf8 + Err NoName -> crash "A robot must have a name after the first boot" + { + names: state.names |> List.append nameUtf8, + factory: robot |> getFactory, + } + |> .names + +## many random robot names based on seed 0 +manyNames0 : List (List U8) +manyNames0 = generateRobotNames { seed: 0 } + +## many random robot names based on seed 1 +manyNames1 : List (List U8) +manyNames1 = generateRobotNames { seed: 1 } + +## The set of letters from 'A' to 'Z' +capitalLetters : Set U8 +capitalLetters = List.range { start: At 'A', end: At 'Z' } |> Set.fromList + +# The first character of a robot's name must range from 'A' to 'Z' +expect + result = manyNames0 |> List.mapTry \names -> names |> List.get 0 + when result is + Ok chars -> Set.fromList chars == capitalLetters + Err OutOfBounds -> Bool.false + +# The second character must also range from 'A' to 'Z' +expect + result = manyNames0 |> List.mapTry \names -> names |> List.get 1 + when result is + Ok chars -> Set.fromList chars == capitalLetters + Err OutOfBounds -> Bool.false + +## The set of digits from '0' to '9' +digits : Set U8 +digits = List.range { start: At '0', end: At '9' } |> Set.fromList + +# The third character must range from '0' to '9' +expect + result = manyNames0 |> List.mapTry \names -> names |> List.get 2 + when result is + Ok chars -> Set.fromList chars == digits + Err OutOfBounds -> Bool.false + +# The fourth character must range from '0' to '9' +expect + result = manyNames0 |> List.mapTry \names -> names |> List.get 3 + when result is + Ok chars -> Set.fromList chars == digits + Err OutOfBounds -> Bool.false + +# The fifth character must range from '0' to '9' +expect + result = manyNames0 |> List.mapTry \names -> names |> List.get 4 + when result is + Ok chars -> Set.fromList chars == digits + Err OutOfBounds -> Bool.false + +# The same seed must generate the same robot names +expect + newNames0 = generateRobotNames { seed: 0 } + manyNames0 == newNames0 + +# Different seeds must generate different robot names (to be precise, it's +# technically possible for the two lists to be identical, but the probability +# is negligible when the lists are long enough). +expect manyNames0 != manyNames1 + +# All robot names coming from the same factory must be unique +expect + uniqueNames = manyNames0 |> Set.fromList + numberOfNames = manyNames0 |> List.len + numberOfUniqueNames = uniqueNames |> Set.len + numberOfNames == numberOfUniqueNames + +### Finally, we will try to ensure that the characters are not linearly +### correlated within each name or across consecutive names. This does not +### guarantee that the names are truly random, but at least it should rule out +### many types of non-random sequences (e.g., such as simply incrementing a +### counter). + +## Convert a list of integers to F64s +toFloats : List (Num *) -> List F64 +toFloats = \numbers -> + numbers |> List.map Num.toF64 + +## The R² correlation coefficient, also known as the coefficient of determination, +## measures the degree of linear correlation between two lists of numbers. +## It ranges from -∞ to +1.0. +## When both lists are strongly linearly correlated, R² approaches +1.0. +## When both lists are long and independently drawn from the same random +## distribution, R² approaches -1.0. +r2Coeff : List F64, List F64 -> F64 +r2Coeff = \numbers1, numbers2 -> + length = numbers1 |> List.len |> Num.toF64 + mean = numbers1 |> List.sum |> Num.div length + subtractMean = \val -> val - mean + square = \val -> val * val + # Total sum of squares (TSS) + tss = numbers1 |> List.map subtractMean |> List.map square |> List.sum + # Residual sum of squares (RSS) + rss = numbers1 |> List.map2 numbers2 Num.sub |> List.map square |> List.sum + epsilon = 1e-10 # to avoid division by zero + 1.0 - rss / (tss + epsilon) + +# To speed up the correlation tests, we truncate the list of names +correlationSampleSize = 200 + +# It's not impossible for the random characters to be correlated by chance, +# but given 200 letters or digits, the probability that the correlation +# coefficient ends up greater than this threshold is negligible +r2Threshold = -0.25 + +seemsIndependentEnoughFrom = \maybeChars1, maybeChars2 -> + when (maybeChars1, maybeChars2) is + (Ok chars1, Ok chars2) -> + r2Coeff (chars1 |> toFloats) (chars2 |> toFloats) < r2Threshold + + _ -> Bool.false # unreachable if names are 5 chars long + +# Characters within a name should not be correlated +expect + truncatedNames0 = manyNames0 |> List.takeFirst correlationSampleSize + [0, 1, 2, 3, 4] + |> List.joinMap \index1 -> [0, 1, 2, 3, 4] |> List.map \index2 -> (index1, index2) + |> List.dropIf \(index1, index2) -> index1 == index2 + |> List.all \(index1, index2) -> + maybeChars = truncatedNames0 |> List.dropLast 1 |> List.mapTry \chars -> chars |> List.get index1 + maybeCharsNext = truncatedNames0 |> List.dropFirst 1 |> List.mapTry \chars -> chars |> List.get index2 + maybeChars |> seemsIndependentEnoughFrom maybeCharsNext + +# Characters in consecutive names should not be correlated +expect + # we truncate the list to speed up the tests + truncatedNames0 = manyNames0 |> List.takeFirst correlationSampleSize + truncatedNames1 = manyNames0 |> List.dropFirst 1 |> List.takeFirst correlationSampleSize + [0, 1, 2, 3, 4] + |> List.joinMap \index1 -> [0, 1, 2, 3, 4] |> List.map \index2 -> (index1, index2) + |> List.all \(index1, index2) -> + maybeChars = truncatedNames0 |> List.mapTry \chars -> chars |> List.get index1 + maybeCharsNext = truncatedNames1 |> List.mapTry \chars -> chars |> List.get index2 + maybeChars |> seemsIndependentEnoughFrom maybeCharsNext