CS152 Homework: Standard ML Modules

Due Tuesday, May 1 at 11:59 PM

The purpose of this assignment is fourfold:

You will complete problems 1-2 and problems A-C.


ML Modules finger exercises

  1. [10 points] A simple functor. In a lecture on Haskell type classes, we talked about QuickCheck and how it generates random data. This capability can be duplicated using ML modules.

    1. Define a signature ARBITRARY with a type t and an operation arbitrary that can generate arbitrary data of type t.

    2. Write a functor ArbitraryList that takes one argument A with signature ARBITRARY and produces a new structure that also has signature ARBITRARY, but in which the arbitrary operation produces an arbitrary list of type A.t list. You will need to come up with appropriate definitions of both t and arbitrary.

      Hints:

      • Don't overlook http://www.standardml.org/Basis, especially List.tabulate. Also try help "lib"; in Moscow ML; you may find Random.range useful.
      • If you want to test your functor, define a structure ArbitraryInt : ARBITRARY.

    3. Is this design a good use of the modules system, or would we be better off solving this problem in the core language (without modules)? Argue one side or the other.

    Put your signature, functor, and argument (in comments) in file [[arbitrary.sml]].

  2. [20 points] Data structures. A graph is a collection of nodes and edges. Each edge connects exactly two nodes---or may connect a node with itself.
    1. Design an abstraction for representing graphs in ML. Your abstraction may be mutable or immutable.

      Formalize your abstraction by giving an ML signature [[GRAPH]] describing the abstraction. Be sure to

      • Identify each operation as a creator, producer, mutator, or observer
      • Specify what each operation does, either using informal English, equational reasoning, or both
      Each node must carry a label; whether you wish to label edges is up to you. In any case, leave the types of labels abstract.

      Put your signature into a file called graph-sig.sml. You will need to compile it with the -toplevel option, e.g.,

      mosmlc -toplevel -c graph-sig.sml
      
      Notice that for this part of the problem you write no code. All you write is the interface.

    2. Use your abstraction to implement topological sort. That is, write a functor that takes a structure matching signature [[GRAPH]] and produces a structure that contains a function that takes a graph and returns a topologically sorted list of node labels (or raises a suitable exception).
      • Give your functor an explicit result signature, paying careful attention to type revelation.
      • If you are not familiar with topological sort, there is a version in uML online.
      • You need not implement [[GRAPH]]. This is the whole point!
      Put your signature into a file called toposort.sml. You will need to compile it with the -toplevel option, e.g.,
      mosmlc -toplevel -c toposort.sml
      

Playing Adversary Games

In problems A-C below, you will implement and use a system for playing simple adversary games. The program will show game configurations, accept moves from the user and choose the best move. Additionally, we will have a function that will help us find out if the program is unbeatable.

The system is based on an abstract game solver (AGS) which, given a description of the rules of the game, will be able to select the best move in a particular configuration. An AGS is obtained by abstracting (separating) the details of a particular game from the details of the solving procedure. As the procedure is based on exhaustive searching (trying all valid moves and selecting the best), it is general enough that we can abstract it. If the solving procedure were highly dependent on the game, such a separation would not be easy to make.

An important issue in such a modular design is to determine the interface between the AGS and the particular game. Such an interface is specified using the SML signature [[GAME]]. This signature declares all the data and functions that an AGS must know about the game in order to do its job. We must be careful to make the signature general enough to cover a wide variety of games. Even details like ``the players take turns'' are considered to be part of the game and not the AGS and will therefore be hidden in the game abstraction. You could even implement a solitaire as a ``two-player'' game in which the second player never gets a turn!

The idea behind the AGS is that it tries all possible moves. For each move, it takes the resulting configuration and tries all possible moves for the adversary, and so on. It assumes at each level that the player plays perfectly, that is, he plays the move most likely to result in a win.

This method (``exhaustive search'') is suitable only for very small games. Nobody would use it for a game like chess, for example. Nevertheless, variations of this idea are used successfully even for chess; the idea is to stop or ``prune'' the search before it goes too far. You may have seen the ``alpha-beta'' pruning strategy in CS 51. We won't worry about these variations; we will just implement the basic algorithm.

You will use two-player games in the last three parts of this assignment: implement a particular game, exercise the AGS on that game, and implement an AGS of your own.

Players and Outcomes

We start by describing a few basic functions which are independent of the game but related to the fact that there are two adversaries. The specifications for these functions are given by the signature [[PLAYER]] and their implementation is given to you in a structure [[Player]]. <>= signature PLAYER = sig datatype player = X | O (* 2 players called X and O *) datatype outcome = WINS of player | TIE (* Returns the other player *) val otherplayer : player -> player (* Pretty print. Convert a value to a * printable representation *) val toString : player -> string val outcomeToString : outcome -> string end @ %def PLAYER player outcome otherplayer Player.toString outcomeToString Here's the implementation: <>= structure Player : PLAYER = struct datatype player = X | O datatype outcome = WINS of player | TIE fun otherplayer X = O | otherplayer O = X (* Returns the name of a player *) fun toString X = "X" | toString O = "O" (* The name of the winner *) fun outcomeToString TIE = "Tie" | outcomeToString (WINS p) = toString p ^ " wins" end @ %def Player Although it might seem overly pedantic, we prefer to isolate details like the player names and how to convert them to a printable representation. Whenever you refer to a player in your code you will have to use one of the following notations: [[Player.otherplayer p]] or [[Player.X]] or [[Player.O]] or [[Player.WINS p]]. Note that the last three expressions can also be used as patterns.

Specification of a Game

Next we describe the specification of a game (the information our AGS needs to know about the game in order to be able to play it ``intelligently''). Such a description must match the signature [[GAME]]. Read the comments for each type and each value in the signature. <>= signature GAME = sig structure Move : sig (* information related to moves *) eqtype move (* A move (probably a set of coordinates) *) exception Move (* Raised (by makemove, fromString) for invalid moves *) val fromString : string -> move (* converts a string to a move; If the string does not correspond to a valid move, fromString raises Move *) val prompt : Player.player -> string (* Given a player, return a request for a move for that player *) val toString : Player.player -> move -> string (* Returns a short message describing a move. Example: "Player X moves to ..." *) end type config (* A representation for a game configuration. It must include a full description of the state of a game at a particular moment, including keeping track of whose turn it is to move *) val toString : config -> string (* Returns an ASCII representation of the configuration. It must show whose turn it is *) val initial : Player.player -> config (* Initial configuration for a game when "player" is the one to start. We need the parameter because the configuration includes the player to move *) val whoseturn : config -> Player.player (* Extracts the player whose turn is to move from a configuration. We need this because occasionally the solver needs to know whose turn is and it cannot extract this information from the configuration by itself because it doesn't know the layout of a configuration *) val makemove: config -> Move.move -> config (* Changes the configuration by making a move. The player making the move is encoded in the configuration. Be sure that the new configuration knows who is to move. *) val outcome : config -> Player.outcome option (* Returns the outcome of the game, or if the game isn't over (the configuration is not a final one), returns NONE *) val finished : config -> bool (* true if the configuration is final. This might be because everybody is stuck (Tie) or because one has won *) val possmoves : config -> Move.move list (* A list of possible moves in a given configuration. ONLY final configurations might return nil. This means that a configuration which is not final MUST have some possible moves *) end @ %def GAME initial whoseturn makemove outcome finished possmoves Move.toString @ %def toString Move.fromString move Move Move.prompt

Compiling Standard ML modules using Moscow ML

To compile an individual module using Moscow ML, you type
mosmlc -c -toplevel filename.sml
This puts compiler-interface information into filename.ui and implementation information into filename.uo. Perhaps surprisingly, either a signature or a structure will produce both .ui and .uo files. This behavior is an artifact of the way Moscow ML works; it should not alarm you.

Once you have compiled a bunch of modules, you can make an executable binary using mosmlc. Here is an example of a command line I use on my system to build an interactive game player:

  mosmlc -toplevel -o games player-sig.uo player.uo game-sig.uo \
                            ags-sig.uo play-sig.uo slickttt.uo ttt.uo \
                            ags.uo aggress.uo nim.uo four.uo peg.uo mrun.uo
Order does matter here; for example, I have to put player.uo after player-sig.uo because the Player structure defined in player.sml uses the PLAYER signature defined in player-sig.sml.

When you are debugging, it is tremendously useful to get these compiled modules into the interactive system. You do this with the Moscow ML load function. I have an example use of load in Part B. Remember that if you recompile, you must exit Moscow ML and start over. Once a module is loaded, loading it again has no effect, even if the code has changed.


Implement Tic-Tac-Toe

A. [35 points] Implement the description for ``Tic-Tac-Toe.'' More precisely, implement a module [[TTT]] matching signature [[GAME]] that describes Tic-Tac-Toe. If you are unfamiliar with Tic-Tac-Toe, you can find an explanation at the end of this assignment. Call your structure [[TTT]], put it in the file [[ttt.sml]], and use the following pattern : <