trimwise 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.
trimwise/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Expose Trimwise's stable public interface."""
2
+
3
+ from trimwise.models import (
4
+ BudgetUnit,
5
+ SemanticBackendError,
6
+ Strategy,
7
+ TrimConfig,
8
+ TrimResult,
9
+ )
10
+ from trimwise.trimmer import Trimmer
11
+
12
+ __all__ = [
13
+ "BudgetUnit",
14
+ "SemanticBackendError",
15
+ "Strategy",
16
+ "TrimConfig",
17
+ "TrimResult",
18
+ "Trimmer",
19
+ ]
@@ -0,0 +1,150 @@
1
+ """Measure text consistently and derive source-preserving fitting prefixes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from typing import TYPE_CHECKING
8
+
9
+ import tiktoken
10
+
11
+ from trimwise.models import BudgetUnit
12
+
13
+ if TYPE_CHECKING:
14
+ from tiktoken import Encoding
15
+
16
+ TokenCounter = Callable[[str], int]
17
+
18
+
19
+ class Measurer:
20
+ """Measure text in one configured unit and expose lexical token IDs."""
21
+
22
+ def __init__(
23
+ self,
24
+ unit: BudgetUnit,
25
+ encoding_name: str,
26
+ token_counter: TokenCounter | None,
27
+ ) -> None:
28
+ """Store measurement settings without eagerly loading unused encodings.
29
+
30
+ Args:
31
+ unit: Unit used for public budget counts.
32
+ encoding_name: Tiktoken encoding used for tokens and ranking.
33
+ token_counter: Optional caller-supplied token counter.
34
+ """
35
+ self.unit = unit
36
+ self._encoding_name = encoding_name
37
+ self._token_counter = token_counter
38
+ self._encoding: Encoding | None = None
39
+
40
+ def count(self, text: str) -> int:
41
+ """Measure text and validate custom counter output.
42
+
43
+ Args:
44
+ text: Text to measure.
45
+
46
+ Returns:
47
+ Nonnegative size in the configured unit.
48
+
49
+ Raises:
50
+ ValueError: If a custom counter returns an invalid value.
51
+ """
52
+ if self.unit is BudgetUnit.CHARACTERS:
53
+ return len(text)
54
+ if self.unit is BudgetUnit.WORDS:
55
+ return len(text.split())
56
+ if self._token_counter is None:
57
+ return len(self._get_encoding().encode(text, disallowed_special=()))
58
+
59
+ count = self._token_counter(text)
60
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
61
+ raise ValueError("token_counter must return a nonnegative integer")
62
+ return count
63
+
64
+ def token_ids(self, text: str) -> list[int]:
65
+ """Tokenize normalized ranking text with the configured encoding.
66
+
67
+ Args:
68
+ text: Ranking-only text.
69
+
70
+ Returns:
71
+ Tiktoken subword identifiers.
72
+ """
73
+ return self._get_encoding().encode(text, disallowed_special=())
74
+
75
+ def fitting_prefix(self, text: str, limit: int) -> str:
76
+ """Return the longest source prefix found within a measurement limit.
77
+
78
+ Args:
79
+ text: Source text whose prefix may be retained.
80
+ limit: Maximum measured size.
81
+
82
+ Returns:
83
+ A source-preserving prefix, possibly empty.
84
+ """
85
+ if limit < 0:
86
+ return ""
87
+ if self.count(text) <= limit:
88
+ return text
89
+ if self.unit is BudgetUnit.CHARACTERS:
90
+ return text[:limit]
91
+ if self.unit is BudgetUnit.WORDS:
92
+ matches = list(re.finditer(r"\S+(?:\s+|$)", text))
93
+ return text[: matches[limit - 1].end()] if limit else ""
94
+ if self._token_counter is None:
95
+ return self._fitting_encoded_prefix(text, limit)
96
+ return self._fitting_scanned_prefix(text, limit)
97
+
98
+ def _fitting_encoded_prefix(self, text: str, limit: int) -> str:
99
+ """Fit a source prefix around the configured encoding's token boundary.
100
+
101
+ Args:
102
+ text: Oversized source text.
103
+ limit: Maximum encoded token count.
104
+
105
+ Returns:
106
+ Longest fitting source prefix around the first excluded token.
107
+ """
108
+ encoding = self._get_encoding()
109
+ tokens = encoding.encode(text, disallowed_special=())
110
+ decoded, offsets = encoding.decode_with_offsets(tokens)
111
+ if decoded != text:
112
+ return self._fitting_scanned_prefix(text, limit)
113
+
114
+ boundary = offsets[limit]
115
+ upper = next(
116
+ (offset for offset in offsets[limit + 1 :] if offset > boundary),
117
+ len(text),
118
+ )
119
+ fitting_end = 0
120
+ for end in range(max(0, boundary - 1), upper + 1):
121
+ if self.count(text[:end]) <= limit:
122
+ fitting_end = end
123
+ return text[:fitting_end]
124
+
125
+ def _fitting_scanned_prefix(self, text: str, limit: int) -> str:
126
+ """Find an exact prefix for a counter with no token-offset API.
127
+
128
+ Args:
129
+ text: Oversized source text.
130
+ limit: Maximum custom token count.
131
+
132
+ Returns:
133
+ Longest fitting source prefix, possibly empty.
134
+ """
135
+ # ponytail: custom counters may be non-monotonic; keep their rare fallback exact until
136
+ # callers can supply a source-offset API.
137
+ for end in range(len(text) - 1, -1, -1):
138
+ if self.count(text[:end]) <= limit:
139
+ return text[:end]
140
+ return ""
141
+
142
+ def _get_encoding(self) -> Encoding:
143
+ """Load and cache the tiktoken encoding on first lexical use.
144
+
145
+ Returns:
146
+ Configured tiktoken encoding.
147
+ """
148
+ if self._encoding is None:
149
+ self._encoding = tiktoken.get_encoding(self._encoding_name)
150
+ return self._encoding
trimwise/models.py ADDED
@@ -0,0 +1,116 @@
1
+ """Define Trimwise's public value objects and validation rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from types import MappingProxyType
9
+ from typing import Any
10
+
11
+
12
+ class Strategy(str, Enum):
13
+ """Select the evidence-ranking method used during truncation."""
14
+
15
+ AUTO = "auto"
16
+ STRUCTURAL = "structural"
17
+ LEXICAL = "lexical"
18
+ SEMANTIC = "semantic"
19
+ HYBRID = "hybrid"
20
+
21
+
22
+ class BudgetUnit(str, Enum):
23
+ """Define how an input and output budget is measured."""
24
+
25
+ TOKENS = "tokens"
26
+ WORDS = "words"
27
+ CHARACTERS = "characters"
28
+
29
+
30
+ class SemanticBackendError(RuntimeError):
31
+ """Report an optional semantic backend failure without hiding its cause."""
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class TrimConfig:
36
+ """Configure reusable measurement, ranking, and omission behavior.
37
+
38
+ Attributes:
39
+ token_encoding: Tiktoken encoding used for token budgets and lexical ranking.
40
+ embedding_model: FastEmbed model used by semantic strategies.
41
+ fastembed_options: Additional keyword arguments for ``TextEmbedding``.
42
+ embedding_batch_size: Passage batch size used during inference.
43
+ mmr_lambda: Balance between relevance and diversity.
44
+ omission_marker: Text inserted where source content was omitted.
45
+ """
46
+
47
+ token_encoding: str = "o200k_base"
48
+ embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
49
+ fastembed_options: Mapping[str, Any] = field(default_factory=dict)
50
+ embedding_batch_size: int = 256
51
+ mmr_lambda: float = 0.7
52
+ omission_marker: str = "[…omitted…]"
53
+
54
+ def __post_init__(self) -> None:
55
+ """Validate configuration and freeze a defensive options copy.
56
+
57
+ Raises:
58
+ TypeError: If FastEmbed options are not a mapping.
59
+ ValueError: If a configured value is outside its supported domain.
60
+ """
61
+ if not isinstance(self.token_encoding, str):
62
+ raise TypeError("token_encoding must be a string")
63
+ if not self.token_encoding.strip():
64
+ raise ValueError("token_encoding must not be blank")
65
+ if not isinstance(self.embedding_model, str):
66
+ raise TypeError("embedding_model must be a string")
67
+ if not self.embedding_model.strip():
68
+ raise ValueError("embedding_model must not be blank")
69
+ if not isinstance(self.omission_marker, str):
70
+ raise TypeError("omission_marker must be a string")
71
+ if not self.omission_marker.strip():
72
+ raise ValueError("omission_marker must not be blank")
73
+ if (
74
+ isinstance(self.embedding_batch_size, bool)
75
+ or not isinstance(self.embedding_batch_size, int)
76
+ or self.embedding_batch_size <= 0
77
+ ):
78
+ raise ValueError("embedding_batch_size must be a positive integer")
79
+ if (
80
+ isinstance(self.mmr_lambda, bool)
81
+ or not isinstance(self.mmr_lambda, (int, float))
82
+ or not 0 <= self.mmr_lambda <= 1
83
+ ):
84
+ raise ValueError("mmr_lambda must be between 0 and 1")
85
+ if not isinstance(self.fastembed_options, Mapping):
86
+ raise TypeError("fastembed_options must be a mapping")
87
+
88
+ options = dict(self.fastembed_options)
89
+ if any(not isinstance(key, str) for key in options):
90
+ raise ValueError("fastembed_options keys must be strings")
91
+ if "model_name" in options:
92
+ raise ValueError("model_name belongs in embedding_model")
93
+ object.__setattr__(self, "fastembed_options", MappingProxyType(options))
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class TrimResult:
98
+ """Describe the resolved strategy and measured truncation result.
99
+
100
+ Attributes:
101
+ text: Extractive output that fits the requested limit.
102
+ input_count: Measured size of the original input.
103
+ output_count: Measured size of the returned output.
104
+ limit: Requested maximum output size.
105
+ unit: Unit used for all counts.
106
+ strategy: Concrete strategy used after resolving ``auto``.
107
+ trimmed: Whether the output differs from the input.
108
+ """
109
+
110
+ text: str
111
+ input_count: int
112
+ output_count: int
113
+ limit: int
114
+ unit: BudgetUnit
115
+ strategy: Strategy
116
+ trimmed: bool
trimwise/py.typed ADDED
File without changes