errlore 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.
@@ -0,0 +1,495 @@
1
+ """Persistent lesson store backed by JSONL via errlore.io.
2
+
3
+ Manages two files:
4
+ data_dir/errors.jsonl -- error events
5
+ data_dir/lessons.jsonl -- extracted lessons (single-record, mutable via atomic_rewrite)
6
+
7
+ Unlike the NEXUS original, lessons are updated *in-place* via atomic_rewrite
8
+ instead of appending new versions. This eliminates dedup ambiguity at read time.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import threading
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from errlore.io import JSONLWriter
19
+ from errlore.lessons.models import ErrorRecord, Lesson, _utc_now_iso
20
+
21
+ if TYPE_CHECKING:
22
+ from errlore.retrieval import LessonRetriever
23
+
24
+ logger = logging.getLogger("errlore.lessons")
25
+
26
+
27
+ class LessonStore:
28
+ """Thread-safe lesson store persisted as JSONL.
29
+
30
+ Args:
31
+ data_dir: Directory for errors.jsonl and lessons.jsonl.
32
+ retriever: Optional semantic retriever implementing
33
+ :class:`~errlore.retrieval.LessonRetriever`. When provided,
34
+ :meth:`search_lessons` uses semantic search with automatic
35
+ fallback to word-overlap when no semantic results are found.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ data_dir: Path,
41
+ *,
42
+ retriever: LessonRetriever | None = None,
43
+ ) -> None:
44
+ self._data_dir = Path(data_dir)
45
+ self._data_dir.mkdir(parents=True, exist_ok=True)
46
+ self._errors_path = self._data_dir / "errors.jsonl"
47
+ self._lessons_path = self._data_dir / "lessons.jsonl"
48
+ # A1: rotation disabled -- these files use ID-based lookups, so all
49
+ # records must remain visible. Growth will be managed by future
50
+ # log compaction.
51
+ self._writer = JSONLWriter(max_bytes=None)
52
+ self._lock = threading.Lock()
53
+ self._retriever = retriever
54
+
55
+ if self._retriever is not None:
56
+ self._sync_retriever()
57
+
58
+ # ------------------------------------------------------------------
59
+ # Retriever sync
60
+ # ------------------------------------------------------------------
61
+
62
+ def _sync_retriever(self) -> None:
63
+ """Ensure all existing lessons are indexed in the retriever.
64
+
65
+ Called once during construction. Each ``add`` is idempotent so
66
+ already-indexed lessons are skipped with only a set-lookup cost.
67
+ """
68
+ if self._retriever is None:
69
+ return
70
+ for lesson in self._read_lessons():
71
+ self._retriever.add(lesson.id, lesson.pattern)
72
+
73
+ # ------------------------------------------------------------------
74
+ # Errors
75
+ # ------------------------------------------------------------------
76
+
77
+ def log_error(
78
+ self,
79
+ model: str,
80
+ task_type: str,
81
+ error_type: str,
82
+ message: str,
83
+ *,
84
+ context: str = "",
85
+ stacktrace: str = "",
86
+ metadata: dict[str, Any] | None = None,
87
+ ) -> str:
88
+ """Record an error event.
89
+
90
+ Returns:
91
+ The generated error ID (12-char hex).
92
+ """
93
+ record = ErrorRecord(
94
+ model=model,
95
+ task_type=task_type,
96
+ error_type=error_type,
97
+ message=message,
98
+ context=context,
99
+ stacktrace=stacktrace,
100
+ metadata=metadata,
101
+ )
102
+ self._writer.append(self._errors_path, record.to_dict())
103
+ logger.debug("Logged error %s: %s", record.id, message[:80])
104
+ return record.id
105
+
106
+ def resolve_error(
107
+ self,
108
+ error_id: str,
109
+ resolution: str,
110
+ lesson: str | None = None,
111
+ ) -> bool:
112
+ """Resolve an error by ID. Optionally extract a lesson automatically.
113
+
114
+ When *lesson* is provided the store creates a new lesson entry with
115
+ confidence 0.8, linking it to the resolved error via ``source_error_id``.
116
+
117
+ The error record is updated in-place (atomic_rewrite).
118
+
119
+ Returns:
120
+ True if the error was found and resolved, False otherwise.
121
+ """
122
+ # Race-safe: read-modify-write happens under ONE file lock via
123
+ # atomic_update, so errors appended concurrently by other threads
124
+ # or processes are never lost (they land before or after, intact).
125
+ target: ErrorRecord | None = None
126
+ already_resolved = False
127
+
128
+ def _apply(
129
+ entries: list[dict[str, object]],
130
+ ) -> list[dict[str, object]] | None:
131
+ nonlocal target, already_resolved
132
+ for entry in entries:
133
+ if entry.get("id") == error_id:
134
+ rec = ErrorRecord.from_dict(entry)
135
+ if rec.resolved:
136
+ already_resolved = True
137
+ target = rec
138
+ return None # abort write, nothing to change
139
+ rec.resolved = True
140
+ rec.resolution = resolution
141
+ target = rec
142
+ entry.update(rec.to_dict())
143
+ return entries
144
+ return None # not found, abort write
145
+
146
+ with self._lock:
147
+ self._writer.atomic_update(self._errors_path, _apply)
148
+
149
+ if target is None:
150
+ logger.debug("resolve_error: error_id=%s not found", error_id)
151
+ return False
152
+ if already_resolved:
153
+ # B11: warn if caller passed a lesson for an already-resolved error.
154
+ if lesson:
155
+ logger.warning(
156
+ "lesson ignored: error %s already resolved", error_id,
157
+ )
158
+ logger.debug("resolve_error: error_id=%s already resolved", error_id)
159
+ return True
160
+
161
+ if lesson:
162
+ self.log_lesson(
163
+ pattern=f"{target.error_type}: {target.message}",
164
+ solution=lesson,
165
+ confidence=0.8,
166
+ task_type=target.task_type,
167
+ error_type=target.error_type,
168
+ source_error_id=error_id,
169
+ )
170
+
171
+ logger.info("Resolved error %s: %s", error_id, resolution[:80])
172
+ return True
173
+
174
+ # ------------------------------------------------------------------
175
+ # Lessons
176
+ # ------------------------------------------------------------------
177
+
178
+ @staticmethod
179
+ def _word_overlap(a: str, b: str) -> float:
180
+ """Word-level Jaccard-style overlap (|intersection| / max(|A|, |B|))."""
181
+ words_a = set(a.lower().split())
182
+ words_b = set(b.lower().split())
183
+ if not words_a or not words_b:
184
+ return 0.0
185
+ intersection = words_a & words_b
186
+ return len(intersection) / max(len(words_a), len(words_b))
187
+
188
+ def _find_duplicate_lesson(self, pattern: str) -> Lesson | None:
189
+ """Check recent lessons for exact or fuzzy (>85% word overlap) duplicate."""
190
+ lessons = self._read_lessons()
191
+ normalized = pattern.lower().strip()
192
+ for lesson in lessons:
193
+ existing = lesson.pattern.lower().strip()
194
+ if existing == normalized:
195
+ return lesson
196
+ if self._word_overlap(normalized, existing) > 0.85:
197
+ return lesson
198
+ return None
199
+
200
+ def log_lesson(
201
+ self,
202
+ pattern: str,
203
+ solution: str,
204
+ *,
205
+ confidence: float = 0.8,
206
+ task_type: str = "",
207
+ error_type: str = "",
208
+ source_error_id: str = "",
209
+ source_errors: list[str] | None = None,
210
+ metadata: dict[str, Any] | None = None,
211
+ ) -> str:
212
+ """Record a lesson with deduplication.
213
+
214
+ Dedup rules (same as NEXUS source):
215
+ - Exact match on pattern (case-insensitive, stripped)
216
+ - Fuzzy match: word overlap > 85%
217
+
218
+ If a duplicate is found the existing lesson ID is returned and
219
+ no new record is written.
220
+
221
+ Returns:
222
+ Lesson ID (new or existing duplicate).
223
+ """
224
+ with self._lock:
225
+ existing = self._find_duplicate_lesson(pattern)
226
+ if existing is not None:
227
+ logger.debug(
228
+ "Duplicate lesson skipped: '%s' matches '%s'",
229
+ pattern[:60],
230
+ existing.pattern[:60],
231
+ )
232
+ return existing.id
233
+
234
+ lesson = Lesson(
235
+ pattern=pattern,
236
+ solution=solution,
237
+ confidence=confidence,
238
+ task_type=task_type,
239
+ error_type=error_type,
240
+ source_error_id=source_error_id,
241
+ source_errors=source_errors or [],
242
+ metadata=metadata,
243
+ )
244
+ self._writer.append(self._lessons_path, lesson.to_dict())
245
+ if self._retriever is not None:
246
+ self._retriever.add(lesson.id, lesson.pattern)
247
+ logger.debug("Logged lesson %s: %s", lesson.id, pattern[:80])
248
+ return lesson.id
249
+
250
+ # ------------------------------------------------------------------
251
+ # Search
252
+ # ------------------------------------------------------------------
253
+
254
+ def search_lessons(
255
+ self,
256
+ *,
257
+ query: str = "",
258
+ task_type: str = "",
259
+ error_type: str = "",
260
+ limit: int = 10,
261
+ ) -> list[Lesson]:
262
+ """Search lessons by structured filters and/or fuzzy text query.
263
+
264
+ When a semantic retriever is configured and *query* is non-empty,
265
+ semantic search runs first. If it yields no results (after
266
+ applying any task_type/error_type filters), the method falls back
267
+ to the original word-overlap logic.
268
+
269
+ Filter priority (word-overlap path):
270
+ 1. task_type / error_type exact match (both can combine)
271
+ 2. query: word overlap > 30%, fallback to substring match
272
+ 3. Results sorted by confidence descending
273
+
274
+ Returns:
275
+ Up to *limit* lessons.
276
+ """
277
+ if not query and not task_type and not error_type:
278
+ return []
279
+
280
+ # -- Semantic path (retriever + query) ----------------------------
281
+ if self._retriever is not None and query:
282
+ sem = self._semantic_search(query, task_type, error_type, limit)
283
+ if sem:
284
+ return sem
285
+ # Fall through to word-overlap below.
286
+
287
+ # -- Word-overlap path (original logic) ---------------------------
288
+ return self._word_overlap_search(query, task_type, error_type, limit)
289
+
290
+ def _semantic_search(
291
+ self,
292
+ query: str,
293
+ task_type: str,
294
+ error_type: str,
295
+ limit: int,
296
+ ) -> list[Lesson]:
297
+ """Run semantic search via the configured retriever.
298
+
299
+ Returns an empty list when no relevant lessons are found so the
300
+ caller can fall back to word-overlap.
301
+ """
302
+ assert self._retriever is not None
303
+
304
+ # Fetch more candidates than needed to allow for post-filtering.
305
+ candidates = self._retriever.search(query, k=limit * 3)
306
+ if not candidates:
307
+ return []
308
+
309
+ score_map: dict[str, float] = {cid: score for cid, score in candidates}
310
+ candidate_ids = set(score_map)
311
+
312
+ all_lessons = self._read_lessons()
313
+ matched: list[Lesson] = []
314
+ for lesson in all_lessons:
315
+ if lesson.id not in candidate_ids:
316
+ continue
317
+ if task_type and lesson.task_type != task_type:
318
+ continue
319
+ if error_type and lesson.error_type != error_type:
320
+ continue
321
+ matched.append(lesson)
322
+
323
+ # Rank by semantic score (primary), confidence (secondary).
324
+ matched.sort(
325
+ key=lambda x: (score_map.get(x.id, 0.0), x.confidence),
326
+ reverse=True,
327
+ )
328
+ return matched[:limit]
329
+
330
+ def _word_overlap_search(
331
+ self,
332
+ query: str,
333
+ task_type: str,
334
+ error_type: str,
335
+ limit: int,
336
+ ) -> list[Lesson]:
337
+ """Original word-overlap search logic (preserved for fallback)."""
338
+ all_lessons = self._read_lessons()
339
+ results: list[Lesson] = []
340
+
341
+ if task_type or error_type:
342
+ for lesson in all_lessons:
343
+ if task_type and lesson.task_type != task_type:
344
+ continue
345
+ if error_type and lesson.error_type != error_type:
346
+ continue
347
+ results.append(lesson)
348
+ # If query is also provided, further filter results
349
+ if query and results:
350
+ filtered: list[Lesson] = []
351
+ for lesson in results:
352
+ overlap = self._word_overlap(query, lesson.pattern)
353
+ if overlap > 0.3 or query.lower() in lesson.pattern.lower():
354
+ filtered.append(lesson)
355
+ if filtered:
356
+ results = filtered
357
+ elif query:
358
+ # Pure fuzzy search
359
+ for lesson in all_lessons:
360
+ overlap = self._word_overlap(query, lesson.pattern)
361
+ if overlap > 0.3:
362
+ results.append(lesson)
363
+ # Fallback: substring
364
+ if not results:
365
+ query_lower = query.lower()
366
+ for lesson in all_lessons:
367
+ if query_lower in lesson.pattern.lower():
368
+ results.append(lesson)
369
+
370
+ results.sort(key=lambda x: x.confidence, reverse=True)
371
+ return results[:limit]
372
+
373
+ # ------------------------------------------------------------------
374
+ # Reinforce / Decay
375
+ # ------------------------------------------------------------------
376
+
377
+ def reinforce(self, lesson_id: str, success: bool) -> bool:
378
+ """Reinforce a lesson: adjust confidence +/-0.1, increment applied_count.
379
+
380
+ Confidence is clamped to [0.1, 1.0]. The lesson record is updated
381
+ in-place via atomic_rewrite (no append of a new version).
382
+
383
+ Returns:
384
+ True if the lesson was found and updated, False otherwise.
385
+ """
386
+ # Race-safe read-modify-write under one file lock (see resolve_error).
387
+ target: Lesson | None = None
388
+
389
+ def _apply(
390
+ entries: list[dict[str, object]],
391
+ ) -> list[dict[str, object]] | None:
392
+ nonlocal target
393
+ for entry in entries:
394
+ if entry.get("id") == lesson_id:
395
+ les = Lesson.from_dict(entry)
396
+ delta = 0.1 if success else -0.1
397
+ les.confidence = round(
398
+ max(0.1, min(1.0, les.confidence + delta)), 2
399
+ )
400
+ les.applied_count += 1
401
+ les.updated_at = _utc_now_iso()
402
+ target = les
403
+ entry.update(les.to_dict())
404
+ return entries
405
+ return None # not found, abort write
406
+
407
+ with self._lock:
408
+ self._writer.atomic_update(self._lessons_path, _apply)
409
+
410
+ if target is None:
411
+ logger.debug("reinforce: lesson_id=%s not found", lesson_id)
412
+ return False
413
+ # No retriever re-sync needed: reinforce never changes pattern text,
414
+ # so the embedding vector is unchanged.
415
+ logger.debug(
416
+ "Reinforced lesson %s: confidence=%.2f applied_count=%d",
417
+ lesson_id,
418
+ target.confidence,
419
+ target.applied_count,
420
+ )
421
+ return True
422
+
423
+ def decay_unused(self) -> int:
424
+ """Decay confidence of unused lessons.
425
+
426
+ Reduces confidence by 0.05 for lessons with applied_count == 0
427
+ and confidence > 0.3. Updated via single atomic_rewrite.
428
+
429
+ Returns:
430
+ Number of lessons that were decayed.
431
+ """
432
+ decayed_count = 0
433
+
434
+ def _apply(
435
+ entries: list[dict[str, object]],
436
+ ) -> list[dict[str, object]] | None:
437
+ nonlocal decayed_count
438
+ now = _utc_now_iso()
439
+ for entry in entries:
440
+ les = Lesson.from_dict(entry)
441
+ if les.applied_count > 0 or les.confidence <= 0.3:
442
+ continue
443
+ les.confidence = round(max(0.0, les.confidence - 0.05), 2)
444
+ les.updated_at = now
445
+ entry.update(les.to_dict())
446
+ decayed_count += 1
447
+ if decayed_count == 0:
448
+ return None # nothing to change, abort write
449
+ return entries
450
+
451
+ with self._lock:
452
+ self._writer.atomic_update(self._lessons_path, _apply)
453
+
454
+ if decayed_count > 0:
455
+ logger.info("Decayed %d unused lessons", decayed_count)
456
+ return decayed_count
457
+
458
+ # ------------------------------------------------------------------
459
+ # Statistics
460
+ # ------------------------------------------------------------------
461
+
462
+ def counts(self) -> dict[str, int]:
463
+ """Return basic statistics about stored errors and lessons.
464
+
465
+ Returns:
466
+ Dict with keys: errors_total, errors_resolved, errors_unresolved,
467
+ lessons_total, lessons_applied (applied_count > 0).
468
+ """
469
+ errors = self._read_errors()
470
+ lessons = self._read_lessons()
471
+
472
+ resolved = sum(1 for e in errors if e.resolved)
473
+ applied = sum(1 for le in lessons if le.applied_count > 0)
474
+
475
+ return {
476
+ "errors_total": len(errors),
477
+ "errors_resolved": resolved,
478
+ "errors_unresolved": len(errors) - resolved,
479
+ "lessons_total": len(lessons),
480
+ "lessons_applied": applied,
481
+ }
482
+
483
+ # ------------------------------------------------------------------
484
+ # Internal I/O
485
+ # ------------------------------------------------------------------
486
+
487
+ def _read_errors(self) -> list[ErrorRecord]:
488
+ """Read all error records from disk."""
489
+ raw = self._writer.read_all(self._errors_path)
490
+ return [ErrorRecord.from_dict(r) for r in raw]
491
+
492
+ def _read_lessons(self) -> list[Lesson]:
493
+ """Read all lesson records from disk."""
494
+ raw = self._writer.read_all(self._lessons_path)
495
+ return [Lesson.from_dict(r) for r in raw]
errlore/py.typed ADDED
File without changes
@@ -0,0 +1,66 @@
1
+ """Semantic retrieval layer for lessons.
2
+
3
+ Provides embedding backends and a vector index for semantic search
4
+ over lessons. Heavy dependencies (numpy, fastembed) live in submodules
5
+ and are imported lazily -- this ``__init__`` only defines lightweight
6
+ Protocol types that can be imported without extras.
7
+
8
+ Public API:
9
+ LessonRetriever -- structural Protocol consumed by LessonStore
10
+ EmbeddingBackend -- structural Protocol for embedding providers
11
+
12
+ Concrete implementations (require ``errlore[embeddings]``):
13
+ ``errlore.retrieval.backend.FastEmbedBackend``
14
+ ``errlore.retrieval.backend.CallableBackend``
15
+ ``errlore.retrieval.index.VectorIndex``
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Protocol, runtime_checkable
21
+
22
+
23
+ @runtime_checkable
24
+ class LessonRetriever(Protocol):
25
+ """Protocol for semantic retrieval backends used by LessonStore.
26
+
27
+ Any object implementing ``search``, ``add``, and ``remove`` with
28
+ matching signatures satisfies this protocol (structural subtyping).
29
+ """
30
+
31
+ def search(self, query: str, k: int) -> list[tuple[str, float]]:
32
+ """Return up to *k* (lesson_id, score) pairs ranked by similarity."""
33
+ ...
34
+
35
+ def add(self, lesson_id: str, text: str) -> None:
36
+ """Index a lesson text under the given ID. Idempotent."""
37
+ ...
38
+
39
+ def remove(self, lesson_id: str) -> None:
40
+ """Remove a lesson from the index. No-op if ID unknown."""
41
+ ...
42
+
43
+
44
+ @runtime_checkable
45
+ class EmbeddingBackend(Protocol):
46
+ """Protocol for pluggable embedding providers."""
47
+
48
+ @property
49
+ def dim(self) -> int:
50
+ """Embedding dimensionality."""
51
+ ...
52
+
53
+ @property
54
+ def model_id(self) -> str:
55
+ """Unique model identifier (used for index compatibility checks)."""
56
+ ...
57
+
58
+ def embed(self, texts: list[str]) -> list[list[float]]:
59
+ """Embed a batch of texts into float vectors."""
60
+ ...
61
+
62
+
63
+ __all__ = [
64
+ "EmbeddingBackend",
65
+ "LessonRetriever",
66
+ ]
@@ -0,0 +1,117 @@
1
+ """Concrete embedding backend implementations.
2
+
3
+ ``FastEmbedBackend`` -- multilingual embeddings via fastembed (lazy import).
4
+ ``CallableBackend`` -- wrap any user-supplied function (ollama, openai, etc.).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import warnings
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ # Default: 384-dim, 50+ languages (RU + EN), 512 tokens, fast.
14
+ _DEFAULT_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
15
+
16
+
17
+ class FastEmbedBackend:
18
+ """Multilingual embedding backend powered by fastembed.
19
+
20
+ The model is loaded lazily on first ``embed`` call. If fastembed
21
+ is not installed, a clear ``ImportError`` is raised with install
22
+ instructions.
23
+
24
+ Args:
25
+ model_name: fastembed model identifier. Must appear in
26
+ ``TextEmbedding.list_supported_models()``.
27
+ """
28
+
29
+ def __init__(self, model_name: str = _DEFAULT_MODEL) -> None:
30
+ self._model_name = model_name
31
+ self._model: Any = None
32
+ self._dim: int | None = None
33
+
34
+ def _ensure_model(self) -> None:
35
+ """Lazy-load the fastembed model on first use."""
36
+ if self._model is not None:
37
+ return
38
+ try:
39
+ from fastembed import TextEmbedding
40
+ except ImportError:
41
+ raise ImportError(
42
+ "fastembed is required for embedding-based retrieval. "
43
+ "Install it with: pip install errlore[embeddings]"
44
+ ) from None
45
+ # C3: suppress the known "now uses mean pooling instead of CLS"
46
+ # UserWarning emitted by fastembed >= 0.6 for models that changed
47
+ # their default pooling strategy. The warning is informational
48
+ # and cannot be acted upon without pinning to an older version.
49
+ with warnings.catch_warnings():
50
+ warnings.filterwarnings(
51
+ "ignore",
52
+ message=".*mean pooling instead of CLS.*",
53
+ category=UserWarning,
54
+ )
55
+ self._model = TextEmbedding(self._model_name)
56
+ # Probe dimensionality.
57
+ probe = list(self._model.embed(["_dim_probe_"]))
58
+ self._dim = len(probe[0])
59
+
60
+ # -- EmbeddingBackend protocol -----------------------------------------
61
+
62
+ @property
63
+ def dim(self) -> int:
64
+ """Embedding dimensionality (discovered on first use)."""
65
+ self._ensure_model()
66
+ assert self._dim is not None
67
+ return self._dim
68
+
69
+ @property
70
+ def model_id(self) -> str:
71
+ """Model identifier string."""
72
+ return self._model_name
73
+
74
+ def embed(self, texts: list[str]) -> list[list[float]]:
75
+ """Embed texts into float vectors via fastembed."""
76
+ self._ensure_model()
77
+ assert self._model is not None
78
+ raw = list(self._model.embed(texts))
79
+ return [vec.tolist() for vec in raw]
80
+
81
+
82
+ class CallableBackend:
83
+ """Wrap an arbitrary callable as an ``EmbeddingBackend``.
84
+
85
+ Useful for plugging in OpenAI, Ollama, or any custom embedding
86
+ function without writing a full backend class.
87
+
88
+ Args:
89
+ fn: Callable that takes ``list[str]`` and returns
90
+ ``list[list[float]]``.
91
+ dim: Fixed embedding dimensionality.
92
+ model_id: Identifier string (used for index compatibility).
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ fn: Callable[[list[str]], list[list[float]]],
98
+ dim: int,
99
+ model_id: str = "custom",
100
+ ) -> None:
101
+ self._fn = fn
102
+ self._dim = dim
103
+ self._model_id = model_id
104
+
105
+ @property
106
+ def dim(self) -> int:
107
+ """Fixed embedding dimensionality."""
108
+ return self._dim
109
+
110
+ @property
111
+ def model_id(self) -> str:
112
+ """Model identifier string."""
113
+ return self._model_id
114
+
115
+ def embed(self, texts: list[str]) -> list[list[float]]:
116
+ """Delegate to the wrapped callable."""
117
+ return self._fn(texts)