Author SHA1 Message Date
Jason Ross 505a7864ea optimized time complexity' 2026-03-10 20:20:06 -05:00
Jason Ross da7596ad30 fix missing file 2026-03-09 14:00:16 -05:00
4 changed files with 181 additions and 63 deletions
+58 -35
View File
@@ -1,61 +1,84 @@
import argparse
import cProfile
import io
import pstats
import time
from pathlib import Path
import tiktoken
from .abbreviations import generate_emoji_abbreviations
from .reducer import reduce_tokens
from .token_counter import count_tokens
SOURCE_FILE = Path("bae_chat_logs/Source/WhatsApp_Bae_Chat.txt")
REDUCED_FILE = Path("bae_chat_logs/Reduced/WhatsApp_Chat-08-03-2026-reduced")
_DEFAULT_SOURCE = Path("bae_chat_logs/Source/WhatsApp_Bae_Chat.txt")
_DEFAULT_OUTPUT = Path("bae_chat_logs/Reduced/WhatsApp_Chat-08-03-2026-reduced")
SPLIT_OPTIONS = {'1': 'year', '2': 'month', '3': 'day', '4': 'none'}
def _fmt(seconds):
return f"{seconds * 1000:.1f}ms" if seconds < 1 else f"{seconds:.2f}s"
def prompt_split_option():
print("How would you like to split the chat output?")
print(" 1) By year")
print(" 2) By month")
print(" 3) By day")
print(" 4) No split (single file)")
while True:
choice = input("Enter choice (1-4): ").strip()
if choice in SPLIT_OPTIONS:
return SPLIT_OPTIONS[choice]
print("Invalid choice. Please enter 1, 2, 3, or 4.")
def main():
split_by = prompt_split_option()
def main(source_file=_DEFAULT_SOURCE, output_file=_DEFAULT_OUTPUT, split_by="month"):
print()
t_total = time.perf_counter()
# 1. Generate abbreviations
print("Generating emoji abbreviations...")
t0 = time.perf_counter()
abbreviations = generate_emoji_abbreviations()
print(f" {len(abbreviations)} abbreviations loaded")
print(f" {len(abbreviations)} abbreviations loaded [{_fmt(time.perf_counter() - t0)}]")
# 2. Reduce source file
print(f"Reducing {SOURCE_FILE} (split by: {split_by})...")
output_files = reduce_tokens(SOURCE_FILE, REDUCED_FILE, abbreviations, split_by)
# 2. Reduce source file (token counts come back from in-memory content — no re-reads)
print(f"Reducing {source_file} (split by: {split_by})...")
t0 = time.perf_counter()
encoding = tiktoken.get_encoding("cl100k_base")
output_files, reduced_tokens_total, source_tokens = reduce_tokens(
source_file, output_file, abbreviations, split_by, encoding=encoding
)
print(f" Reduction complete [{_fmt(time.perf_counter() - t0)}]")
for f in output_files:
print(f" Saved to {f}")
# 3. Count and display tokens
print("Counting tokens...")
source_tokens = count_tokens(SOURCE_FILE)
reduced_tokens_total = 0
for f in output_files:
tokens = count_tokens(f)
reduced_tokens_total += tokens
if len(output_files) > 1:
print(f" {f.name}: {tokens:,} tokens")
saved = source_tokens - reduced_tokens_total
print()
print(f"Source tokens: {source_tokens:,}")
print(f"Reduced tokens: {reduced_tokens_total:,}")
print(f"Tokens saved: {saved:,} ({saved / source_tokens * 100:.1f}%)")
print(f"\nTotal time: {_fmt(time.perf_counter() - t_total)}")
if __name__ == "__main__":
main()
parser = argparse.ArgumentParser()
parser.add_argument("source", nargs="?", type=Path, default=_DEFAULT_SOURCE,
help="Source chat log file (default: %(default)s)")
parser.add_argument("output", nargs="?", type=Path, default=_DEFAULT_OUTPUT,
help="Output file base path (default: %(default)s)")
parser.add_argument(
"--split",
choices=["year", "month", "week", "day", "none"],
default="none",
help="How to split the chat output (default: %(default)s)",
)
parser.add_argument(
"--profile",
nargs="?",
const=20,
type=int,
metavar="N",
help="Run with cProfile and print the top N functions by cumulative time (default: 20)",
)
args = parser.parse_args()
if args.profile:
pr = cProfile.Profile()
pr.enable()
main(args.source, args.output, args.split)
pr.disable()
buf = io.StringIO()
ps = pstats.Stats(pr, stream=buf).sort_stats(pstats.SortKey.CUMULATIVE)
ps.print_stats(args.profile)
print("\n--- cProfile output ---")
print(buf.getvalue())
else:
main(args.source, args.output, args.split)
+12
View File
@@ -0,0 +1,12 @@
import ast
from pathlib import Path
ABBREVIATIONS_FILE = Path(__file__).resolve().parent.parent / "abbreviations.txt"
def generate_emoji_abbreviations():
"""Load emoji abbreviation mappings from abbreviations.txt."""
text = ABBREVIATIONS_FILE.read_text(encoding="utf-8")
# The file contains "EMOJI_ABBREVIATIONS = { ... }" — extract the dict literal
_, _, dict_literal = text.partition("=")
return ast.literal_eval(dict_literal.strip())
+110 -28
View File
@@ -1,18 +1,77 @@
import re
from collections import defaultdict
from datetime import date
from pathlib import Path
import ahocorasick
import emoji
_NAME_REPLACEMENTS = {
'Honeybelle Ong-Jimenez Gaitano': 'Belle',
'Jason Ross': 'Jason',
}
def _reduce_line(line, abbreviations):
"""Apply all token reductions to a single line."""
line = line.replace('Honeybelle Ong-Jimenez Gaitano', 'Belle')
line = line.replace('Jason Ross', 'Jason')
line = emoji.demojize(line)
for long, short in abbreviations.items():
line = line.replace(long, short)
return line
def _build_automaton(abbreviations):
"""Build an Aho-Corasick automaton for name and multi-codepoint emoji replacements.
Single-codepoint emoji are handled separately via str.translate() (_build_translate_table),
which is a pure-C O(n) pass and much faster for high-frequency single chars.
Abbreviation strings (':long_name:' -> ':short:') are omitted — they were only needed
as a second pass after emoji.demojize(), which we no longer call.
"""
A = ahocorasick.Automaton()
for long, short in _NAME_REPLACEMENTS.items():
A.add_word(long, (len(long), short))
for char, meta in emoji.EMOJI_DATA.items():
if len(char) > 1:
en_name = meta['en']
target = abbreviations.get(en_name, en_name)
A.add_word(char, (len(char), target))
if len(A) == 0:
return None
A.make_automaton()
return A
def _build_translate_table(abbreviations):
"""Build a str.translate() table for single-codepoint emoji.
Must be applied AFTER the automaton pass so that multi-codepoint emoji (e.g. 👋🏻)
are consumed first, leaving only standalone single-codepoint occurrences.
"""
table = {}
for char, meta in emoji.EMOJI_DATA.items():
if len(char) == 1:
en_name = meta['en']
table[ord(char)] = abbreviations.get(en_name, en_name)
return table
def _apply_automaton(text, automaton):
"""Single-pass replacement using Aho-Corasick (left-to-right, longest match wins)."""
# Keep only the longest match per start position to avoid sorting dominated matches.
# Composite emoji (e.g. 👋🏻) generate overlapping matches at the same start;
# the dict ensures only the longest is retained before we sort start positions.
best = {}
for end_idx, (pat_len, replacement) in automaton.iter(text):
start = end_idx - pat_len + 1
end = end_idx + 1
existing = best.get(start)
if existing is None or end > existing[0]:
best[start] = (end, replacement)
parts = []
last_end = 0
for start in sorted(best):
end, replacement = best[start]
if start < last_end:
continue # overlapping with a previously committed match — skip
parts.append(text[last_end:start])
parts.append(replacement)
last_end = end
parts.append(text[last_end:])
return ''.join(parts)
_DATE_RE = re.compile(r'^(\d{1,2})/(\d{1,2})/(\d{2,4}),')
@@ -24,41 +83,60 @@ def _date_key(month, day, year_short, split_by):
return str(year)
elif split_by == 'month':
return f"{year}-{int(month):02d}"
elif split_by == 'week':
iso_year, iso_week, _ = date(year, int(month), int(day)).isocalendar()
return f"{iso_year}-W{iso_week:02d}"
else: # day
return f"{year}-{int(month):02d}-{int(day):02d}"
def reduce_tokens(input_file, output_file, abbreviations, split_by='none'):
def _reduce_line(line, automaton, translate_table):
if automaton is not None:
line = _apply_automaton(line, automaton)
return line.translate(translate_table)
def reduce_tokens(input_file, output_file, abbreviations, split_by='none', encoding=None):
"""Replace long usernames and convert emojis to shortened text codes.
split_by: 'none', 'year', 'month', or 'day'
Returns a list of output file paths that were written.
split_by: 'none', 'year', 'month', 'week', or 'day'
encoding: optional tiktoken encoding; if provided, token counts are computed with
two encode calls total (source + all reduced) — no re-reads needed.
Returns (list[Path], int, int) — output paths, total reduced token count,
and source token count. Token counts are -1 when encoding is None.
"""
output_file = Path(output_file)
if output_file.suffix != '.txt':
output_file = output_file.with_suffix(output_file.suffix + '.txt')
automaton = _build_automaton(abbreviations)
translate_table = _build_translate_table(abbreviations)
with open(input_file, 'r', encoding='utf-8') as f_in:
source_text = f_in.read()
source_tokens = len(encoding.encode(source_text)) if encoding is not None else -1
if split_by == 'none':
with open(input_file, 'r', encoding='utf-8') as f_in, \
open(output_file, 'w', encoding='utf-8') as f_out:
for line in f_in:
f_out.write(_reduce_line(line, abbreviations))
return [Path(output_file)]
reduced = _reduce_line(source_text, automaton, translate_table)
with open(output_file, 'w', encoding='utf-8') as f_out:
f_out.write(reduced)
reduced_tokens = len(encoding.encode(reduced)) if encoding is not None else -1
return [Path(output_file)], reduced_tokens, source_tokens
# Split mode: group lines by date bucket
buckets = defaultdict(list)
current_key = None
with open(input_file, 'r', encoding='utf-8') as f_in:
for line in f_in:
match = _DATE_RE.match(line)
if match:
month, day, year_short = match.groups()
current_key = _date_key(month, day, year_short, split_by)
reduced = _reduce_line(line, abbreviations)
if current_key is None:
current_key = '_unknown'
buckets[current_key].append(reduced)
for line in source_text.splitlines(keepends=True):
reduced = _reduce_line(line, automaton, translate_table)
match = _DATE_RE.match(reduced)
if match:
month, day, year_short = match.groups()
current_key = _date_key(month, day, year_short, split_by)
if current_key is None:
current_key = '_unknown'
buckets[current_key].append(reduced)
output_path = Path(output_file)
stem = output_path.stem if output_path.suffix else output_path.name
@@ -67,9 +145,13 @@ def reduce_tokens(input_file, output_file, abbreviations, split_by='none'):
written_files = []
for key in sorted(buckets):
content = ''.join(buckets[key])
out = parent / f"{stem}-{key}{suffix}"
with open(out, 'w', encoding='utf-8') as f_out:
f_out.writelines(buckets[key])
f_out.write(content)
written_files.append(out)
return written_files
all_reduced = ''.join(''.join(lines) for lines in buckets.values())
reduced_tokens = len(encoding.encode(all_reduced)) if encoding is not None else -1
return written_files, reduced_tokens, source_tokens
+1
View File
@@ -1,2 +1,3 @@
emoji>=2.0.0
pyahocorasick>=2.0.0
tiktoken>=0.5.0