In this notebook we illustrate several of our results, by means of simulation and explicit computation. We follow the subsections, references and order of the revised version of the paper.
# We make the simulations repeatable by setting the seed
set_random_seed(0)
We begin with Theorem 1, which states that a word is realizable iff its signature is interlacing.
def sim_data(n):
    # simulates a configuration for n iid points uniform on the circle
    # the data is returned as a sorted list of elements of type (pos,index)
    # where 'pos' is the position in [0,1] in increasing order, and 'index' is either:
    # - 0 for a point
    # - 1 for the diametrically opposite point
    # - 2 for a perpendicular bisector
    # - 3 for the diametrically opposite part of the bisector
    P = [random() for _ in range(n)] #n iid points
    PP = [(P[i] + 1/2) % 1 for i in range(n)] #diametrically opposite points
    P.sort()
    L = [ (P[i] + P[i+1]) / 2 for i in range(n-1)] + [ (P[n-1]-1+P[0])/2 %1 ] #bisectors
    LL = [ (L[i] + 1/2) % 1 for i in range(n)] #diametrically opposite side of bisectors
    # the next list, U will contain, the data described before
    U = [(x,0) for x in P] + [(x,1) for x in PP] + [(x,2) for x in L] + [(x,3) for x in LL]
    U.sort() #default sorting is w.r.t. the first entries
    return U
def sim_word(n):
    # simulates data and deduces the occupancy word
    U = sim_data(n)
    V = [] # will be the occupancy word
    current_number = 0
    for (_,x) in U:
        if x==2 or x==3:
            V.append(current_number)
            current_number=0
        if x==0:
            current_number+=1
    V[0] += current_number
    return V
# an occupancy word for n=10 random points:
sim_word(10)
def word_to_sig(V):
    # takes a word and returns its signature
    n = len(V)/2
    return [V[i] + V[i+n] for i in range(n)]
def sim_sig(n):
    # simulates the signature of the occupancy word
    V = sim_word(n)
    return word_to_sig(V)
# a signature of a word for n=10 random points:
sim_sig(10)
# We now test if signatures are always interlacing.
def is_interlacing(S):
    # Takes a signature S (list of 0, 1 and 2) and says if it is interlacing
    n0 = S.count(0)
    n2 = S.count(2)
    # If there are no 0, no 2, or not the same number of 0 and 2, fail:
    if n0==0 or n0!=n2:
        return False
    # Otherwise, we run through S and each time we see a 0/2, we check if the previous was different.
    # Initially, 'previous' is the one *different* from the first 0/2 in S.
    i0 = S.index(0) #first index for 0
    i2 = S.index(2) #first index for 2
    if i0<i2:
        previous=2
    else:
        previous=0
    for x in S:
        if x==0 or x==2:
            if x==previous:
                return False
            previous=x
    return True
# We simulate n_tests signatures for n, and check if they are all interlacing.
n_tests=1000000
n=20
n_fail=0
for _ in range(n_tests):
    if not(is_interlacing(sim_sig(n))):
        print('fail')
        n_fail += 1
        
print('Number of non-interlacing signatures found: ' + str(n_fail) )
We can now illustrate Proposition 2, which states uniqueness of the occupancy word for $n=3$ up to symmetries.
# occupancy word for a random word with n=3
sim_word(3)
# we test that this is indeed the only word up to symmetries (i.e. the only bracelet),
# for n_tests simulations
n_tests = 1000000
n_mistakes = 0
for _ in range(n_tests):
    V = sim_word(3)
    # we make the word 'canonical' by computing the rotation that is minimal
    # in lexicographic order:
    min_rotation = min(V[i:]+V[:i] for i in range(6))
    if min_rotation != [0,0,1,1,0,1] and min_rotation != [0,0,1,0,1,1]:
        print('fail')
        n_mistakes += 1
print('For n=3, number of bracelets different from the reference one found: '+str(n_mistakes))
We now illustrate Corollary 3 and Table 1. For these, we first list all realizable signatures, then words, then bracelets for small $n$. This is done using Sage's Automata libraries, since the language describing realizable signatures is a rational one.
# The following automaton recognizes alternating signatures.
# It has seven states: A, B1, B2, C1, C2, D1, D2.
# The initial state is A and the final states are C1, C2.
# Its representation as a graph is plotted below (disregard the '|-' in edge labels)
sig_automaton = Automaton(
    {'A': [('A', 1), ('B1', 0), ('B2', 2)],
     'B1': [('B1', 1), ('C1', 2)], 'B2': [('B2', 1), ('C2', 0)], 
     'C1': [('C1', 1), ('D1', 0)], 'C2': [('C2', 1), ('D2', 2)],
     'D1': [('D1', 1), ('C1', 2)], 'D2': [('D2', 1), ('C2', 0)] },
    initial_states = ['A'], final_states = ['C1', 'C2'])
sig_automaton.graph().plot(edge_labels_background='transparent',
                           edge_labels='True', vertex_size=200.,
                           pos={'A':[0.,0.],'B1':[0.3,0.],'B2':[-0.3,0.],
                                'C1':[0.6,0.],'C2':[-0.6,0.],
                                'D1':[0.9,0.],'D2':[-0.9,0.]})
def all_sig(n):
    # returns a list of all alternating signatures for n points
    num = sig_automaton.number_of_words(n)
    l = list(sig_automaton.language(n))
    # Sage returns all words of length <=n in l, so we restrict to the last ones to get length ==n.
    return l[-num:] 
def sig_to_words(S):
    # Takes a signature and returns all words of 0/1 that have this signature.
    # The signature has length n, and the words have length 2*n.
    # A - If the signature has a 2 (resp. 0) in position i,
    # then the word must have a 1 (resp. 0) in positions i and i+n.
    # B - If the signature has a 1 in position i, then in the word,
    # exactly one of positions i, i+n is 1, and the other is 0.
    n = len(S)
    lV = [] #will store the list of words
    V0 = [0 for _ in range(2*n)] #reference word that will satisfy just A
    for i in range(n):
        if S[i]==2:
            V0[i] = 1
            V0[i+n] = 1
    # now V0 satisfies A
    # To compute all words satisfying also B, we compute all subsets of positions of 1's
    pos1 = set([i for i,x in enumerate(S) if x==1]) #all positions of 1 in the signature
    for set1 in subsets(pos1): 
        # the subset 'set1' will be the 1's for V[i], 
        # and those of pos1\set1 will be the 1's for V[i+n]
        V = copy(V0)
        for i in set1:
            V[i] = 1
        for i in pos1.difference(set1):
            V[i+n] = 1
        lV.append(V)
    return lV
def all_words(n):
    # returns a list of all realizable words for n points
    lV = []
    for S in all_sig(n):
        lV += sig_to_words(S)
    return lV
# For n from 3 to 10, we enumerate all realizable words 
# and compare their number with that of Corollary 1.10
for n in range(3,13):
    print( 'for n='+str(n) + ': ' + str(len(all_words(n))) + ' enumerated words, ' + 
          str(3**n - 2**(n+1) + 1 ) + ' in theory')
# We chose to represent a bracelet as a word that is minimal for the lexicographic order
# among all rotations of the word and the reversed word.
def word_to_brac(V):
    # Takes a word and returns the corresponding bracelet
    m1 = min(V[i:]+V[:i] for i in range(len(V))) #min rotation of the word
    V2 = copy(V)
    V2.reverse()
    m2 = min(V2[i:]+V2[:i] for i in range(len(V))) #min rotation of reversed word
    m = min(m1,m2)
    return m
    #m_n = int("".join(str(i) for i in m),2)
    #return m_n
def all_brac(n):
    # Returns a list of all bracelets for n points.
    l = all_words(n)
    ll = [] #will be the list of all bracelets
    for V in l:
        bV = word_to_brac(V)
        if not(bV in ll):
            ll.append(bV)
    return ll
# Computing the number of bracelets for n from 3 to 12. This extends Table 1.
for n in range(3,13):
    l = all_brac(n)
    print('n='+str(n)+': number of bracelets '+str(len(l)))
We also illustrate Andrew Howroyd's Corollary 4, that gives an explicit formula as a sum for the number of bracelets. This formula is of course way more efficient, and can very quickly give the first ~10000 terms of the sequence.
def num_brac(n):
    # Returns the number of realizable bracelets for n, using Andrew Howroyd's formula
    s = 0 #the sum
    for d in divisors(n):
        if (n/d) % 2 == 1:
            s+=(3**d - 2**(d+1) - (-1)**n)*euler_phi(n/d)
    res = s / (4*n) #the result
    if n%2==0:
        res+=(3**(n/2 - 1) +1)/ 2
    return res
# Computing the first terms and checking if they agree with our bruteforce algorithm
for n in range(3,13):
    print('n='+str(n)+': number of bracelets '+str(num_brac(n)))
Proposition 5 is harder to illustrate. However, we can illustrate Proposition 6, that is also some evidence for the previous one.
def b(n):
    # returns the special word denoted by b_n
    return [1,0] + [1 for _ in range(n-1)] + [0 for _ in range(n-1)]
def empirical_prob_b(n,n_tests):
    # computes the empirical probability of the word b_n, by taking n_tests samples
    bword = b(n)
    # as b_n is a bracelet, we also compute its reversed version
    bword_reversed = b(n)
    bword_reversed.reverse()
    res = 0 #number of success
    for _ in range(n_tests):
        V = sim_word(n)
        for i in range(2*n): #we test for all rotations of the simulated word V
            rotated_V = V[i:]+V[:i]
            if rotated_V == bword or rotated_V == bword_reversed:
                res += 1
                break
    return float(res/n_tests)
def theory_prob_b(n):
    return float( n / (3*2**(2*n-6)) )
# For n from 3 to 10, we simulate n_tests samples
# and compare the empirical probability of b_n with the theoretical one
n_tests = 1000000
for n in range(3,11):
    print('for n='+str(n)+', empirical: '+str(empirical_prob_b(n,n_tests))+
          ', theory: '+str(theory_prob_b(n)))
We turn to Theorem 7. It concerns the expected number of regions of type 0, 1, 2, or equivalently twice the number of 0, 1, 2 in signatures. As described in the paper, it is enough to illustrate only for type 0, which is what we do here.
def empirical_num_0(n,n_tests):
    # computes the empirical expectation of numbers of 0,1,2
    # in n_tests samples.
    H0 = 0
    for _ in range(n_tests):
        S = sim_sig(n)
        # for each 0 in the signatures there are 2 regions (convention: 2n regions in total)
        H0 += 2*S.count(0)
    H0 = float( H0/(n_tests) )
    return H0
def theory_num_0(n):
    h0 = float( (n/2) * (1 + 1/(3**(n-2))) )
    return h0
# For n from 3 to 10, we simulate n_tests samples
# and compare the empirical average number of 0's with the theoretical one
n_tests = 1000000
for n in range(3,11):
    H0 = empirical_num_0(n,n_tests)
    h0 = theory_num_0(n)
    print('for n='+str(n)+': empirical: '+str(H0)+', theory: '+str(h0))
We analogously illustrate Theorem 8, which concerns the average lengths of the three types of regions.
def sim_lengths(n):
    # simulates the lengths of regions of size 0, 1, 2 for one realization
    U = sim_data(n)
    l = [0.,0.,0.] #total lengths of each type
    n_points = 0 #number of points in the current region
    left_bound = 0. #left bound of the current region (the first one will require special care)
    for (x,i) in U:
        if i == 2 or i == 3: #end of region
            if left_bound == 0.: #case of first region
                i0 = n_points #number of points at beginning
                l0 = x #length at beginning
                left_bound = x
                n_points = 0
            else: #usual case
                l[n_points] += (x-left_bound)
                left_bound = x
                n_points = 0
        else: #normal point or antipoint
            n_points += 1
    l[i0 + n_points] += 1.0 - left_bound + l0 #first region and last regions together
    return l
def empirical_lengths(n,n_tests):
    # computes the empirical expectations of proportions of 0,1,2 in n_tests samples.
    L = [0.,0.,0.]
    for _ in range(n_tests):
        [l0,l1,l2] = sim_lengths(n)
        L[0] += l0
        L[1] += l1
        L[2] += l2
    L[0] = L[0]/n_tests
    L[1] = L[1]/n_tests
    L[2] = L[2]/n_tests
    return L
def theory_lengths(n):
    l0 = float( (3**(n-1)+2*n-7) / (8*3**(n-1)) )
    l1 = float( (3**(n-1)-n-1) / (2*3**(n-1)) )
    l2 = float( (3**(n)+2*n+11) / (8*3**(n-1)) )
    return [l0,l1,l2]
# For n from 3 to 10, we simulate n_tests samples
# and compare the empirical average lengths with the theoretical one
n_tests = 1000000
for n in range(3,11):
    l_e = empirical_lengths(n,n_tests)
    l_t = theory_lengths(n)
    print('for n='+str(n)+':')
    print('   empirical: '+str(l_e))
    print('   theory:    '+str(l_t))
We now illustrate Theorem 11, that concerns the functions on $[0,1]$ defined by the number of regions of each type up to that point, and the lengths of these regions.
def function_count(n):
    # For one sample, returns the three functions giving the proportion of regions
    # of each type up to that point.
    # The first region may be wrong, but we are only interested in asymptotic properties.
    lx = [0.] #list of abscissas, which will be the positions of bisectors (except the first one)
    # for each abscissa in lx, there will be an entry in each of the three following lists
    # giving the proportions of 0, 1 and 2 regions up to that point:
    ly = [[0],[0],[0]] 
    U = sim_data(n)
    n_points = 0 #number of points in the current region
    for (x,i) in U:
        if i == 2 or i == 3: #end of region
            lx.append(x)
            # all 3 lists of ly get the last entry repeated, then that of n_points is increased
            ly[0].append(ly[0][-1])
            ly[1].append(ly[1][-1])
            ly[2].append(ly[2][-1])
            ly[n_points][-1] += 1
            n_points = 0
        else: #normal point or antipoint
            n_points += 1
    # Renormalizing
    for i in range(3):
        for j in range(len(lx)):
            ly[i][j] /= (2*n)
    return (lx,ly)
# We compute one sample for a large n and plot the three counting functions
n=10000
(lx,[ly0,ly1,ly2])=function_count(n)
plot0 = list_plot([(lx[i],ly0[i]) for i in range(len(lx))],
                  plotjoined=True,color='red',legend_label='0',
                  title='Proportion of regions of each type in [0,t], for one realization with n='+str(n),
                  axes_labels=['t',''])
plot1 = list_plot([(lx[i],ly1[i]) for i in range(len(lx))],
                  plotjoined=True,color='blue',legend_label='1')
plot2 = list_plot([(lx[i],ly2[i]) for i in range(len(lx))],
                  plotjoined=True,color='green',legend_label='2')
plot_theo0 = list_plot([(lx[i],lx[i]/4) for i in range(len(lx))],
                       plotjoined=True,color='black',linestyle='--',
                       legend_label='theoretical')
plot_theo1 = list_plot([(lx[i],lx[i]/2) for i in range(len(lx))],
                       plotjoined=True,color='black',linestyle='--')
(plot0+plot1+plot2+plot_theo0+plot_theo1).show()
# of course the curves of 0 and 2 are very close, 
# since the number of regions cannot differ by more than 1
# due to the alternating property (so by 1/(2n) after renormalization)
def function_lengths(n):
    # Same as the previous one, but for lengths of regions. The code is very similar.
    lx = [0.]
    ly = [[0],[0],[0]] 
    U = sim_data(n)
    n_points = 0 #number of points in the current region
    left_bound = 0. #left boundary of the current region
    for (x,i) in U:
        if i == 2 or i == 3: #end of region
            lx.append(x)
            ly[0].append(ly[0][-1])
            ly[1].append(ly[1][-1])
            ly[2].append(ly[2][-1])
            ly[n_points][-1] += x-left_bound
            left_bound=x
            n_points = 0
        else: #normal point or antipoint
            n_points += 1
    return (lx,ly)
# We compute one sample for a large n and plot the three length functions
n=10000
(lx,[ly0,ly1,ly2])=function_lengths(n)
plot0 = list_plot([(lx[i],ly0[i]) for i in range(len(lx))],
                  plotjoined=True,color='red',legend_label='0',
                  title='Lengths of regions of each type in [0,t], for one realization with n='+str(n),
                  axes_labels=['t',''])
plot1 = list_plot([(lx[i],ly1[i]) for i in range(len(lx))],
                  plotjoined=True,color='blue',legend_label='1')
plot2 = list_plot([(lx[i],ly2[i]) for i in range(len(lx))],
                  plotjoined=True,color='green',legend_label='2')
plot_theo0 = list_plot([(lx[i],lx[i]/8) for i in range(len(lx))],
                       plotjoined=True,color='black',linestyle='--',
                       legend_label='theoretical')
plot_theo1 = list_plot([(lx[i],lx[i]/2) for i in range(len(lx))],
                       plotjoined=True,color='black',linestyle='--')
plot_theo2 = list_plot([(lx[i],3*lx[i]/8) for i in range(len(lx))],
                       plotjoined=True,color='black',linestyle='--')
(plot0+plot1+plot2+plot_theo0+plot_theo1+plot_theo2).show()
We finish with Theorem 12. This is harder to illustrate, because we cannot easily sample a word uniformly among realizable words for large values of $n$.
# First we compute the average of numbers of each region among all realizable words.
# This is a partial illustration of Theorem 12 (i).
# For each realizable word we compute the signature and count the 0s, 1s, 2s.
print('Average proportions of regions among all realizable words: ')
for n in range(3,13):
    n0=0 #total number of regions of type 0
    n1=0 #total number of regions of type 1
    n2=0 #total number of regions of type 2
    l = all_words(n)
    for V in l:
        S = word_to_sig(V)
        n0 += S.count(0)
        n1 += S.count(1)
        n2 += S.count(2)
    # Renormalizing by n (total number of regions in the signature) and the number of words
    n0 = float(n0 / (n*len(l)))
    n1 = float(n1 / (n*len(l)))
    n2 = float(n2 / (n*len(l)))
    print('n='+str(n)+': '+str(n0)+', '+str(n1)+', '+str(n2))
print('Asymptotic: '+str(float(1/6))+', '+str(float(2/3))+', '+str(float(1/6)))
# Same as the previous one but for bracelets.
print('Average proportions of regions among all realizable bracelets: ')
for n in range(3,13):
    n0=0
    n1=0
    n2=0
    l = all_brac(n)
    for V in l:
        S = word_to_sig(V)
        n0 += S.count(0)
        n1 += S.count(1)
        n2 += S.count(2)
    n0 = float(n0 / (n*len(l)))
    n1 = float(n1 / (n*len(l)))
    n2 = float(n2 / (n*len(l)))
    print('n='+str(n)+': '+str(n0)+', '+str(n1)+', '+str(n2))
print('Asymptotic: '+str(float(1/6))+', '+str(float(2/3))+', '+str(float(1/6)))    
# To try to illustrate Theorem 12 (ii), we sample one realizable word taken uniformly
# among all realizable words. Then we plot the three functions mentioned in the theorem.
# The enumeration of all realizable words is unfornutately very long for n >= 14 or so.
n = 13
l = all_words(n)
v = l[ int( len(l)*random() ) ] #one word taken uniformly at random
s = word_to_sig(v)
# Lists for the three functions of 0s, 1s, 2s in the signature at each position
l0 = []
l1 = []
l2 = []
for i in range(n+1):
    l0.append( (s[0:i].count(0) - float(i/6)) * 2 / sqrt(n) )
    l1.append( (s[0:i].count(1) - float(2*i/3)) * 2 / sqrt(n) )
    l2.append( (s[0:i].count(2) - float(i/6)) * 2 / sqrt(n) )
plot0 = list_plot([(i/n,l0[i]) for i in range(n+1)],
                  plotjoined=True,color='red',legend_label='0',
                  title='Normalized number of regions of each type between indices 0 and cn, for a random word taken uniformly, with with n='+str(n),
                  axes_labels=['c',''])
plot1 = list_plot([(i/n,l1[i]) for i in range(n+1)],
                  plotjoined=True,color='blue',legend_label='1')
plot2 = list_plot([(i/n,l2[i]) for i in range(n+1)],
                  plotjoined=True,color='green',legend_label='2')
(plot0+plot1+plot2).show()
# In the limit, the green and red curves should look like the same brownian motion W_c,
# while the blue one looks like -2*W_c.