The goal of this project is to create a turing machine in OCaml, a functional programming language as well as write a few simple programs that can be run on said turing machine.
To get things started, we need to install Opam which is the package manager for OCaml
I studied this book for basic functional programming paradigm and the official docs for workflow and technical details.
The file terminology is closely similar to C; .mli
files are sources for interfaces, like .h
, it will produce a .cmi
object file on compilation. .ml
files are sources for implementations, like .c
, it will produce a .cmx
object file on compilation. File with other extensions are considered native C files.
We will be using manual compilation for this project, so build tools like dune are forbidden. For manual compilations using ocamlc or ocamlopt, the order of the source file and object files matter when compiling. Which means if codeA is dependent on codeB, codeB needs to be compiled first. The general compilation commands are like so
If we wanted to use external libraries installed by opam, we nee to surround our compalation commands with ocamlfind and with different flags for compiling and linking.
I have structured my project in the following structure; All sources and modules will be inside the srcs
directory
The makefile will be something simple like this to get things going
The .SUFFIXES rule is to let Makefile recognise our Ocaml file suffixes, which is not in the known suffixes by default .c, cpp, .o ...
.
The most basic unit of computation. A machine or tool with limited memory and power which can be used to compute.
A FSM can be represented via a graph.
States (nodes) - the mode the machine is in
Transitions (edges) - the action of switching between states using symbols
a
is a starting state
d
is an ending/accepting state
{0, 1}
are symbols to define transitions
The FSM describe above can be used to do 2 things:
A formal definition of a FSM would be using a 5-tuple
In the particular example above, the user inputs can be considered as languages.
Above is an example of a FSM that accepts strings that has 0011 in it.
Above is an example of a FSM that accepts string which accepts strings that dont have 0011 in it.
To represent the machines language in formal definition, it can be done like so
since the languages are sets, we can say that
Consider the FSM below :
As we can see, the strings which are to be accepted are 10, 01, 001, 0001, 00001 .....
. However, it does not handle behaviour like 100
explicitly.
In the machine above, there are dead states (undefined transitions). Those are declared implicitly, and once a machine goes into a dead state, it stays in a dead state.
With that said, the formal definition of computation is
Let w = w1w2w3w4....wn
be a string where wi
is a part of symbols. And M
accepts w
is there is a sequence of states
As known as, M recognizes language A if A = {w | M accepts W}
Since finite state machines have limited memory, it is impossible for it to process languages which needs memory to be accepted / rejected. such impossible languages are languages which need to have matching parenthesis, languages which needs to match number of characters and so on. Regular languages are languages which can be represented by at least 1 Finite State Machine.
Such languages are also closed in operations with operations like union, concatnation and star. This means the result of thos operations (union, concatnation and star) on regular language will be a regular language also.
This is proven by the ability to construct a FSM with the combinations of the 2 FSM where the new FSM is able to recognize the language of the result of the operation.
Such a machine is possible and valid, hence the proof is complete.
A regular expression is something used to express / represent valid strings in regular languages. For example, if we have a symbol set Σ = {'a', 'b', 'c', 'd'}
, the regex a(b|c)d
will generate the strings {abd, acd}
. There are a few general nonataions with set definitions;
The pumping lemme is a way to determine if a language is a regular language or not. The key idea is that if a FSM has a cycle, you can go around the cycle once or multiple times and the strings generated / inputted should be also in the same language (accepted)
All strings are in the form S = x(iy)z
where i > 0
.
If A
is a regular language and |s| >= P (Pumping length, strings that are longer than this will have a cycle)
. The string can be devided into s = xyz
Such that y
is repeatable and the resulting string is still in the language.
On a non-regular language like B={0^n1^n | n >= 0}
and suppose the pumping length is 7, a valid string in this language would be 00000001111111
. If the language is regular, we can split the string above into 3 parts. Suppose the y
part is 000111
in the middle. If we duplicate the y
part once, we will result in 00000001110001111111
, which is not accepted by the language, which contradicts the regular language rule.
The above example also will have similar results if we move y somewhere else, 0000000
000
1111111
and 0000000
111
1111111
are all invalid strings of the language.
A formal grammar describes how to form strings from an alphabet (symbol) from a language. A Context Free Grammar (CFG) is a formal grammar whose production rules can be applied to a non-terminal symbol regard;ess of context. They can generate Context Free Languages (CFL)
For example, for the CFG E -> T + Fi
Given the CFG below,
To derive the language starting from the symbol E;
We pick the leftmost variable to expand first, the effect will be the same if we picked the right most variable. The derivation can also be written as
E =>* a + (a x a)
.
Just like Regular languages, there is a pumping lemma for CFLs. Instead of a loop in the FSM, the loop will be occuring in the non terminal at the parse tree.
A computing model like FSM just that it also interacts with stack memory. It is by design non-deterministic since transitions may also depend on the stack top and may push to the stack. It recognizes CFLs.
FSM transition
PDA transition
It is not possible to pop an emtpy stack
Formal definition
A model of computation like FSA and PDA, however it is more powerful and is a model for modern computers (turing complete). It recognizes Decidable languages or Turing recognizable languages.
The input for a TM comes in the form of a tape with symbols on it like so
The underlying FSM operates like so
In my OCaml project, I have installed some external dependencies, yojson
for JSON read / write and spectrum
for terminal color and fomratting.
The way yojson works by handling JSON data structures is like so.
For example, our unary_sub
machine is typed by yojson like so :
The logic to tokenize the machine definition is consutrcted like so
The mentioned struct will look something like this, stored as an OCaml record. Note that this struct is NOT the machine definition, just the configuration of the machine.
The definition of a Turing machine is like so According to wiki
Since we have a configuration defined, we have to create a machine with the defnition above based on the defined configuration. The resulting record shape to transform is to
As we can see, most of the data can be just moved from the configuration object, however for the transition table, we need to do further processing to be able to parse that. for the transitions that are defined like so
We can execute the following logic to construct a transition table based on the input. first of all, we can define a type that represents a transition table row / (scan_write, erase_one) and then map the rows with values that define that transition (read, to_state, write and action).
We can define each individual transisiton value and each state transition (contains multiple transition values) like so
With the types above, we can construct a transition table by doing the following
The function that creates a transition table entry for each element will
As we can see now, we are able to parse the machine and display its information
Now we have parsed the machine as the input, the second input left to process is the tape input. This one is simple, we can represent the entire tape structure like so
We will also have a Tape.create
function that accepts / parses the input user argument, validates that the symbols are in the machines alphabet and create an instance of a srtucture above. We will also have functions to
Once those functions are implemented, we can see the representation of the same tape, moved in different directions.
This part is where the turing machine starts executing the commands entered by the user on the machine defined.
This helpful resource mentioned how a turing machine should evaluate the code in terms of programming. Notice the simulator section of the image below, it is quite similar to what we are tring to do now.
The first thing to do is to trace the current state, which we need to obtain from the machine definitions using the current state index. For debugging purposes, I also print out the current state of the tape to stdout
After we get the current state (Final or Normal in our case, accpeting states and rejected states are ambigious), We will halt execution and return if the state is a Final state. If its not a Final state however, we
The above is implemented and can be tested with a sample input given; 111-11=
with the machine definition generated above as well.
Every turing machine can be represented by a finite state machine; for the unary subtraction program that was given, it can be represented as such
The program follows a simple algorithim
The program should behave like a base-1 calculator, an example of input output is like so 11+11 = 1111
. The unary addition program can be represend visually in a graph like so
The program does the following:
This program will accept inputs which are palindromes, and it will write the result y
or n
at the end of the tape before halting.
This program will determine if the input has equal numbers of 0
s and 1
s and they are next to each other.
This program will determine if the length of the input is even
A universal turing machine is a turing machine that can simulate other turing machines. Our previous turing machine programs are run using the turing machine we wrote in OCaml, the challenge of this is to write a turing machine in turing machine language, replacing the turing machine we wrote using OCaml.
The resources are scarce for this one, I can hardly find any good ones with a low skill floor without any hard prerequisites. I have looked up on Minskys UTM and I find it require more background knowledge than what we already have up to this point.
I kept searching and stumbled upon This awesome open source repo by jileventreur which happen to have a minimal implementation of a simple turing machine. My apporach will be heavily inspired by his credible work. However the documentation was not sufficent and verbose enough to make me understand without further analysis; hence the motivation to write this document.
When we 'run' a program in the context of state machines (a set of instructions which changes some state), What we are doing is that we are :
This loop is silimiar to the Fetch-Decode-Execute cycle in modern CPUs. Fetch being reading an instruction, Decode being parsing the instruction and Execute being applying the state changes stated in the parsed instructions
To 'simulate' this behaviour, we need to write a program (set of state-changing instructions, in this case is our UTM) that does the fetch-decode-execute cycle we see earlier and run the new program on a machine (in this case is our OCaml written turing machine)
We kept mentioning that the execution part of the cycle changes some state; the 'state' here in modern CPUs could be registers, files in your file system, memory contents etc. As in the context of UTM, the 'state' here can be the state of the tape head and the tape.
Before we plan out the integration of the simulation, it is crucial to know what will the input to our so called simulation
be.
A true UTM will have its own complex set of rules and encoding methods which can run ANY turing machine in existence. However we wont do that here, we may assume that we know what machine we are trying to simulate prior to building the UTM.
With that said, the encoding that we will be using to encode/compile a turing machine and its input to feed into a built UTM can be more laxed like so.
1.+=|.|ABCDE|A|E|A.A.RA1A1RA+A+RA=B.LB1C=LB+E.LC1C1LC+D1RD1D1RD=E.R|_11+1111=
Notice how the information is sperated by pipes.
The first section denotes the alphabet of the machine
The second section denotes the blank character the machine uses
The third section denotes the states the machine has, represented by a single character
The foruth and fifth section denotes the initial and final states respectively
The sixth section denotes the transitions of the machine; with each transition being seperated into 5 characters like so A.A.R A1A1R A+A+R A=B.L B1C=L B+E.L C1C1L C+D1R D1D1R D=E.R
. Each subsection here has 5 characters, and they represent state, read character, to_state, write character and action respectively
The final section will be the input of the input machine. Notice that we have an underscore _
at the beginning, which denotes our initial head position.
Here is the original, uncompiled turing machine
Note: the main limitation of this encoding is that we cant have more than 52 states, which we are representing using a single character
These are some methods which will be commonly used to manipulate the tape that will help us develop more high-level instructions when we build our UTM
If you want to move an arbitrary number of steps in a direction, the naive way to do so is to find a way to put a character at that spot, then move at that direction indefnitely until that character is found. There is a way to do this without the hassle of placing down a marker by using the current tape head state as a counter instead like so:
This approach is somewhat similar of creating a linked list where the to_state points to the next node for all characters read. The write character will remain unchanged from the read character.
This method is derived from the Arbitrary move, where you want to write a character after moving a certain amount of steps. A small modification to the Arbitrary move states can be done to acheive this
Instead of writing the same character at the end of the moves, we will just write an arbitrary character instead
If we want to look for n
amount of characters (useful when traversing through sections by looking for pipes), we can modify the Arbitrary move states so that it only progresses when we find a character that we want for n
number of times
So far we should be familiar by now that we can store information in the state and the tape. Just like how modern CPUs have different mediums for memory and storage (registers, RAM, caches, disk …) with their specific use cases (slow-presistent access vs fast-ephemeral access), we can also say the same for state storage vs tape storage here.
We can treat state storage as fast-ephemeral storage as we will be constantly changing the contents and we cant store too big information inside it. We chose this over tape memory because to access tape memory, you will need to move to certain place everytime you want to read/write the memory. We can store things like conditionals and counters using state memory.
However, this makes tape memory suitable for slow-persistent access because we dont have the desire to read/write tape memory often, it can be used to store things which needs to be there for a long time; in the context of UTM, it would be things like current state of input machine and current symbol read from input machine
State memory can also help us to create conditionals like switch statements. The way it works is that we have a initial switch state which would scan for each unique character, and then the switch states would then jump to a unique to_state, which will continue with their respective transitions.
Of course, there are a lot more things we can do with state memory and tape memory manipulation which can be derived from the operations above
The first few states of your UTM should be some initialization operations. The reason we do this is because the inputted tape does not have any extra characters for us to be used as tape memory, but at the same time, we do not need to use every single information inside the input machine to be able to carry out execution. So the whole goal of initialization is to remove unwanted information and then be able to initialize that removed so that it can be used as tape memory.
We combine the tape and state manipulation steps earlier to acheive this.
After initialization, our tape should look like this
~~~~~~~~A~|.|A|E|A.A.RA1A1RA+A+RA=B.LB1C=LB+E.LC1C1LC+D1RD1D1RD=E.R|_1+1=
, notice now that the alphabets are removed and replaced with blank ~
characters and the initial state is recorded as well.
As we can see, from steps 1 - 3, we are blanking out the characters of the input machine and the states because we dont need to persist those information for execution and we needed space for the tape memory
For steps 4 - 8, we are padding the blank symbol .
to the right so we get a contigious block of memory.
For steps 9 - 12, we are reading the initial state and recording it in tape memory to begin execution.
This will be the Fetch-Decode-Execute cycle for our machine. The steps you can take to acieve this once our machine is initialized are :
_
from while moving right5
characters each, we are able to manually traverse them while matching.state
and read_char
matched), we will record its to_state
, action
and write_char
in state memory_
and based on our state memory values, we will overwrite the symbol at the cursor with write_char
and display the cursors position in the direction of action
.to_state
I couldnt fit the whole sequence in 1 image but as we can see, we are actively trying to find the cursor _
after our initialization (this is the beginning of the read process)
The symbol at the right of the cursor is read and saved in state memory; now we are trying to traverse to tape memory region to write the new symbol.
After we wrote the new symbol in tape memory, we read the current state and test if the state is a final state. Since we can see it is not, we go back to the tape memory region to begin finding the transition (decode process starts here)
We have recorded the current state and the current symbol in state memory, and now we will be traversing to the tape memory space where the transitions are stored and look for the appropriate transition by matching the current symbol and state. Luckily, its the second transition in the list. We record the action, to_state and write_char values of the transition and store it in state memory
In this stage, we go back to the tape memory region where the current state is stored and writes the to_state value which was origininally in state memory. We should be writing the write_char after this as we prepare to traverse to the cursor (execute process starts here)
Once we are at the cursor, we adjust the cursors position according to the action value in state memory and we also overwrite the character before with write_char, also in state memory.
After that, we will traverse to the leftmost blank character and this process starts all over again.
Eventually, the rightmost section of the tape (input machines tape) will be mutated according to its behaviour above, the final state gets written to tape memory and the machine will HALT during the decoding phase where the program checks for the final state.