textslinger 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
textslinger/causal.py ADDED
@@ -0,0 +1,437 @@
1
+ import torch
2
+ from typing import List, Tuple
3
+ from transformers import AutoModelForCausalLM, AutoTokenizer
4
+ import itertools
5
+ import heapq
6
+ from textslinger.language_model import LanguageModel, DEFAULT_SYMBOL_SET
7
+ from textslinger.exceptions import InvalidLanguageModelException
8
+ from scipy.special import logsumexp
9
+ from scipy.special import softmax
10
+ import time
11
+ from collections import defaultdict
12
+ from typing import Final
13
+
14
+
15
+ class CausalLanguageModel(LanguageModel):
16
+ """Character language model based on a pre-trained causal model, GPT-2 by default."""
17
+
18
+ def __init__(self,
19
+ symbol_set: List[str],
20
+ lang_model_name: str,
21
+ lm_path: str = None,
22
+ lm_device: str = "cpu",
23
+ lm_left_context: str = "",
24
+ beam_width: int = None,
25
+ fp16: bool = False,
26
+ mixed_case_context: bool = False,
27
+ case_simple: bool = False,
28
+ max_completed: int = None,
29
+ ):
30
+ """
31
+ Initialize instance variables and load the language model with given path
32
+ Args:
33
+ symbol_set - list of symbol strings
34
+ lang_model_name - name of the Hugging Face casual language model to load
35
+ lm_path - load fine-tuned model from specified directory
36
+ lm_device - device to use for making predictions (cpu, mps, or cuda)
37
+ lm_left_context - text to condition start of sentence on
38
+ beam_width - how many hypotheses to keep during the search, None=off
39
+ fp16 - convert model to fp16 to save memory/compute on CUDA
40
+ mixed_case_context - use mixed case for language model left context
41
+ case_simple - simple fixing of left context case
42
+ max_completed - stop search once we reach this many completed hypotheses, None=don't prune
43
+ """
44
+ super().__init__(symbol_set=symbol_set)
45
+ self.model = None
46
+ self.tokenizer = None
47
+ self.vocab_size = 0
48
+ self.valid_vocab = []
49
+ self.vocab = defaultdict(list)
50
+ # Since subword token ids are integers, use a list instead of a dictionary
51
+ self.index_to_word = []
52
+ self.index_to_word_lower = []
53
+ self.symbol_set_lower = None
54
+ self.device = lm_device
55
+ self.left_context = lm_left_context
56
+ self.fp16 = fp16
57
+ self.mixed_case_context = mixed_case_context
58
+ self.case_simple = case_simple
59
+ self.max_completed = max_completed
60
+
61
+ if not max_completed and not beam_width:
62
+ print(f"WARNING: using causal language model without any pruning, this can be slow!")
63
+ else:
64
+ print(f"Causal language model, beam_width {beam_width}, max_completed {max_completed}")
65
+
66
+ # We optionally load the model from a local directory, but if this is not
67
+ # specified, we load a Hugging Face model
68
+ self.model_name = lang_model_name
69
+ self.model_dir = lm_path if lm_path else self.model_name
70
+
71
+ # Parameters for the search
72
+ self.beam_width = beam_width
73
+
74
+ # Simple heuristic to correct case in the LM context
75
+ self.simple_upper_words = {"i": "I",
76
+ "i'll": "I'll",
77
+ "i've": "I've",
78
+ "i'd": "I'd",
79
+ "i'm": "I'm"}
80
+
81
+ # Track how much time spent in different parts of the predict function
82
+ self.predict_total_ns = 0
83
+ self.predict_inference_ns = 0
84
+
85
+
86
+ # Are we a model that automatically inserts a start token that we need to get rid of
87
+ self.drop_first_token = False
88
+
89
+ self.load()
90
+
91
+ def _build_vocab(self) -> None:
92
+ """
93
+ Build a vocabulary table mapping token index to word strings
94
+ """
95
+
96
+ # Loop over all the subword tokens in the LLM
97
+ for i in range(self.vocab_size):
98
+ # Create a map from the subword token integer ID to the mixed and lowercase string versions
99
+ word = self.tokenizer.decode([i])
100
+ word_lower = word.lower()
101
+ self.index_to_word += word,
102
+ self.index_to_word_lower += word_lower,
103
+
104
+ # Check if all the characters in the subword token are in our valid symbol set
105
+ valid = True
106
+ for ch in word_lower:
107
+ if ch == ' ':
108
+ continue
109
+ elif ch not in self.symbol_set_lower:
110
+ valid = False
111
+ break
112
+
113
+ # If the subword token symbols are all valid, then add it to the list of valid token IDs
114
+ if valid:
115
+ self.valid_vocab += i,
116
+ # Add this token ID to all lists for its valid text prefixes
117
+ for j in range(len(word)):
118
+ key = word_lower[0:j + 1]
119
+ self.vocab[key] += i,
120
+
121
+ # When done, self.vocab can be used to map to possible following subword tokens given some text, e.g.:
122
+ # self.vocab["cyclo"] = [47495, 49484]
123
+ # self.index_to_word[self.vocab["cyclo"][0]] = cyclop
124
+ # self.index_to_word[self.vocab["cyclo"][1]] = cyclopedia
125
+
126
+ (self.model_name.startswith("facebook/opt") or self.model_name.startswith("figmtu/opt") or "Llama-3.1" in self.model_name)
127
+
128
+ # Get the index we use for the start or end pseudo-word
129
+ if self.left_context == "":
130
+ if "gpt2" in self.model_name:
131
+ self.left_context = "<|endoftext|>"
132
+ elif "Llama-3.1" in self.model_name:
133
+ self.left_context = "<|begin_of_text|>"
134
+ # Seems to have both sentence start and end tokens: https://docs.mistral.ai/guides/tokenization/
135
+ elif "Mistral" in self.model_name:
136
+ self.left_context = "<s>"
137
+ else:
138
+ self.left_context = "</s>"
139
+
140
+ # OPT, Llama and Mistral all insert start token
141
+ self.drop_first_token = (self.model_name.startswith("facebook/opt") or
142
+ self.model_name.startswith("figmtu/opt") or
143
+ "Llama-3.1" in self.model_name or
144
+ "Mistral" in self.model_name)
145
+
146
+ # Get token id(s) for the left context we condition all sentences on
147
+ self.left_context_tokens = self._encode(self.left_context)
148
+ print(f"Causal: left_context = '{self.left_context}', left_context_tokens = {self.left_context_tokens}")
149
+
150
+ def _encode(self, text: str) -> List[int]:
151
+ tokens = self.tokenizer.encode(text)
152
+ # Both OPT and Llama automatically insert a start token which we want to control ourselves
153
+ if len(tokens) > 1 and self.drop_first_token:
154
+ tokens = tokens[1:]
155
+
156
+ return tokens
157
+
158
+ def _sequence_string(self, sequence: List[int]) -> str:
159
+ """
160
+ Convert a sequence of subword token IDs into a string with each token in ()'s
161
+ :param sequence: List of subword token IDs
162
+ :return: String
163
+ """
164
+ return "".join([f"({self.index_to_word[x]})" for x in sequence])
165
+
166
+ def get_all_tokens_text(self):
167
+ """
168
+ Return an array with the text of all subword tokens.
169
+ The array is in order by the integer index into the vocabulary.
170
+ This is mostly just for exploring the tokens in different LLMs.
171
+ :return: Array of subword token text strings.
172
+ """
173
+ result = []
174
+ for i in range(self.vocab_size):
175
+ result.append(self.tokenizer.decode([i]))
176
+ return result
177
+
178
+ def predict(self, evidence: List[str]) -> List[Tuple]:
179
+ """
180
+ Given an evidence of typed string, predict the probability distribution of
181
+ the next symbol
182
+ Args:
183
+ evidence - a list of characters (typed by the user)
184
+ Response:
185
+ A list of symbols with probability
186
+ """
187
+
188
+ assert self.model is not None, "language model does not exist!"
189
+ start_ns = time.time_ns()
190
+
191
+ context = "".join(evidence)
192
+
193
+ # If using the simple case feature, we need to go through the actual
194
+ # left context and capitalize the first letter in the sentence as
195
+ # well as any word in our list of words that should be capitalized.
196
+ if self.case_simple and len(context) > 0:
197
+ cased_context = ""
198
+ words = context.split()
199
+ for i, word in enumerate(words):
200
+ if i == 0 and word[0] >= 'a' and word[0] <= 'z':
201
+ word = word[0].upper() + word[1:]
202
+ if i > 0:
203
+ if word in self.simple_upper_words:
204
+ word = self.simple_upper_words[word]
205
+ cased_context += " "
206
+ cased_context += word
207
+ # Handle ending space in the context
208
+ if context[-1] == ' ':
209
+ cased_context += " "
210
+ context = cased_context
211
+
212
+ context_lower = context.lower()
213
+
214
+ # Index in the hypothesis string that is the next character after our context
215
+ target_pos = len(context_lower)
216
+
217
+ # For stats purposes track length of the prefix we are extending from space to match
218
+ # prefix_len = target_pos
219
+
220
+ # Look for the last space in the context, or -1 if no begin_text in context yet
221
+ pos = context_lower.rfind(" ")
222
+ tokens = []
223
+ tokens.extend(self.left_context_tokens)
224
+ if pos >= 0:
225
+ # Optionally, we condition on upper and lower case left context
226
+ if self.mixed_case_context:
227
+ truncated_context = context[0:pos]
228
+ else:
229
+ truncated_context = context_lower[0:pos]
230
+ tokens.extend(self._encode(truncated_context))
231
+ #prefix_len -= pos
232
+
233
+ #print(f"DEBUG, {context_lower} pos {pos}, prefix_len {prefix_len}")
234
+
235
+ # Constant indexes for use with the hypotheses tuples
236
+ LOGP: Final[int] = 0
237
+ SEQ: Final[int] = 1
238
+ LEN: Final[int] = 2
239
+
240
+ # Our starting hypothesis that we'll be extending.
241
+ # Format is (log likelihood, token id sequence, text length).
242
+ # Note: we only include tokens after any in left context.
243
+ start_length = 0
244
+ for x in tokens[len(self.left_context_tokens):]:
245
+ start_length += len(self.index_to_word_lower[x])
246
+ current_hypos = [(0.0, tokens, start_length)]
247
+
248
+ # We use a priority queue to track the top hypotheses during the beam search.
249
+ # For a beam of 8, empirical testing showed this was about the same amount
250
+ # of time as a simpler list that used a linear search to replace when full.
251
+ heapq.heapify(current_hypos)
252
+
253
+ # Create a hash mapping each valid following character to a list of log probabilities
254
+ char_to_log_probs = defaultdict(list)
255
+
256
+ # Add new extended hypotheses to this heap
257
+ next_hypos = []
258
+
259
+ # Tracks count of completed hypotheses
260
+ completed = 0
261
+
262
+ # Used to signal to while loop to stop the search
263
+ done = False
264
+
265
+ # Start a beam search forward from the backed off token sequence.
266
+ # Each iteration of this while loop extends hypotheses by all valid tokens.
267
+ # We only keep at most self.beam_width hypotheses in the valid heap.
268
+ # Stop extending search once we reach our max completed target.
269
+ while len(current_hypos) > 0 and not done:
270
+ # We'll explore hypothesis in order from most probable to least.
271
+ # This has little impact on how long it takes since this is only sorting a small number of things.
272
+ # But it is important with max_completed pruning since we want to bias for completing high probability things.
273
+ current_hypos.sort(reverse=True)
274
+
275
+ # Work on the hypotheses from the last round of extension.
276
+ # Create the torch tensor for the inference with a row for each hypothesis.
277
+ tokens_tensor = torch.tensor([x[SEQ] for x in current_hypos]).reshape(len(current_hypos), -1).to(self.device)
278
+
279
+ before_inference_ns = time.time_ns()
280
+ # Ask the LLM to predict tokens that come after our current set of hypotheses
281
+ with torch.no_grad():
282
+ # Compute the probabilities from the logits
283
+ log_probs = torch.log_softmax(self.model(tokens_tensor).logits[:, -1, :], dim=1)
284
+
285
+ # Create a big 2D tensor where each row is that hypothesis' current likelihood.
286
+ # First create a list of just the hypotheses' likelihoods.
287
+ # Then reshape to be a column vector.
288
+ # Then duplicate the column based on the number of subword tokens in the LLM.
289
+ add_tensor = torch.tensor([x[LOGP] for x in current_hypos]).reshape((log_probs.size()[0], 1)).repeat(1, log_probs.size()[1]).to(self.device)
290
+
291
+ # Add the current likelihoods with each subtoken's probability.
292
+ # Move it back to the CPU and convert to numpy since this makes it a lot faster to access for some reason.
293
+ new_log_probs = torch.add(log_probs, add_tensor).detach().cpu().numpy()
294
+ self.predict_inference_ns += time.time_ns() - before_inference_ns
295
+
296
+ for current_index, current in enumerate(current_hypos):
297
+ vocab = []
298
+ extra_vocab = []
299
+ # Extending this hypothesis must match the remaining text
300
+ remaining_context = context_lower[current[LEN]:]
301
+ if len(remaining_context) == 0:
302
+ # There is no remaining context thus all subword tokens that are valid under our symbol set
303
+ # should be considered when computing the probability of the next character.
304
+ vocab = self.valid_vocab
305
+ else:
306
+ if remaining_context in self.vocab:
307
+ # We have a list of subword tokens that match the remaining text.
308
+ # They could be the same length as the remaining text or longer and have the remaining text as a prefix.
309
+ vocab = self.vocab[remaining_context]
310
+
311
+ # We may need to use a subword token that doesn't completely consume the remaining text.
312
+ # Find these by tokenizing all possible lengths of text starting from the current position.
313
+ for i in range(1, len(remaining_context)):
314
+ tokenization = self._encode(context_lower[current[LEN]:current[LEN] + i])
315
+ # Ignore tokenizations involving multiple tokens since they involve an ID we would have already added.
316
+ if len(tokenization) == 1:
317
+ extra_vocab += tokenization[0],
318
+
319
+ # The below code takes the most time, results from pprofile on 5 phrases on an 2080 GPU:
320
+ # 299| 22484582| 89.5763| 3.9839e-06| 14.24%| for token_id in itertools.chain(vocab, extra_vocab):
321
+ # 300| 0| 0| 0| 0.00%| # For a hypothesis to finish it must extend beyond the existing typed context
322
+ # 301| 22483271| 93.7939| 4.17172e-06| 14.91%| subword_len = len(self.index_to_word_lower[token_id])
323
+ # 302| 22483271| 92.8608| 4.13022e-06| 14.76%| if (current[LEN] + subword_len) > len(context):
324
+ # 303| 0| 0| 0| 0.00%| # Add this likelihood to the list for the character at the prediction position.
325
+ # 304| 0| 0| 0| 0.00%| # Tracking the list and doing logsumpexp later was faster than doing it for each add.
326
+ # 305| 22480431| 106.353| 4.73094e-06| 16.90%| char_to_log_probs[self.index_to_word_lower[token_id][target_pos - current[LEN]]] += new_log_probs[current_index][token_id],
327
+ # 306| 22480431| 92.689| 4.1231e-06| 14.73%| completed += 1
328
+ # 307| 2840| 0.0124488| 4.38338e-06| 0.00%| elif not self.beam_width or len(next_hypos) <
329
+ #
330
+ # Tuning notes:
331
+ # - With a beam of 8 and max completed of 32,000, getting around 5x speedup on written dev set.
332
+ # - This results in a PPL increase of 0.0025 versus old results using only beam of >= 8.
333
+ # - Pruning based on log probability difference and based on minimum number of hypotheses per symbol in alphabet did worse.
334
+ # - Code for these other pruning methods was removed.
335
+ # Possible ways to make it faster:
336
+ # - Stop part way through the below for loop over (vocab, extra_vocab). But this seems weird since the token IDs are in
337
+ # no particular order, we'd be just stopping early on the last hypothesis being explored by the enclosing loop.
338
+ # - Sort the rows in the log prob results on the GPU. Use these to limit which token IDs we explore in the below
339
+ # for loop. Is it possible to do this without introducing too much extra work to limit to the high probability ones?
340
+
341
+ # Create a list of token indexes that are a prefix of the target text.
342
+ # We go over all the integer IDs in the vocab and extra_vocab lists.
343
+ for token_id in itertools.chain(vocab, extra_vocab):
344
+ # For a hypothesis to finish it must extend beyond the existing typed context
345
+ subword_len = len(self.index_to_word_lower[token_id])
346
+ if (current[LEN] + subword_len) > len(context):
347
+ # Add this likelihood to the list for the character at the prediction position.
348
+ # Tracking the list and doing logsumpexp later was faster than doing it for each add.
349
+ char_to_log_probs[self.index_to_word_lower[token_id][target_pos - current[LEN]]] += new_log_probs[current_index][token_id],
350
+ completed += 1
351
+ elif not self.beam_width or len(next_hypos) < self.beam_width:
352
+ # If we are under the beam limit then just add it
353
+ heapq.heappush(next_hypos,
354
+ (new_log_probs[current_index][token_id],
355
+ current[SEQ] + [token_id],
356
+ current[LEN] + subword_len))
357
+ elif new_log_probs[current_index][token_id] > next_hypos[0][LOGP]:
358
+ # Or replace the worst hypotheses with the new one
359
+ heapq.heappushpop(next_hypos,
360
+ (new_log_probs[current_index][token_id],
361
+ current[SEQ] + [token_id],
362
+ current[LEN] + subword_len))
363
+
364
+ # Break out of the for loop over hypotheses and while loop if we reach our max completed goal
365
+ if self.max_completed and completed >= self.max_completed:
366
+ done = True
367
+ break
368
+
369
+ # Swap in the extended set as the new current working set
370
+ current_hypos = next_hypos
371
+ next_hypos = []
372
+
373
+ # Parallel array to symbol_set for storing the marginals
374
+ char_probs = []
375
+ for ch in self.symbol_set_lower:
376
+ # Handle cases when symbols are never seen
377
+ if ch in char_to_log_probs:
378
+ char_probs += logsumexp(char_to_log_probs[ch]),
379
+ else:
380
+ char_probs += float("-inf"),
381
+
382
+ # Normalize to a distribution that sums to 1
383
+ char_probs = softmax(char_probs)
384
+
385
+ next_char_pred = {}
386
+ for i, ch in enumerate(self.symbol_set_lower):
387
+ # This works even if ch is a space
388
+ next_char_pred[ch.upper()] = char_probs[i]
389
+
390
+ end_ns = time.time_ns()
391
+ self.predict_total_ns += end_ns - start_ns
392
+
393
+ return list(sorted(next_char_pred.items(), key=lambda item: item[1], reverse=True))
394
+
395
+ def dump_predict_times(self) -> None:
396
+ """Print some stats about the prediction timing"""
397
+ if self.predict_total_ns > 0:
398
+ print(f"Predict %: "
399
+ f"inference {self.predict_inference_ns / self.predict_total_ns * 100.0:.3f}")
400
+
401
+ def load(self) -> None:
402
+ """
403
+ Load the language model and tokenizer, initialize class variables
404
+ """
405
+ try:
406
+ self.tokenizer = AutoTokenizer.from_pretrained(self.model_name, use_fast=False)
407
+ except BaseException:
408
+ raise InvalidLanguageModelException(f"{self.model_name} is not a valid model identifier on HuggingFace.")
409
+ self.vocab_size = self.tokenizer.vocab_size
410
+ try:
411
+ self.model = AutoModelForCausalLM.from_pretrained(self.model_dir)
412
+ if self.fp16 and self.device == "cuda":
413
+ self.model = self.model.half()
414
+ except:
415
+ raise InvalidLanguageModelException(f"{self.model_dir} is not a valid local folder or model identifier on HuggingFace.")
416
+
417
+ self.model.eval()
418
+
419
+ self.model.to(self.device)
420
+
421
+ self.symbol_set_lower = []
422
+
423
+ for ch in self.symbol_set:
424
+ # This works even if ch is a space
425
+ self.symbol_set_lower.append(ch.lower())
426
+
427
+ self._build_vocab()
428
+
429
+ def get_num_parameters(self) -> int:
430
+ """
431
+ Find out how many parameters the loaded model has
432
+ Args:
433
+ Response:
434
+ Integer number of parameters in the transformer model
435
+ """
436
+ return sum(p.numel() for p in self.model.parameters())
437
+
@@ -0,0 +1,227 @@
1
+ from collections import Counter
2
+ import torch
3
+ from typing import List, Tuple
4
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
5
+ from textslinger.language_model import LanguageModel
6
+ from textslinger.exceptions import InvalidLanguageModelException, WordPredictionsNotSupportedException
7
+ from scipy.special import softmax
8
+
9
+
10
+ class ClassifierLanguageModel(LanguageModel):
11
+ """Character language model based on a transformer with a classification head"""
12
+
13
+ def __init__(self,
14
+ symbol_set: List[str],
15
+ lang_model_name: str,
16
+ lm_path: str = None,
17
+ lm_device: str = "cpu",
18
+ lm_left_context: str = "",
19
+ beam_width: int = 8,
20
+ batch_size: int = 8,
21
+ fp16: bool = False,
22
+ mixed_case_context = False,
23
+ case_simple = False,
24
+ ):
25
+ """
26
+ Initialize instance variables and load the language model with given path
27
+ Args:
28
+ response_type - SYMBOL only
29
+ symbol_set - list of symbol strings
30
+ lang_model_name - name of the Hugging Face casual language model to load
31
+ lm_path - load fine-tuned model from specified directory
32
+ lm_device - device to use for making predictions (cpu, mps, or cuda)
33
+ lm_left_context - text to condition start of sentence on
34
+ beam_width - how many hypotheses to keep during the search
35
+ batch_size - how many sequences to pass in at a time during inference
36
+ fp16 - convert model to fp16 to save memory/compute on CUDA
37
+ mixed_case_context - use mixed case for language model left context
38
+ case_simple - simple fixing of left context case
39
+ """
40
+ super().__init__(symbol_set=symbol_set)
41
+ self.model = None
42
+ self.tokenizer = None
43
+ self.vocab_size = 0
44
+ self.valid_vocab = []
45
+ self.vocab = {}
46
+ self.longest_token = 0
47
+ self.index_to_word = {}
48
+ self.index_to_word_lower = {}
49
+ self.symbol_set_lower = None
50
+ self.device = lm_device
51
+ self.left_context = lm_left_context
52
+ self.fp16 = fp16
53
+ self.mixed_case_context = mixed_case_context
54
+ self.case_simple = case_simple
55
+
56
+ # We optionally load the model from a local directory, but if this is not
57
+ # specified, we load a Hugging Face model
58
+ self.model_name = lang_model_name
59
+ self.model_dir = lm_path if lm_path else self.model_name
60
+
61
+ # parameters for search
62
+ self.beam_width = beam_width
63
+ self.batch_size = batch_size
64
+
65
+ self.simple_upper_words = {"i": "I",
66
+ "i'll": "I'll",
67
+ "i've": "I've",
68
+ "i'd": "I'd",
69
+ "i'm": "I'm"}
70
+ self.load()
71
+
72
+ def _build_vocab(self) -> None:
73
+ """
74
+ Build a vocabulary table mapping token index to word strings
75
+ """
76
+
77
+ for i in range(self.vocab_size):
78
+ word = self.tokenizer.decode([i])
79
+ word_lower = word.lower()
80
+ self.index_to_word[i] = word
81
+ self.index_to_word_lower[i] = word_lower
82
+ valid = True
83
+ for ch in word_lower:
84
+ if ch not in self.symbol_set_lower:
85
+ valid = False
86
+ break
87
+ if valid:
88
+ self.valid_vocab += i,
89
+ length = len(word)
90
+ if length > self.longest_token:
91
+ self.longest_token = length
92
+ for j in range(length):
93
+ key = word_lower[0:j + 1]
94
+ if key not in self.vocab:
95
+ self.vocab[key] = []
96
+ self.vocab[key] += i,
97
+
98
+ # Get the index we use for the start or end pseudo-word
99
+ if self.left_context == "":
100
+ if "gpt2" in self.model_name:
101
+ self.left_context = "<|endoftext|>"
102
+ else:
103
+ self.left_context = "</s>"
104
+ # Get token id(s) for the left context we condition all sentences on
105
+ self.left_context_tokens = self._encode(self.left_context)
106
+ # print(f"left_context_tokens = {self.left_context_tokens}")
107
+
108
+ def _encode(self, text: str) -> List[int]:
109
+ if text == "":
110
+ return []
111
+
112
+ tokens = self.tokenizer.encode(text)
113
+ if len(tokens) > 1 and (self.model_name.startswith("facebook/opt") or self.model_name.startswith("figmtu/opt")):
114
+ tokens = tokens[1:]
115
+
116
+ return tokens
117
+
118
+ def predict_character(self, evidence: List[str]) -> List[Tuple]:
119
+ """
120
+ Given an evidence of typed string, predict the probability distribution of
121
+ the next symbol
122
+ Args:
123
+ evidence - a list of characters (typed by the user)
124
+ Response:
125
+ A list of symbols with probability
126
+ """
127
+
128
+ assert self.model is not None, "language model does not exist!"
129
+
130
+ context = "".join(evidence)
131
+
132
+ if self.case_simple and len(context) > 0:
133
+ cased_context = ""
134
+ words = context.split()
135
+ for i, word in enumerate(words):
136
+ if i == 0 and word[0] >= 'a' and word[0] <= 'z':
137
+ word = word[0].upper() + word[1:]
138
+ if i > 0:
139
+ if word in self.simple_upper_words:
140
+ word = self.simple_upper_words[word]
141
+ cased_context += " "
142
+ cased_context += word
143
+ # Handle ending space in the context
144
+ if context[-1] == ' ':
145
+ cased_context += " "
146
+ #print(f"Simple casing of left context, from '{context}' to '{cased_context}'")
147
+ context = cased_context
148
+
149
+ else:
150
+ context = context.lower()
151
+
152
+ tokens = []
153
+ tokens.extend(self.left_context_tokens)
154
+
155
+ # Optionally, we condition on upper and lower case left context
156
+ if not self.mixed_case_context:
157
+ context = context.lower()
158
+ tokens.extend(self._encode(context))
159
+
160
+ tensor = torch.tensor([tokens]).to(self.device)
161
+
162
+ with torch.no_grad():
163
+ logits = self.model(tensor).logits
164
+ char_probs = torch.softmax(logits, dim=1).to("cpu").numpy()[0]
165
+
166
+ keys = [*"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' "]
167
+ next_char_pred = Counter(dict(zip(keys, char_probs)))
168
+
169
+ for low in self.symbol_set_lower:
170
+ if low.isalpha():
171
+ next_char_pred[low.upper()] += next_char_pred[low]
172
+ del next_char_pred[low]
173
+
174
+ return list(sorted(next_char_pred.items(), key=lambda item: item[1], reverse=True))
175
+
176
+ def predict_word(self,
177
+ left_context: List[str],
178
+ right_context: List[str] = [" "],
179
+ nbest: int = 3,
180
+ ) -> List[Tuple]:
181
+ """
182
+ Using the provided data, compute log likelihoods over the next sequence of symbols
183
+ Args:
184
+ left_context - The text that precedes the desired prediction.
185
+ right_context - The text that will follow the desired prediction. For simple word
186
+ predictions, this should be a single space.
187
+ nbest - The number of top predictions to return
188
+
189
+ Response:
190
+ A list of tuples, (predicted text, log probability)
191
+ """
192
+ raise WordPredictionsNotSupportedException("Word predictions are not supported for this model.")
193
+
194
+ def load(self) -> None:
195
+ """
196
+ Load the language model and tokenizer, initialize class variables
197
+ """
198
+ try:
199
+ self.tokenizer = AutoTokenizer.from_pretrained(self.model_name, use_fast=False)
200
+ except BaseException:
201
+ raise InvalidLanguageModelException(f"{self.model_name} is not a valid model identifier on HuggingFace.")
202
+ self.vocab_size = self.tokenizer.vocab_size
203
+ try:
204
+ self.model = AutoModelForSequenceClassification.from_pretrained(self.model_dir)
205
+ if self.fp16 and self.device == "cuda":
206
+ self.model = self.model.half()
207
+ except:
208
+ raise InvalidLanguageModelException(f"{self.model_dir} is not a valid local folder or model identifier on HuggingFace.")
209
+
210
+ self.model.eval()
211
+
212
+ self.model.to(self.device)
213
+
214
+ self.symbol_set_lower = []
215
+ for ch in self.symbol_set:
216
+ self.symbol_set_lower.append(ch.lower())
217
+
218
+ self._build_vocab()
219
+
220
+ def get_num_parameters(self) -> int:
221
+ """
222
+ Find out how many parameters the loaded model has
223
+ Args:
224
+ Response:
225
+ Integer number of parameters in the transformer model
226
+ """
227
+ return sum(p.numel() for p in self.model.parameters())
@@ -0,0 +1,25 @@
1
+
2
+ class TextPredictException(Exception):
3
+ """textpredict core exception.
4
+
5
+ Thrown when an error occurs specific to textpredict core concepts.
6
+ """
7
+
8
+ def __init__(self, message, errors=None):
9
+ super().__init__(message)
10
+ self.message = message
11
+ self.errors = errors
12
+
13
+
14
+ class InvalidLanguageModelException(TextPredictException):
15
+ """Invalid Language Model Exception.
16
+
17
+ Thrown when attempting to load a language model from an invalid path"""
18
+ ...
19
+
20
+
21
+ class KenLMInstallationException(TextPredictException):
22
+ """KenLM Installation Exception.
23
+
24
+ Thrown when attempting to import kenlm without installing the module"""
25
+ ...
@@ -0,0 +1,52 @@
1
+ """Defines the language model base class."""
2
+ from abc import ABC, abstractmethod
3
+ from typing import List, Tuple
4
+ from string import ascii_uppercase
5
+
6
+ # Returns all uppercase English letters
7
+ def alphabet():
8
+ """Alphabet.
9
+
10
+ Function used to standardize the symbols we use as alphabet.
11
+
12
+ Returns
13
+ -------
14
+ array of letters.
15
+ """
16
+ return list(ascii_uppercase)
17
+
18
+ DEFAULT_SYMBOL_SET = alphabet() + [' ']
19
+
20
+ class LanguageModel(ABC):
21
+ """Parent class for Language Models."""
22
+
23
+ symbol_set: List[str] = None
24
+
25
+ def __init__(self,
26
+ symbol_set: List[str] = DEFAULT_SYMBOL_SET):
27
+ self.symbol_set = symbol_set
28
+
29
+ @classmethod
30
+ def name(cls) -> str:
31
+ """Model name used for configuration"""
32
+ suffix = 'LanguageModel'
33
+ if cls.__name__.endswith(suffix):
34
+ return cls.__name__[0:-len(suffix)].upper()
35
+ return cls.__name__.upper()
36
+
37
+ @abstractmethod
38
+ def predict(self, evidence: List[str]) -> List[Tuple]:
39
+ """
40
+ Using the provided data, compute log likelihoods over the entire symbol set.
41
+ Args:
42
+ evidence - ['A', 'B']
43
+
44
+ Response:
45
+ probability - dependent on response type, a list of words or symbols with probability
46
+ """
47
+ ...
48
+
49
+ @abstractmethod
50
+ def load(self) -> None:
51
+ """Load model from the provided assets/path"""
52
+ ...
textslinger/mixture.py ADDED
@@ -0,0 +1,126 @@
1
+ from collections import Counter
2
+ from typing import Optional, Dict, List, Tuple
3
+ from math import isclose
4
+
5
+ from textslinger.language_model import LanguageModel
6
+ from textslinger.exceptions import InvalidLanguageModelException
7
+
8
+ # pylint: disable=unused-import
9
+ # flake8: noqa
10
+ """All supported models must be imported"""
11
+ from textslinger.causal import CausalLanguageModel
12
+ from textslinger.ngram import NGramLanguageModel
13
+
14
+ class MixtureLanguageModel(LanguageModel):
15
+ """
16
+ Character language model that mixes any combination of other models
17
+ """
18
+
19
+ supported_lm_types = ["CAUSAL", "NGRAM", "UNIFORM"]
20
+
21
+ @staticmethod
22
+ def language_models_by_name() -> Dict[str, LanguageModel]:
23
+ """Returns available language models indexed by name."""
24
+ return {lm.name(): lm for lm in LanguageModel.__subclasses__()}
25
+
26
+ @staticmethod
27
+ def validate_parameters(types: List[str], weights: List[float], params: List[Dict[str, str]]):
28
+ if params is not None:
29
+ if (types is None) or (len(types) != len(params)):
30
+ raise InvalidLanguageModelException("Length of parameters does not match length of types")
31
+
32
+ if weights is not None:
33
+ if (types is None) or (len(types) != len(weights)):
34
+ raise InvalidLanguageModelException("Length of weights does not match length of types")
35
+ if not isclose(sum(weights), 1.0, abs_tol=1e-05):
36
+ raise InvalidLanguageModelException("Weights do not sum to 1")
37
+
38
+ if types is not None:
39
+ if weights is None:
40
+ raise InvalidLanguageModelException("Model weights not provided")
41
+ if params is None:
42
+ raise InvalidLanguageModelException("Model parameters not provided")
43
+ if not all(x in MixtureLanguageModel.supported_lm_types for x in types):
44
+ raise InvalidLanguageModelException(f"Supported model types: {MixtureLanguageModel.supported_lm_types}")
45
+
46
+ def __init__(self,
47
+ symbol_set: List[str],
48
+ lm_types: List[str],
49
+ lm_weights: List[float],
50
+ lm_params: List[Dict[str, str]] = None):
51
+ """
52
+ Initialize instance variables and load the language model with given path
53
+ Args:
54
+ response_type - SYMBOL only
55
+ symbol_set - list of symbol strings
56
+ lm_types - list of types of models to mix
57
+ lm_weights - list of weights to use when mixing the models
58
+ lm_params - list of dictionaries to pass as parameters for each model's instantiation
59
+ """
60
+
61
+ MixtureLanguageModel.validate_parameters(lm_types, lm_weights, lm_params)
62
+
63
+ super().__init__(symbol_set=symbol_set)
64
+ self.models = list()
65
+ self.symbol_set = symbol_set
66
+
67
+ self.lm_types = lm_types
68
+ self.lm_weights = lm_weights
69
+ self.lm_params = lm_params
70
+
71
+ MixtureLanguageModel.validate_parameters(self.lm_types, self.lm_weights, self.lm_params)
72
+
73
+ self.load()
74
+
75
+ @staticmethod
76
+ def interpolate_language_models(lms: List[Dict[str, float]], coeffs: List[float]) -> List[Tuple]:
77
+ """
78
+ interpolate two or more language models
79
+ Args:
80
+ lms - output from the language models (a list of dicts with char as keys and prob as values)
81
+ coeffs - list of rescale coefficients, lms[0] will be scaled by coeffs[0] and so on
82
+ Response:
83
+ a list of (char, prob) tuples representing an interpolated language model
84
+ """
85
+ combined_lm = Counter()
86
+
87
+ for i, lm in enumerate(lms):
88
+ for char in lm:
89
+ combined_lm[char] += lm[char] * coeffs[i]
90
+
91
+ return list(sorted(combined_lm.items(), key=lambda item: item[1], reverse=True))
92
+
93
+ def predict(self, evidence: List[str]) -> List[Tuple]:
94
+ """
95
+ Given an evidence of typed string, predict the probability distribution of
96
+ the next symbol
97
+ Args:
98
+ evidence - a list of characters (typed by the user)
99
+ Response:
100
+ A list of symbols with probability
101
+ """
102
+
103
+ pred_list = list()
104
+
105
+ # Generate predictions from each component language model
106
+ pred_list = [dict(model.predict(evidence)) for model in self.models]
107
+
108
+ # Mix the component models
109
+ next_char_pred = MixtureLanguageModel.interpolate_language_models(pred_list, self.lm_weights)
110
+
111
+ return next_char_pred
112
+
113
+ def load(self) -> None:
114
+ """
115
+ Load the language models to be mixed
116
+ """
117
+ language_models = MixtureLanguageModel.language_models_by_name()
118
+ for lm_type, params in zip(self.lm_types, self.lm_params):
119
+ model = language_models[lm_type]
120
+ lm = None
121
+ try:
122
+ lm = model(self.symbol_set, **params)
123
+ except InvalidLanguageModelException as e:
124
+ raise InvalidLanguageModelException(f"Error in creation of model type {lm_type}: {e.message}")
125
+
126
+ self.models.append(lm)
textslinger/ngram.py ADDED
@@ -0,0 +1,110 @@
1
+ from collections import Counter
2
+ from typing import Optional, List, Tuple
3
+ from textslinger.language_model import LanguageModel
4
+ from textslinger.exceptions import InvalidLanguageModelException
5
+ import kenlm
6
+ import numpy as np
7
+
8
+
9
+ class NGramLanguageModel(LanguageModel):
10
+ """Character n-gram language model using the KenLM library for querying"""
11
+
12
+ def __init__(self,
13
+ symbol_set: List[str],
14
+ lm_path: str,
15
+ skip_symbol_norm: Optional[bool] = False):
16
+
17
+ super().__init__(symbol_set=symbol_set)
18
+ print(f"Creating n-gram language model, lm_path = {lm_path}")
19
+ self.model = None
20
+ self.lm_path = lm_path
21
+ self.skip_symbol_norm = skip_symbol_norm
22
+ self.load()
23
+
24
+ def predict(self, evidence: List[str]) -> List[Tuple]:
25
+ """
26
+ Given an evidence of typed string, predict the probability distribution of
27
+ the next symbol
28
+ Args:
29
+ evidence - a list of characters (typed by the user)
30
+ Response:
31
+ A list of symbols with probability
32
+ """
33
+
34
+ # Do not modify the original parameter, could affect mixture model
35
+ context = evidence.copy()
36
+
37
+ if len(context) > 11:
38
+ context = context[-11:]
39
+
40
+ evidence_str = ''.join(context).lower()
41
+
42
+ for i, ch in enumerate(context):
43
+ if ch == ' ':
44
+ context[i] = "<sp>"
45
+
46
+ self.model.BeginSentenceWrite(self.state)
47
+
48
+ # Update the state one token at a time based on evidence, alternate states
49
+ for i, token in enumerate(context):
50
+ if i % 2 == 0:
51
+ self.model.BaseScore(self.state, token.lower(), self.state2)
52
+ else:
53
+ self.model.BaseScore(self.state2, token.lower(), self.state)
54
+
55
+ next_char_pred = None
56
+
57
+ # Generate the probability distribution based on the final state
58
+ if len(context) % 2 == 0:
59
+ next_char_pred = self.prob_dist(self.state)
60
+ else:
61
+ next_char_pred = self.prob_dist(self.state2)
62
+
63
+ return next_char_pred
64
+
65
+ def load(self) -> None:
66
+ """
67
+ Load the language model, initialize state variables
68
+ Args:
69
+ path: language model file path
70
+ """
71
+
72
+ try:
73
+ self.model = kenlm.LanguageModel(self.lm_path)
74
+ except BaseException:
75
+ raise InvalidLanguageModelException(
76
+ f"A valid model path must be provided for the KenLMLanguageModel.\nPath{self.lm_path} is not valid.")
77
+
78
+ self.state = kenlm.State()
79
+ self.state2 = kenlm.State()
80
+
81
+ def prob_dist(self, state: kenlm.State) -> List[Tuple]:
82
+ """
83
+ Take in a state and generate the probability distribution of next character
84
+ Args:
85
+ state - the kenlm state updated with the evidence
86
+ Response:
87
+ A list of symbols with probability
88
+ """
89
+ next_char_pred = Counter()
90
+
91
+ temp_state = kenlm.State()
92
+
93
+ for char in self.symbol_set:
94
+ # Replace the space character with KenLM's <sp> token
95
+ if char == ' ':
96
+ score = self.model.BaseScore(state, '<sp>', temp_state)
97
+ else:
98
+ score = self.model.BaseScore(state, char.lower(), temp_state)
99
+
100
+ # BaseScore returns log probs, convert by putting 10 to its power
101
+ next_char_pred[char] = pow(10, score)
102
+
103
+ # We can optionally disable normalization over our symbol set
104
+ # This is useful if we want to compare against SRILM with a LM with a larger vocab
105
+ if not self.skip_symbol_norm:
106
+ sum = np.sum(list(next_char_pred.values()))
107
+ for char in self.symbol_set:
108
+ next_char_pred[char] /= sum
109
+
110
+ return list(sorted(next_char_pred.items(), key=lambda item: item[1], reverse=True))
textslinger/uniform.py ADDED
@@ -0,0 +1,65 @@
1
+ """Uniform language model"""
2
+ from typing import Dict, List, Tuple, Union, Optional
3
+ import numpy as np
4
+ from textslinger.language_model import LanguageModel, DEFAULT_SYMBOL_SET
5
+
6
+
7
+ class UniformLanguageModel(LanguageModel):
8
+ """Language model in which probabilities for symbols are uniformly
9
+ distributed.
10
+
11
+ Parameters
12
+ ----------
13
+ response_type - SYMBOL only
14
+ symbol_set - optional specify the symbol set, otherwise uses DEFAULT_SYMBOL_SET
15
+ """
16
+
17
+ def __init__(self,
18
+ symbol_set: Optional[List[str]] = DEFAULT_SYMBOL_SET):
19
+ super().__init__(symbol_set=symbol_set)
20
+
21
+ def predict(self, evidence: Union[str, List[str]]) -> List[Tuple]:
22
+ """
23
+ Using the provided data, compute probabilities over the entire symbol.
24
+ set.
25
+
26
+ Parameters
27
+ ----------
28
+ evidence - list of previously typed symbols
29
+
30
+ Returns
31
+ -------
32
+ list of (symbol, probability) tuples
33
+ """
34
+ probs = equally_probable(self.symbol_set)
35
+ return list(zip(self.symbol_set, probs))
36
+
37
+ def load(self) -> None:
38
+ """Restore model state from the provided checkpoint"""
39
+
40
+ def equally_probable(alphabet: List[str],
41
+ specified: Optional[Dict[str, float]] = None) -> List[float]:
42
+ """Returns a list of probabilities which correspond to the provided
43
+ alphabet. Unless overridden by the specified values, all items will
44
+ have the same probability. All probabilities sum to 1.0.
45
+
46
+ Parameters:
47
+ ----------
48
+ alphabet - list of symbols; a probability will be generated for each.
49
+ specified - dict of symbol => probability values for which we want to
50
+ override the default probability.
51
+ Returns:
52
+ --------
53
+ list of probabilities (floats)
54
+ """
55
+ n_letters = len(alphabet)
56
+ if not specified:
57
+ return np.full(n_letters, 1 / n_letters)
58
+
59
+ # copy specified dict ignoring non-alphabet items
60
+ overrides = {k: specified[k] for k in alphabet if k in specified}
61
+ assert sum(overrides.values()) < 1
62
+
63
+ prob = (1 - sum(overrides.values())) / (n_letters - len(overrides))
64
+ # override specified values
65
+ return [overrides[sym] if sym in overrides else prob for sym in alphabet]
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: textslinger
3
+ Version: 0.1.0
4
+ Summary: TextSlinger text prediction toolkit.
5
+ Author-email: Dylan Gaines <dcgaines@mtu.edu>, Keith Vertanen <vertanen@mtu.edu>, CAMBI <cambi_support@googlegroups.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Keith Vertanen, Dylan Gaines
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ Project-URL: Homepage, https://www.cambi.tech/
28
+ Project-URL: Source, https://github.com/kdv123/textpredict
29
+ Platform: Linux
30
+ Platform: Windows
31
+ Platform: Mac OS-X
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Topic :: Scientific/Engineering :: Human Machine Interfaces
34
+ Classifier: Natural Language :: English
35
+ Classifier: Intended Audience :: Science/Research
36
+ Classifier: Intended Audience :: End Users/Desktop
37
+ Classifier: Intended Audience :: Developers
38
+ Classifier: Programming Language :: Python
39
+ Classifier: Programming Language :: Python :: 3.9
40
+ Classifier: Programming Language :: Python :: 3.10
41
+ Requires-Python: <3.11,>=3.9
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE.md
44
+ Requires-Dist: torch==2.2.0
45
+ Requires-Dist: torchvision==0.17.0
46
+ Requires-Dist: torchaudio==2.2.0
47
+ Requires-Dist: datasets==2.0.0
48
+ Requires-Dist: bitsandbytes==0.42.0
49
+ Requires-Dist: requests==2.32.3
50
+ Requires-Dist: kenlm==0.1
51
+ Requires-Dist: nlpaug==1.1.11
52
+ Requires-Dist: psutil==5.7.2
53
+ Requires-Dist: ipywidgets==8.1.3
54
+ Requires-Dist: sentencepiece==0.2.0
55
+ Requires-Dist: protobuf==4.25.3
56
+ Requires-Dist: evaluate==0.4.0
57
+ Requires-Dist: scikit-learn==1.2.2
58
+ Requires-Dist: accelerate==0.33.0
59
+ Requires-Dist: transformers==4.47.0
60
+ Requires-Dist: numpy==1.24.4
61
+ Requires-Dist: tqdm==4.62.2
62
+ Provides-Extra: release
63
+ Requires-Dist: twine==5.0.0; extra == "release"
64
+ Requires-Dist: build==1.2.2.post1; extra == "release"
65
+ Requires-Dist: wheel==0.43.0; extra == "release"
66
+ Dynamic: license-file
67
+
68
+ Python library for making text predictions using a language model.
69
+
70
+ **** Setting up a Python environment ****
71
+ These instructions are the same as those in the classify-aac which actually does training of models.
72
+ But the same environment should also work for evaluation using the models (though you may only need a subset of the packages).
73
+ This was tested on cheetah on 9/14/24.
74
+
75
+ If you don't have anaconda installed in your user account you'll first need to do that.
76
+ See: https://docs.anaconda.com/anaconda/install/linux/
77
+
78
+ % conda create -n aacllm python=3.10
79
+ % conda activate aacllm
80
+ % conda install pytorch torchvision torchaudio pytorch-cuda cuda mpi4py -c pytorch -c nvidia
81
+ % pip install 'git+https://github.com/potamides/uniformers.git#egg=uniformers'
82
+ % pip install --upgrade transformers
83
+ % pip install kenlm==0.1 --global-option="--max_order=12"
84
+ % pip install rbloom bitsandbytes requests nlpaug ipywidgets psutil datasets sentencepiece protobuf evaluate scikit-learn deepspeed accelerate
@@ -0,0 +1,14 @@
1
+ textslinger/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ textslinger/causal.py,sha256=QgDf6EvP1XuVmA9vevMwSyqkeTfpZhjN6ktn2s0Z8p0,21989
3
+ textslinger/classifier.py,sha256=7p1zPocHlv93WaCtC4ddCpqxhwK4JgHKOy-t45Sjm70,8972
4
+ textslinger/exceptions.py,sha256=UuetkKZj7YWTNDVxyRGXyGjLB70f1Padq3VHkhjcGG8,663
5
+ textslinger/language_model.py,sha256=bVwjRei78MV_-PbH15b_icfLCa7Isusj6t-nUDrbGUo,1388
6
+ textslinger/mixture.py,sha256=pa7WWCFDcSBpgKkK_FjfgupiiMMtc2hADRIl8_PlDaY,5026
7
+ textslinger/ngram.py,sha256=2qU_vXF2C1WTw5V96vaFcg58YAQVoaOXaFGgBmNR8Xo,3848
8
+ textslinger/uniform.py,sha256=-osO-sw96TBF26XYdo2SuoJP-cvVFCiooEmA3oy38Xc,2258
9
+ textslinger-0.1.0.dist-info/licenses/LICENSE.md,sha256=9GrMOuuZ2WSNM16xWY2bWj2r0PgqJ98dzowfYcNtDjs,1084
10
+ textslinger-0.1.0.dist-info/METADATA,sha256=3HIjEAwB1o37Z3nez0SFaFfn_yi41qSwlGXSR6bGjj4,3946
11
+ textslinger-0.1.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
12
+ textslinger-0.1.0.dist-info/top_level.txt,sha256=cYLIX2fs3IGchHPWv9BvkQ59hycDDNy2fc68WTpTGpo,12
13
+ textslinger-0.1.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
14
+ textslinger-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (78.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Keith Vertanen, Dylan Gaines
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ textslinger
@@ -0,0 +1 @@
1
+