Skip to content

Commit 27c2249

Browse files
committed
5D tokenizer with multiverse time travel
1 parent 14e174c commit 27c2249

3 files changed

Lines changed: 133 additions & 86 deletions

File tree

tests/tokenization.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,9 @@ def test_backslash(self):
135135

136136
self.assertEqual(TIProgram.encode(r'TI(Disp "A\ and B")TI'), TIProgram.encode(r'TI(Disp "\x41\x40\x42")TI'))
137137
self.assertEqual(TIProgram.encode(r'TI(Send("A\ and \B"))TI'), TIProgram.encode(r'TI(Send("A and B"))TI'))
138+
139+
def test_equations(self):
140+
self.assertEqual(TIProgram.encode(r'"sin(X)"->{Y1}'), b'\x2A\xc2X\x11\x2A\x04\x5E\x10')
141+
self.assertEqual(TIProgram.encode(r'String>Equ("sin(X)",{r1}'), b'\xBB\x56\x2A\xc2X\x11\x2A\x2B\x5E\x40')
142+
self.assertEqual(TIProgram.encode(r'"sin(X)->{X2T}'), TIProgram.encode(r'"\sin(X)->{X2T}'))
143+
self.assertNotEqual(TIProgram.encode(r'"sin(X)->|u')[:-2], TIProgram.encode(r'"sin(X)->UU')[:-2])

tivars/tokenizer/encoder.py

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from tivars.models import *
1212
from tivars.token import *
1313
from tivars.trie import *
14+
from tivars.util import *
1415
from .state import *
1516

1617

@@ -70,38 +71,46 @@ def tokenize(string: str, *, trie: TITokenTrie = None, mode: str = None, normali
7071
trie = trie or TI_84PCE.tokens.tries[None]
7172
mode = mode or "smart"
7273

73-
tokens = []
74-
index = 0
75-
7674
match mode:
7775
case "max":
78-
stack = [MaxMode()]
79-
76+
steps = [([], string, 0, [MaxMode()])]
77+
8078
case "min" | "string":
81-
stack = [MinMode()]
79+
steps = [([], string, 0, [MinMode()])]
8280

8381
case "smart":
84-
stack = [SmartMode()]
82+
steps = [([], string, 0, [SmartMode()])]
8583

8684
case _:
8785
raise ValueError(f"unrecognized tokenization mode: '{mode}'")
8886

89-
while string:
87+
while steps:
88+
tokens, string, index, stack = steps.pop(0)
89+
9090
try:
91-
token, remainder, contexts = stack.pop().munch(string, trie)
92-
stack += contexts
91+
state = stack.pop()
92+
if not string:
93+
if state.accept:
94+
return tokens
9395

94-
except ValueError:
95-
raise ValueError(f"could not tokenize input at position {index}: '{string[:12]}'")
96+
else:
97+
continue
98+
99+
if isinstance(state, IllegalState):
100+
continue
96101

97102
except IndexError:
98-
raise ValueError(f"stack consumed at position {index}: '{string[:12]}'")
103+
raise ValueError(f"stack consumed at position {index}: '{trim_string(string, 12)}'")
104+
105+
try:
106+
token, remainder, timelines = state.munch(string, trie)
107+
for contexts in timelines:
108+
steps.append((tokens + [token], remainder, index + len(string) - len(remainder), stack + contexts))
99109

100-
tokens.append(token)
101-
index += len(string) - len(remainder)
102-
string = remainder
110+
except (IndexError, ValueError):
111+
raise ValueError(f"failed to tokenize input at position {index}: '{trim_string(string, 12)}'")
103112

104-
return tokens
113+
raise ValueError(f"all tokenization attempts failed; last segment was '{trim_string(string, 12)}'")
105114

106115

107116
def unparse(tokens: Sequence[TIToken]) -> bytes:

tivars/tokenizer/state.py

Lines changed: 101 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Encoder states
2+
NFA implementation for context-aware tokenization with lookahead
33
"""
44

55

@@ -16,26 +16,29 @@ class TokenizerState:
1616
Each state represents some encoding context which affects tokenization.
1717
"""
1818

19-
mode: int = 0
20-
"""
21-
Whether to munch maximally (``0``) or minimally (``-1``)
22-
"""
23-
2419
max_length: int = None
2520
"""
2621
The maximum number of tokens to emit before leaving this state
2722
"""
2823

29-
def __init__(self, length: int = 0):
24+
def __init__(self, mode: int, accept: bool = True, length: int = 0):
25+
"""
26+
:param mode: Whether to munch maximally (``0``) or minimally (``-1``)
27+
:param accept: Whether this state can end a timeline (defaults to ``True``)
28+
:param length: The current length of the input this state is going to process (defaults to ``0``)
29+
"""
30+
31+
self.mode = mode
32+
self.accept = accept
3033
self.length = length
3134

32-
def munch(self, string: str, trie: TITokenTrie) -> tuple[TIToken, str, list['TokenizerState']]:
35+
def munch(self, string: str, trie: TITokenTrie) -> tuple[TIToken, str, list[list['TokenizerState']]]:
3336
"""
34-
Munch the input string and determine the resulting token, encoder state, and remainder of the string
37+
Munch the input string and determine the resulting token, tokenizer timelines, and remainder of the string
3538
3639
:param string: The text string to tokenize
3740
:param trie: The `TokenTrie` object to use for tokenization
38-
:return: A tuple of the output `Token`, the remainder of ``string``, and a list of states to add to the stack
41+
:return: A tuple of the output `Token`, the remainder of ``string``, and a list of timelines
3942
"""
4043

4144
# Is this a byte literal?
@@ -44,7 +47,7 @@ def munch(self, string: str, trie: TITokenTrie) -> tuple[TIToken, str, list['Tok
4447
string, remainder = string[:length], string[length:]
4548
token = IllegalToken(bytes.fromhex(string.lstrip(r"\ux")))
4649

47-
return token, remainder, self.next(token)
50+
return token, remainder, self.next(token, remainder)
4851

4952
# Is this a var prefix?
5053
for leading_byte, prefix in TIToken.var_prefixes.items():
@@ -53,95 +56,106 @@ def munch(self, string: str, trie: TITokenTrie) -> tuple[TIToken, str, list['Tok
5356
string, remainder = string[:length], string[length:]
5457
token = IllegalToken(bytes([leading_byte, int(string[-2:], 16)]))
5558

56-
return token, remainder, self.next(token)
59+
return token, remainder, self.next(token, remainder)
5760

5861
# Is there a token separator?
5962
if string.startswith(("␟", " ", "‌")):
6063
string = string[1:]
6164

6265
# Is there a backslash?
6366
if string.startswith("\\"):
64-
string = string[1:]
65-
self.mode = 0
67+
tokens = trie.match(string[1:])
68+
token, remainder = tokens[0]
6669

67-
tokens = trie.match(string)
68-
if not tokens:
69-
raise ValueError("no tokenization options exist")
70+
else:
71+
tokens = trie.match(string)
7072

71-
# Is this a glyph?
72-
if string[0] in punctuation and len(tokens) > 1:
73-
tokens.pop()
73+
# Is this a glyph?
74+
if string[0] in punctuation and len(tokens) > 1:
75+
tokens.pop()
7476

75-
token, remainder = tokens[self.mode]
77+
token, remainder = tokens[self.mode]
7678

7779
# Are we out of tokens?
7880
if self.length == self.max_length:
79-
return token, remainder, []
81+
return token, remainder, [[]]
8082

81-
return token, remainder, self.next(token)
83+
return token, remainder, self.next(token, remainder)
8284

83-
def next(self, token: TIToken) -> list['TokenizerState']:
85+
def next(self, token: TIToken, remainder: str) -> list[list['TokenizerState']]:
8486
"""
85-
Determines the next tokenizer state given a token
87+
Determines the next tokenizer timelines given a token
8688
8789
The current state is popped from the stack, and the states returned by this method are pushed.
8890
89-
If the list of returned states is...
90-
- empty, then the tokenizer is exiting the current state.
91-
- length one, then the tokenizer's current state is being replaced by a new state.
92-
- length two, then the tokenizer is entering a new state, able to exit back to this one.
91+
1. The current state is popped from the stack.
92+
2. All possible timelines are determined, each a list of states.
93+
3. For each separate timeline, those states are added its stack.
94+
95+
If a list of states in a timeline is...
96+
- empty, then the timeline is exiting the current state.
97+
- length one, then the timeline's current state is being replaced by a new state.
98+
- length two, then the timeline is entering a new state, able to exit back to this one.
9399
94100
:param token: The current token
95-
:return: A list of tokenizer states to add to the stack
101+
:param remainder: The remaining string content to tokenize
102+
:return: A list of timelines (each a list of states)
96103
"""
97104

98-
return [type(self)(self.length + 1)]
105+
return [[type(self)(self.mode, self.accept, self.length + 1)]]
106+
107+
108+
class IllegalState(TokenizerState):
109+
"""
110+
Tokenizer state which indicates its timeline must be pruned
111+
"""
99112

100113

101114
class MaxMode(TokenizerState):
102115
"""
103116
Maximal munching mode
104117
"""
105118

106-
mode = 0
119+
def __init__(self, mode: int = 0, accept: bool = True, length: int = 0):
120+
super().__init__(mode, accept, length)
107121

108122

109123
class MinMode(TokenizerState):
110124
"""
111125
Minimal munching mode
112126
"""
113127

114-
mode = -1
128+
def __init__(self, mode: int = -1, accept: bool = True, length: int = 0):
129+
super().__init__(mode, accept, length)
115130

116131

117132
class Line(TokenizerState):
118133
"""
119134
State which is always exited after a line break or STO
120135
"""
121136

122-
def next(self, token: TIToken) -> list[TokenizerState]:
137+
def next(self, token: TIToken, remainder: str) -> list[list[TokenizerState]]:
123138
match token.bits:
139+
# STO (→) Line break
124140
case b'\x04' | b'\x3F':
125-
return []
141+
return [[]]
126142

127143
case _:
128-
return super().next(token)
144+
return super().next(token, remainder)
129145

130146

131-
class Name(Line):
147+
class Name(MinMode, Line):
132148
"""
133149
Valid var identifiers
134150
"""
135151

136-
mode = -1
137-
138-
def next(self, token: TIToken) -> list[TokenizerState]:
152+
def next(self, token: TIToken, remainder: str) -> list[list[TokenizerState]]:
139153
# Digits Uppercase letters (and theta)
140154
if b'\x30' <= token.bits <= b'\x39' or b'\x41' <= token.bits <= b'\x5B':
141-
return super().next(token)
155+
return super().next(token, remainder)
142156

143157
else:
144-
return []
158+
return [[]]
145159

146160

147161
class ListName(Name):
@@ -165,72 +179,90 @@ class String(Line):
165179
Strings
166180
"""
167181

168-
mode = -1
169-
170-
def next(self, token: TIToken) -> list[TokenizerState]:
182+
def next(self, token: TIToken, remainder: str) -> list[list[TokenizerState]]:
171183
match token.bits:
184+
case b'\x04':
185+
return [[StringTarget(self.mode, self.accept)]]
186+
172187
case b'\x2A':
173-
return []
188+
return [[StringSto(self.mode, self.accept)]]
174189

175190
case _:
176-
return super().next(token)
191+
return super().next(token, remainder)
177192

178193

179-
class MaxString(String):
194+
class StringStart(Line):
180195
"""
181-
Maximally munched string
196+
Opening quote of a string
182197
"""
183198

184-
mode = 0
199+
def next(self, token: TIToken, remainder: str) -> list[list[TokenizerState]]:
200+
match token.bits:
201+
case b'\x2A':
202+
return [[String(self.mode, self.accept)]]
203+
204+
case _:
205+
return [[]]
185206

186207

187-
class MaxStart(Line):
208+
class StringSto(Line):
188209
"""
189-
State to initialize `MaxString`
190-
191-
If any token besides ``"`` is encountered, this state is immediately exited to avoid cluttering the stack.
210+
STO immediately following a string
192211
"""
193212

194-
mode = 0
195-
196-
def next(self, token: TIToken) -> list[TokenizerState]:
213+
def next(self, token: TIToken, remainder: str) -> list[list[TokenizerState]]:
197214
match token.bits:
198-
case b'\x2A':
199-
return [MaxString()]
215+
case b'\x04':
216+
return [[StringTarget(self.mode, self.accept)]]
217+
218+
case _:
219+
return [[]] if self.accept else [[IllegalState(0)]]
220+
221+
222+
class StringTarget(Line):
223+
"""
224+
STO target of a string
225+
"""
226+
227+
def next(self, token: TIToken, remainder: str) -> list[list[TokenizerState]]:
228+
match self.mode, token.bits.startswith(b'\x5E'), self.accept:
229+
case (0, True, _) | (0, _, True) | (-1, False, _):
230+
return [[]]
200231

201232
case _:
202-
return []
233+
return [[IllegalState(0)]]
203234

204235

205236
class SmartMode(TokenizerState):
206237
"""
207238
Smart tokenization mode
208239
"""
209240

210-
mode = 0
241+
def __init__(self, mode: int = 0, accept: bool = True, length: int = 0):
242+
super().__init__(mode, accept, length)
211243

212-
def next(self, token: TIToken) -> list[TokenizerState]:
244+
def next(self, token: TIToken, remainder: str) -> list[list[TokenizerState]]:
213245
match token.bits:
214246
# "
215247
case b'\x2A':
216-
return [self, String()]
248+
return [[self, String(0, False)], [self, String(-1)]]
217249

218250
# prgm
219251
case b'\x5F':
220-
return [self, ProgramName()]
252+
return [[self, ProgramName()]]
221253

222254
# Send( String>Equ(
223255
case b'\xE7' | b'\xBB\x56':
224-
return [self, MaxStart()]
256+
return [[self, StringStart(0)]]
225257

226258
# |L
227259
case b'\xEB':
228-
return [self, ListName()]
260+
return [[self, ListName()]]
229261

230262
case _:
231-
return super().next(token)
263+
return super().next(token, remainder)
232264

233265

234-
__all__ = ["TokenizerState", "MaxMode", "MinMode", "SmartMode",
266+
__all__ = ["TokenizerState", "IllegalState", "MaxMode", "MinMode", "SmartMode",
235267
"Line", "Name", "ListName", "ProgramName",
236-
"String", "MaxString", "MaxStart"]
268+
"String", "StringStart", "StringSto", "StringTarget"]

0 commit comments

Comments
 (0)