summaryrefslogtreecommitdiff
path: root/monkey/action.py
blob: 1b036d69340be8fd23d1de5c7720a9d82fd9c161 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
#!/usr/bin/python3

class Action:
    # type ∈ ['insert', 'remove', 'solve', 'solve_all', 'next', 'stop', 'test', 'hint']
    # time: absolute elapsed time since the attempt started, in ms
    # offset: position of the first inserted/removed character
    # text: inserted/removed text or query
    # total, passed: number of test cases
    def __init__(self, type, time, offset=0, text='', total=0, passed=0):
        self.type = type
        self.time = time
        if type in {'insert', 'remove'}:
            self.offset = offset
            self.length = len(text)
            self.text = text
        elif type in {'prolog_solve', 'solve_all'}:
            self.query = text
        elif type == 'test':
            self.total = total
            self.passed = passed

    def __str__(self):
        s = 't = ' + str(self.time/1000.0) + '  ' + self.type
        if self.type in {'insert', 'remove'}:
            s += ' "' + self.text.replace('\n', '\\n').replace('\t', '\\t') + '" at ' + str(self.offset)
        elif self.type in {'prolog_solve', 'solve_all'}:
            s += ' "' + self.query + '"'
        elif self.type == 'test':
            s += ' {0} / {1}'.format(self.passed, self.total)
        return s

    # apply this action to text
    def apply(self, text):
        if self.type == 'insert':
            return text[:self.offset] + self.text + text[self.offset:]
        elif self.type == 'remove':
            return text[:self.offset] + text[self.offset+self.length:]
        else:
            return text

    # reverse the application of this action
    def unapply(self, text):
        if self.type == 'insert':
            return text[:self.offset] + text[self.offset+self.length:]
        elif self.type == 'remove':
            return text[:self.offset] + self.text + text[self.offset:]
        else:
            return text

_packet_action_map = {
    'ins':  lambda packet, time, code: Action('insert', time, offset=packet['off'], text=packet['txt']),
    'rm':   lambda packet, time, code: Action('remove', time, offset=packet['off'], text=packet['txt']),
    'tst':  lambda packet, time, code: Action('test', time, total=packet['tot'], passed=packet['pas']),
    'hnt':  lambda packet, time, code: Action('hint', time),
    'slva': lambda packet, time, code: Action('solve_all', time, text=packet['qry']),
    'prolog_solve':  lambda packet, time, code: Action('prolog_solve', time, text=packet['query']),
    'prolog_next':   lambda packet, time, code: Action('next', time),
    'prolog_end':    lambda packet, time, code: Action('stop', time),
}
# parse log from database into a list of actions, cleaning up some fluff.
# ignore non-text actions (queries and tests)
def parse(data):
    if data == None:
        return [], []

    actions = []
    incorrect = set()

    time = 0
    code = ''
    for packet in data:
        try:
            time += packet['dt']
            action = _packet_action_map[packet['typ']](packet, time, code)
        except:
            # ignore any errors while decoding a packet
            continue

        # skip normalization if this is the first action
        if actions == []:
            actions.append(action)
            code = action.apply(code)
            continue

        # add to list of actions; modify previously added action if necessary
        prev = actions[-1]

        # remove superfluous REMOVE action when newline is inserted (due to editor auto-indent)
        if prev.type == 'remove' and action.type == 'insert' and \
           action.time == prev.time and \
           action.offset == prev.offset and action.length > prev.length and \
           action.text[action.length-prev.length:] == prev.text:
            # discard last REMOVE action
            code = prev.unapply(code)
            actions.pop()

            # replace current action with something better
            length = action.length - prev.length
            new = Action('insert', prev.time, offset = prev.offset, text = action.text[:length])
            actions.append(new)
            code = new.apply(code)

        # remove superfluous INSERT action when newline is removed (due to editor auto-indent)
        elif prev.type == 'remove' and action.type == 'insert' and \
             action.time == prev.time and \
             action.offset == prev.offset and action.length < prev.length and \
             prev.text[prev.length-action.length:] == action.text:
            # discard last INSERT action
            code = prev.unapply(code)
            actions.pop()

            # replace current action with something better
            length = prev.length - action.length
            new = Action('remove', prev.time, offset = prev.offset, text = prev.text[:length])
            actions.append(new)
            code = new.apply(code)

        # discard INSERT/REMOVE pairs (typos)
        elif prev.type == 'insert' and action.type == 'remove' and \
             action.time - prev.time < 10000 and \
             action.offset == prev.offset and action.text == prev.text:
            # discard last and current actions
            code = prev.unapply(code)
            actions.pop()

        # discard REMOVE/INSERT pairs (deleted char then typed back)
        elif prev.type == 'remove' and action.type == 'insert' and \
             action.offset == prev.offset and action.text == prev.text:
            # discard last and current actions
            code = prev.unapply(code)
            actions.pop()

        # otherwise, simply append the current action
        else:
            actions.append(action)
            code = action.apply(code)

    return actions

# expand any multi-char actions (does not do anything for the 99%)
def expand(actions):
    i = 0
    while i < len(actions):
        if actions[i].type == 'insert' and len(actions[i].text) > 1:
            a = actions.pop(i)
            for offset in range(len(a.text)):
                actions.insert(i+offset, Action('insert', a.time, a.offset+offset, a.text[offset]))
            i += len(a.text)
        elif actions[i].type == 'remove' and len(actions[i].text) > 1:
            a = actions.pop(i)
            for offset in range(len(a.text)):
                actions.insert(i, Action('remove', a.time, a.offset+offset, a.text[offset]))
            i += len(a.text)
        else:
            i += 1

# each action in parse() result corresponds to single insertion/deletion.
# this function merges related adjacent actions
def compress(actions):
    # first make each edit change exactly one character, for easier handling
    expand(actions)

    i = 0
    while i < len(actions)-1:
        a = actions[i]
        b = actions[i+1]

        # merge adjacent INSERT actions
        # +a +b  →  +ab
        if a.type == 'insert' and b.type == 'insert' and \
            b.offset == a.offset + a.length: #and b.time - a.time < 10000:
            a.text += b.text
            a.length += b.length
            del actions[i+1]

        # merge adjacent REMOVE actions (two cases: backspace & delete)
        # -b -a  →  -ab
        elif a.type == 'remove' and b.type == 'remove' and \
            b.offset == a.offset - b.length: #and b.time - a.time < 10000:
            a.text = b.text + a.text
            a.offset = b.offset
            a.length += b.length
            del actions[i+1]
        # -a -b  →  -ab
        elif a.type == 'remove' and b.type == 'remove' and \
             b.offset == a.offset and b.time - a.time < 10000:
            a.text += b.text
            a.length += b.length
            del actions[i+1]

        # merge adjacent INSERT/REMOVE actions
        # +ab -b  →  +a
        elif a.type == 'insert' and b.type == 'remove' and \
             b.offset >= a.offset and b.offset < a.offset + a.length and \
             b.length == a.offset + a.length - b.offset and b.time - a.time < 10000:
            del_start = b.offset - a.offset
            del_end = del_start + b.length
            a.text = a.text[:del_start] + a.text[del_end:]
            a.length -= b.length
            del actions[i+1]

        else:
            i += 1

# some sample code
if __name__ == '__main__':
    import sys, os.path
    sys.path.append(os.path.dirname(os.path.realpath(__file__)) + '/..') # the parent directory is the app directory

    from db.models import Problem, Solution

    # print all problem ids
    print('problems:')
    for problem in Problem.list():
        print('  {}\t{}'.format(problem.id, problem.identifier))
    print()

    pid = input('enter problem id: ')

    # print all attempt ids for the selected problem
    print('users solving problem ' + str(pid) + ':')
    attempts = Solution.filter(problem_id=pid)
    print(', '.join([str(attempt.codeq_user_id) for attempt in attempts]))
    print()

    uid = input('enter user id: ')
    attempt = Solution.get(problem_id=pid, codeq_user_id=uid)

    try:
        actions = parse(attempt.trace)
        print('read ' + str(len(actions)) + ' actions from log')
        compress(actions)
        print('after compression: ' + str(len(actions)) + ' actions')
        print()

        print('code versions for this attempt:')
        code = ''
        for action in actions:
            code = action.apply(code)
            print(action)
            print(code.strip())
            print()
    except Exception as ex:
        sys.stderr.write('Error parsing action log: ' + str(ex))