Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions benchmarks/latency_baseline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Latency baseline profiler for sentencesplit.

Measures wall-clock latency (not token lag) for the three latency-sensitive
paths and produces a cProfile hot-function breakdown:

1. one-shot ``segment()`` -- interactive / per-request use
2. ``should_wait_for_more()`` -- the lookahead probe path
3. ``StreamSegmenter.feed()`` -- token-by-token streaming

Run with:
uv run python benchmarks/latency_baseline.py
uv run python benchmarks/latency_baseline.py --profile # adds cProfile
"""

from __future__ import annotations

import argparse
import cProfile
import pstats
import statistics
import time
from io import StringIO

from sentencesplit import Segmenter, StreamSegmenter

SHORT = "Dr. Smith went to Washington. He arrived on Jan. 5th at 3 p.m. and met with Sen. Jones."
MEDIUM = (
"Dr. Smith went to Washington. He arrived on Jan. 5th at 3 p.m. "
"The model is GPT 3.1 and it is fast. That is all for now. Goodbye. "
"She paid $4.50 for the U.S. edition (vol. 2, p. 17). Mr. Lee agreed."
)
# A larger realistic document: repeat the medium sample to ~5 KB of prose.
LARGE = " ".join([MEDIUM] * 20)

SAMPLES = {"short": SHORT, "medium": MEDIUM, "large": LARGE}


def _stats(times_ms: list[float]) -> str:
times_ms.sort()
median = statistics.median(times_ms)
mean = statistics.fmean(times_ms)
p95 = times_ms[min(len(times_ms) - 1, int(0.95 * len(times_ms)))]
p99 = times_ms[min(len(times_ms) - 1, int(0.99 * len(times_ms)))]
return f"mean={mean:.3f}ms median={median:.3f}ms p95={p95:.3f}ms p99={p99:.3f}ms"


def _time_calls(fn, iters: int) -> list[float]:
# warm up (build caches: language profile, abbreviation automaton)
for _ in range(3):
fn()
times = []
for _ in range(iters):
t0 = time.perf_counter()
fn()
times.append((time.perf_counter() - t0) * 1000)
return times


def bench_oneshot(iters: int) -> None:
print("\n== one-shot segment() (reused Segmenter) ==")
seg = Segmenter(language="en", clean=False, char_span=False)
for name, text in SAMPLES.items():
times = _time_calls(lambda t=text: seg.segment(t), iters)
print(f" {name:7} ({len(text):>4} chars): {_stats(times)}")


def bench_lookahead(iters: int) -> None:
print("\n== should_wait_for_more() (lookahead probe path) ==")
seg = Segmenter(language="en", clean=False, char_span=False)
# A text whose last segment ends in '.' triggers the probe loop.
for name, text in SAMPLES.items():
times = _time_calls(lambda t=text: seg.should_wait_for_more(t), iters)
print(f" {name:7} ({len(text):>4} chars): {_stats(times)}")


def bench_streaming(iters: int) -> None:
print("\n== StreamSegmenter.feed() (per-token wall time over a full doc) ==")
for mode in ("conservative", "aggressive"):
per_doc_ms = []
for _ in range(iters):
stream = StreamSegmenter(language="en", buffering_mode=mode)
tokens = [t + " " for t in MEDIUM.split(" ")]
t0 = time.perf_counter()
for tok in tokens:
stream.feed(tok)
stream.flush()
per_doc_ms.append((time.perf_counter() - t0) * 1000)
n_tokens = len(MEDIUM.split(" "))
per_doc_ms.sort()
med = statistics.median(per_doc_ms)
print(f" {mode:13}: whole-doc median={med:.3f}ms over {n_tokens} feeds (~{med / n_tokens:.4f}ms/token)")


def profile_path(label: str, fn, n: int) -> None:
print(f"\n##### cProfile: {label} (x{n}) #####")
pr = cProfile.Profile()
pr.enable()
for _ in range(n):
fn()
pr.disable()
s = StringIO()
pstats.Stats(pr, stream=s).strip_dirs().sort_stats("cumulative").print_stats(18)
print(s.getvalue())


def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--iters", type=int, default=2000)
ap.add_argument("--profile", action="store_true")
args = ap.parse_args()

print("sentencesplit latency baseline")
print("=" * 64)
bench_oneshot(args.iters)
bench_lookahead(max(args.iters // 4, 200))
bench_streaming(max(args.iters // 20, 50))

if args.profile:
seg = Segmenter(language="en", clean=False, char_span=False)
profile_path("segment(MEDIUM)", lambda: seg.segment(MEDIUM), 4000)
profile_path("should_wait_for_more(MEDIUM)", lambda: seg.should_wait_for_more(MEDIUM), 2000)


if __name__ == "__main__":
main()
24 changes: 19 additions & 5 deletions sentencesplit/abbreviation_replacer.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ def _replace_with_escape(txt: str, escaped: str, suffix_pattern: str, replacemen
return txt[1:]


# Constant patterns run on every ``replace()`` call. Compiling them once at import
# (rather than via a raw ``re.sub`` literal each call) skips the per-call pattern
# cache lookup in the abbreviation hot path.
# Compact time token with no leading space (e.g. "3P.M.").
_COMPACT_AMPM_RE = re.compile(r"(?<=\d)([AaPp])\.([Mm])\.")
# Sentence-boundary period after an all-uppercase 3+ part initialism ("S∯A∯T∯ ").
_UPPERCASE_INITIALISM_BOUNDARY_RE = re.compile(r"(?<=[A-Z]∯[A-Z]∯[A-Z])∯(?=\s)")
# Standalone pronoun "I" abbreviation sentinel before whitespace.
_STANDALONE_I_BOUNDARY_RE = re.compile(r"(?<![A-Za-z0-9_∯])I∯(?=\s)")
# Non-ASCII a.m./p.m. boundary restores (with and without an inner space).
_NON_ASCII_AMPM_RE = re.compile(r"(\d\s*[AaPp]∯[Mm])∯(?=\s)")
_NON_ASCII_AMPM_SPACED_RE = re.compile(r"(\d\s*[AaPp]∯\s+[Mm])∯(?=\s)")


class _AbbreviationData:
"""Pre-computed abbreviation data for a language, cached per Abbreviation class."""

Expand Down Expand Up @@ -308,7 +322,7 @@ def replace(self) -> str:
self.replace_multi_period_abbreviations()
# Protect compact time tokens with no space before them (e.g. "3P.M.")
# so a.m./p.m. rules can decide boundary vs non-boundary using context.
self.text = re.sub(r"(?<=\d)([AaPp])\.([Mm])\.", r"\1∯\2∯", self.text)
self.text = _COMPACT_AMPM_RE.sub(r"\1∯\2∯", self.text)
# Restore a sentence-boundary period when an all-uppercase multi-period
# abbreviation with 3+ parts (e.g. "S∯A∯T∯", "E∯S∯T∯") is followed
# by a space and uppercase letter.
Expand All @@ -331,7 +345,7 @@ def restore_uppercase_initialism_boundary(match):
return match.group()
return "."

self.text = re.sub(r"(?<=[A-Z]∯[A-Z]∯[A-Z])∯(?=\s)", restore_uppercase_initialism_boundary, self.text)
self.text = _UPPERCASE_INITIALISM_BOUNDARY_RE.sub(restore_uppercase_initialism_boundary, self.text)
self.text = self.protect_allcaps_imprint_abbreviations()
self.apply_ampm_boundary_rules()
if self.RESTORE_STANDALONE_I_BOUNDARIES:
Expand Down Expand Up @@ -392,7 +406,7 @@ def _restore(match):
return "I." if self._leans_split else match.group()
return "I."

return re.sub(r"(?<![A-Za-z0-9_∯])I∯(?=\s)", _restore, self.text)
return _STANDALONE_I_BOUNDARY_RE.sub(_restore, self.text)

@staticmethod
def _two_letter_initialism_key(parts: list[str]) -> str:
Expand Down Expand Up @@ -500,8 +514,8 @@ def _restore(match):
return f"{match.group(1)}."
return match.group()

self.text = re.sub(r"(\d\s*[AaPp]∯[Mm])∯(?=\s)", _restore, self.text)
self.text = re.sub(r"(\d\s*[AaPp]∯\s+[Mm])∯(?=\s)", _restore, self.text)
self.text = _NON_ASCII_AMPM_RE.sub(_restore, self.text)
self.text = _NON_ASCII_AMPM_SPACED_RE.sub(_restore, self.text)
return self.text

def replace_period_of_abbr(self, txt: str, abbr: str, escaped: str | None = None) -> str:
Expand Down
4 changes: 3 additions & 1 deletion sentencesplit/exclamation_words.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ class ExclamationWords:
# Longest first so a longer entry (e.g. "!Kung-Ekoka") is matched before a
# shorter prefix ("!Kung") that would otherwise leave a dangling suffix.
EXCLAMATION_REGEX = r"|".join(re.escape(w) for w in sorted(EXCLAMATION_WORDS, key=len, reverse=True))
# Compiled once: apply_rules runs in boundary processing for every segment.
_EXCLAMATION_RE = re.compile(EXCLAMATION_REGEX)

@classmethod
def apply_rules(cls, text: str) -> str:
return re.sub(ExclamationWords.EXCLAMATION_REGEX, replace_punctuation, text)
return cls._EXCLAMATION_RE.sub(replace_punctuation, text)
6 changes: 5 additions & 1 deletion sentencesplit/lang/russian.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
from sentencesplit.abbreviation_replacer import AbbreviationReplacer
from sentencesplit.lang.common import Common, Standard

# Constant pattern compiled once: " и " followed by a Cyrillic capital, used to
# detect a conjunction continuation when deciding an abbreviation boundary.
_RUSSIAN_CONJUNCTION_CONTINUATION_RE = re.compile(r"\sи\s+[А-ЯЁ]")


class Russian(Common, Standard):
iso_code = "ru"
Expand Down Expand Up @@ -145,7 +149,7 @@ def _sr_continues_compare_phrase(cls, text, start=0):
found = text.find(boundary, index)
if found != -1:
sentence_end = min(sentence_end, found)
return re.search(r"\sи\s+[А-ЯЁ]", text[index:sentence_end]) is not None
return _RUSSIAN_CONJUNCTION_CONTINUATION_RE.search(text[index:sentence_end]) is not None

def replace_period_of_abbr(self, txt, abbr, escaped=None):
abbr = abbr.strip()
Expand Down
11 changes: 8 additions & 3 deletions sentencesplit/lang/slovak.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
from sentencesplit.punctuation_replacer import replace_punctuation
from sentencesplit.utils import apply_rules

# Constant patterns compiled once at import instead of recompiled per call.
_SLOVAK_DOUBLE_QUOTES_RE = re.compile(r"\„(?=(?P<tmp>[^“\\]+|\\{2}|\\.)*)(?P=tmp)\“")
_SLOVAK_ORDINAL_PERIOD_RE = re.compile(r"(?<=\d)\.(?=\s*[a-z]+)")
_SLOVAK_ROMAN_PERIOD_RE = re.compile(r"((\s+[VXI]+)|(^[VXI]+))(\.)(?=\s+)", re.IGNORECASE)


class Slovak(Common, Standard):
iso_code = "sk"
Expand Down Expand Up @@ -248,7 +253,7 @@ class BetweenPunctuation(BetweenPunctuation):
BETWEEN_SLOVAK_DOUBLE_QUOTES_REGEX_2 = r"\„(?=(?P<tmp>[^“\\]+|\\{2}|\\.)*)(?P=tmp)\“"

def sub_punctuation_between_slovak_double_quotes(self, txt):
return re.sub(self.BETWEEN_SLOVAK_DOUBLE_QUOTES_REGEX_2, replace_punctuation, txt)
return _SLOVAK_DOUBLE_QUOTES_RE.sub(replace_punctuation, txt)

def sub_punctuation_between_quotes_and_parens(self, txt):
txt = self.sub_punctuation_between_single_quotes(txt)
Expand All @@ -272,11 +277,11 @@ def replace_numbers(self, text: str) -> str:

def replace_period_in_ordinal_numerals(self, text: str) -> str:
# Rubular: https://rubular.com/r/0HkmvzMGTqgWs6
return re.sub(r"(?<=\d)\.(?=\s*[a-z]+)", "∯", text)
return _SLOVAK_ORDINAL_PERIOD_RE.sub("∯", text)

def replace_period_in_roman_numerals(self, text: str) -> str:
# Rubular: https://rubular.com/r/XlzTIi7aBRThSl
return re.sub(r"((\s+[VXI]+)|(^[VXI]+))(\.)(?=\s+)", r"\1∯", text, flags=re.IGNORECASE)
return _SLOVAK_ROMAN_PERIOD_RE.sub(r"\1∯", text)

def replace_period_in_slovak_dates(self, text: str) -> str:
MONTHS = [
Expand Down
12 changes: 9 additions & 3 deletions sentencesplit/lists_item_replacer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@

from sentencesplit.utils import Rule, apply_rules, split_mode_rank

# Constant patterns compiled once at import instead of recompiled per call.
# Newline-separated list-marker guards: a list spanning lines is not collapsed.
_MULTILINE_BULLET_GUARD_RE = re.compile(r"♨.+(\n|\r).+♨")
_MULTILINE_PAREN_MARKER_GUARD_RE = re.compile(r"☝.+\n.+☝|☝.+\r.+☝")


class ListItemReplacer:
ROMAN_NUMERALS = "i ii iii iv v vi vii viii ix x xi xii xiii xiv xv xvi xvii xviii xix xx".split(" ")
Expand Down Expand Up @@ -50,6 +55,7 @@ class ListItemReplacer:

# Rubular: http://rubular.com/r/GcnmQt4a3I
ROMAN_NUMERALS_IN_PARENTHESES = r"\(((?=[mdclxvi])m*(c[md]|d?c*)(x[cl]|l?x*)(i[xv]|v?i*))\)(?=\s[A-Z])"
_ROMAN_NUMERALS_IN_PARENTHESES_RE = re.compile(ROMAN_NUMERALS_IN_PARENTHESES)

# A false-positive guard for numbered lists. Some adjacent ordinals are
# prose, not list items (e.g. English "for 1. above ... 2. above" or German
Expand All @@ -70,7 +76,7 @@ def add_line_break(self):
return self.text

def replace_parens(self):
self.text = re.sub(self.ROMAN_NUMERALS_IN_PARENTHESES, r"&✂&\1&⌬&", self.text)
self.text = self._ROMAN_NUMERALS_IN_PARENTHESES_RE.sub(r"&✂&\1&⌬&", self.text)
return self.text

def format_numbered_list_with_parens(self):
Expand Down Expand Up @@ -160,7 +166,7 @@ def add_line_breaks_for_numbered_list_with_periods(self):
text_for_breaks,
)

if (text_for_breaks.count("♨") >= 2) and (not re.search("♨.+(\n|\r).+♨", text_for_breaks)):
if (text_for_breaks.count("♨") >= 2) and (not _MULTILINE_BULLET_GUARD_RE.search(text_for_breaks)):
self.text = apply_rules(
text_for_breaks,
self.SpaceBetweenListItemsFirstRule,
Expand All @@ -173,7 +179,7 @@ def replace_parens_in_numbered_list(self):
self.scan_lists(self.NUMBERED_LIST_PARENS_REGEX, self.NUMBERED_LIST_PARENS_REGEX, "☝")

def add_line_breaks_for_numbered_list_with_parens(self):
if "☝" in self.text and not re.search("☝.+\n.+☝|☝.+\r.+☝", self.text):
if "☝" in self.text and not _MULTILINE_PAREN_MARKER_GUARD_RE.search(self.text):
self.text = apply_rules(
self.text,
self.SpaceBetweenListItemsThirdRule,
Expand Down
20 changes: 12 additions & 8 deletions sentencesplit/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,17 @@ def _quote_resplit_thresholds(self) -> tuple[int, int] | None:
return (2, 3)
return (_QUOTE_MIN_INTERIOR_SENTENCES, _QUOTE_MIN_WORDS)

def _maybe_resplit_multi_sentence_quote(self, pps: str, quote_thresholds: tuple[int, int] | None) -> list[str] | None:
# ``_resplit_multi_sentence_quote`` returns ``None`` unless the segment
# begins with a leading quote (its first gate). Computing the
# abbreviation-protected scan is the expensive part of this branch, so
# skip it entirely for the common quote-free segment instead of building
# it eagerly for every sentence only to have the resplit reject it.
if quote_thresholds is None or _LEADING_QUOTE_RE.match(pps) is None:
return None
protected_text = self.replace_abbreviations(_quote_abbreviation_scan_text(pps))
return _resplit_multi_sentence_quote(pps, *quote_thresholds, protected_text=protected_text)

def _resplit_segments(self, postprocessed_sents: list[str]) -> list[str]:
if self.profile.latin_uppercase_resplit:
# Re-split at ".) Capital" boundaries (period inside closing paren before new sentence)
Expand All @@ -565,14 +576,7 @@ def _resplit_segments(self, postprocessed_sents: list[str]) -> list[str]:
parts = (
_split_on_uppercase_boundary(pps, _LATIN_RESPLIT_RE)
or _split_on_uppercase_boundary(pps, _MULTI_TERMINATOR_RESPLIT_RE)
or (
quote_thresholds is not None
and _resplit_multi_sentence_quote(
pps,
*quote_thresholds,
protected_text=self.replace_abbreviations(_quote_abbreviation_scan_text(pps)),
)
)
or self._maybe_resplit_multi_sentence_quote(pps, quote_thresholds)
or None
)
if parts is None:
Expand Down
24 changes: 24 additions & 0 deletions sentencesplit/segmenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,30 @@ def segment_spans(self, text: str | None) -> list[TextSpan]:
processed_sents = self.processor(self._processor_text(text)).process()
return [TextSpan(s, start, end) for s, start, end in self._match_spans(processed_sents, text)]

def segment_spans_with_lookahead(self, text: str | None) -> tuple[list[TextSpan], bool]:
"""Return sentence spans **and** the trailing-boundary lookahead verdict.

Equivalent to calling :meth:`segment_spans` and
:meth:`should_wait_for_more` separately, but segments ``text`` once
instead of twice: both the spans and the ``should_wait_for_more`` verdict
are derived from a single ``process()`` + span-mapping pass. This is the
redundancy that otherwise dominates per-delta cost in
:class:`~sentencesplit.stream_segmenter.StreamSegmenter`, where every
``feed`` needs both. Requires ``clean=False`` (same as
:meth:`segment_spans`); the boundary and lookahead logic is byte-for-byte
identical to the two separate calls.
"""
if self.clean:
raise InvalidConfigurationError("segment_spans_with_lookahead() requires clean=False.")
if not text:
return [], False
processed_sents = self.processor(self._processor_text(text)).process()
matched_spans = list(self._match_spans(processed_sents, text))
spans = [TextSpan(s, start, end) for s, start, end in matched_spans]
comparison_segments = [s for s, _, _ in matched_spans]
should_wait = self._wait_for_last_segment(text, comparison_segments)
return spans, should_wait

def segment_clean(self, text: str | None) -> list[str]:
"""Return cleaned sentences regardless of the instance's clean flag."""
if not text:
Expand Down
9 changes: 7 additions & 2 deletions sentencesplit/stream_segmenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,13 @@ def _detect(self) -> None:
stream base offset. Emitted text is never revisited, so nothing it emits
can later grow or be re-sliced.
"""
spans = self._tail_spans()
self._last_should_wait = self._segmenter.should_wait_for_more(self._buffer) if self._buffer else False
# One segmentation pass yields both the tail spans and the trailing-
# boundary lookahead verdict; computing them separately would segment the
# buffer twice on every delta.
if self._buffer:
spans, self._last_should_wait = self._segmenter.segment_spans_with_lookahead(self._buffer)
else:
spans, self._last_should_wait = [], False
if not spans:
return
last_index = len(spans) - 1
Expand Down
Loading