vectorwave 1.0.2__cp313-cp313-win_amd64.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.
- vectorwave/__init__.py +35 -0
- vectorwave/batch/__init__.py +0 -0
- vectorwave/batch/batch.py +257 -0
- vectorwave/check/__init__.py +17 -0
- vectorwave/check/calibrate.py +358 -0
- vectorwave/check/cli.py +74 -0
- vectorwave/check/config.py +75 -0
- vectorwave/check/plugin.py +175 -0
- vectorwave/check/report.py +48 -0
- vectorwave/cli/__init__.py +326 -0
- vectorwave/cli/__main__.py +6 -0
- vectorwave/cli/dev/__init__.py +0 -0
- vectorwave/cli/dev/compose.yml +33 -0
- vectorwave/core/__init__.py +0 -0
- vectorwave/core/auto_injector.py +111 -0
- vectorwave/core/core.py +0 -0
- vectorwave/core/decorator.py +257 -0
- vectorwave/core/generator.py +154 -0
- vectorwave/core/initializer.py +37 -0
- vectorwave/core/llm/__init__.py +0 -0
- vectorwave/core/llm/base.py +47 -0
- vectorwave/core/llm/factory.py +10 -0
- vectorwave/core/llm/openai_client.py +79 -0
- vectorwave/database/__init__.py +0 -0
- vectorwave/database/archiver.py +101 -0
- vectorwave/database/dataset.py +154 -0
- vectorwave/database/db.py +342 -0
- vectorwave/database/db_search.py +466 -0
- vectorwave/exception/__init__.py +0 -0
- vectorwave/exception/exceptions.py +22 -0
- vectorwave/models/__init__.py +0 -0
- vectorwave/models/db_config.py +164 -0
- vectorwave/monitoring/__init__.py +0 -0
- vectorwave/monitoring/alert/__init__.py +0 -0
- vectorwave/monitoring/alert/base.py +8 -0
- vectorwave/monitoring/alert/factory.py +21 -0
- vectorwave/monitoring/alert/null_alerter.py +7 -0
- vectorwave/monitoring/alert/webhook_alerter.py +69 -0
- vectorwave/monitoring/monitoring.py +0 -0
- vectorwave/monitoring/otel.py +167 -0
- vectorwave/monitoring/tracer.py +539 -0
- vectorwave/prediction/__init__.py +0 -0
- vectorwave/prediction/predictor.py +0 -0
- vectorwave/runtime.py +210 -0
- vectorwave/search/__init__.py +0 -0
- vectorwave/search/execution_search.py +136 -0
- vectorwave/search/extended_search.py +0 -0
- vectorwave/search/rag_search.py +154 -0
- vectorwave/store/__init__.py +19 -0
- vectorwave/store/base.py +144 -0
- vectorwave/store/factory.py +43 -0
- vectorwave/store/lance_store.py +364 -0
- vectorwave/store/weaviate_store.py +306 -0
- vectorwave/utils/__init__.py +0 -0
- vectorwave/utils/context.py +4 -0
- vectorwave/utils/function_cache.py +104 -0
- vectorwave/utils/github_pr.py +87 -0
- vectorwave/utils/healer.py +346 -0
- vectorwave/utils/path_utils.py +32 -0
- vectorwave/utils/replayer.py +303 -0
- vectorwave/utils/replayer_semantic.py +130 -0
- vectorwave/utils/return_caching_utils.py +166 -0
- vectorwave/utils/scheduler.py +127 -0
- vectorwave/utils/serialization.py +17 -0
- vectorwave/utils/status.py +51 -0
- vectorwave/vectorizer/__init__.py +0 -0
- vectorwave/vectorizer/base.py +12 -0
- vectorwave/vectorizer/factory.py +51 -0
- vectorwave/vectorizer/huggingface_vectorizer.py +34 -0
- vectorwave/vectorizer/openai_vectorizer.py +46 -0
- vectorwave/vectorwave_core.cp313-win_amd64.pyd +0 -0
- vectorwave-1.0.2.dist-info/METADATA +360 -0
- vectorwave-1.0.2.dist-info/RECORD +78 -0
- vectorwave-1.0.2.dist-info/WHEEL +4 -0
- vectorwave-1.0.2.dist-info/entry_points.txt +4 -0
- vectorwave-1.0.2.dist-info/licenses/LICENSE +21 -0
- vectorwave-1.0.2.dist-info/licenses/NOTICE +31 -0
- vectorwave-1.0.2.dist-info/sboms/vectorwave_core.cyclonedx.json +1076 -0
vectorwave/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from . import runtime as _runtime
|
|
2
|
+
# Light up the visible indicator (stderr banner + PID file) on first import.
|
|
3
|
+
# Silence with VECTORWAVE_QUIET=1.
|
|
4
|
+
_runtime.activate()
|
|
5
|
+
|
|
6
|
+
from .core.decorator import vectorize
|
|
7
|
+
from .database.db import initialize_database, update_database_schema
|
|
8
|
+
from .database.db_search import search_functions, search_executions, search_errors_by_message, search_functions_hybrid
|
|
9
|
+
from .monitoring.tracer import trace_span
|
|
10
|
+
from .search.rag_search import search_and_answer, analyze_trace_log
|
|
11
|
+
from .core.generator import generate_and_register_metadata
|
|
12
|
+
from .utils.healer import VectorWaveHealer
|
|
13
|
+
from .utils.replayer import VectorWaveReplayer
|
|
14
|
+
from .utils.replayer_semantic import SemanticReplayer
|
|
15
|
+
from .database.dataset import VectorWaveDatasetManager
|
|
16
|
+
from .core.auto_injector import VectorWaveAutoInjector
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
'vectorize',
|
|
20
|
+
'initialize_database',
|
|
21
|
+
'search_functions',
|
|
22
|
+
'search_functions_hybrid',
|
|
23
|
+
'search_executions',
|
|
24
|
+
'search_errors_by_message',
|
|
25
|
+
'trace_span',
|
|
26
|
+
'search_and_answer',
|
|
27
|
+
'analyze_trace_log',
|
|
28
|
+
'generate_and_register_metadata',
|
|
29
|
+
'VectorWaveHealer',
|
|
30
|
+
'VectorWaveReplayer',
|
|
31
|
+
'SemanticReplayer',
|
|
32
|
+
'VectorWaveDatasetManager',
|
|
33
|
+
'VectorWaveAutoInjector',
|
|
34
|
+
'update_database_schema'
|
|
35
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import weaviate
|
|
3
|
+
import atexit
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import queue
|
|
7
|
+
import time
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import Optional, List, Dict, Any
|
|
10
|
+
|
|
11
|
+
from ..models.db_config import get_weaviate_settings, WeaviateSettings
|
|
12
|
+
from ..database.db import get_weaviate_client
|
|
13
|
+
|
|
14
|
+
# Rust Core 모듈 Import 시도
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from vectorwave.vectorwave_core import RustBatchManager
|
|
21
|
+
USE_RUST_CORE = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
USE_RUST_CORE = False
|
|
24
|
+
|
|
25
|
+
class WeaviateBatchManager:
|
|
26
|
+
"""
|
|
27
|
+
Manages Weaviate batch imports.
|
|
28
|
+
Uses High-Performance Rust Core if available, otherwise falls back to Python.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, host: Optional[str] = None, port: Optional[int] = None,
|
|
32
|
+
grpc_port: Optional[int] = None, api_key: Optional[str] = None):
|
|
33
|
+
self._initialized = False
|
|
34
|
+
self._shutdown_done = False
|
|
35
|
+
self.settings: WeaviateSettings = get_weaviate_settings()
|
|
36
|
+
self.client: Optional[weaviate.WeaviateClient] = None
|
|
37
|
+
|
|
38
|
+
# Lite mode (LanceDB local file store) skips the Weaviate client entirely.
|
|
39
|
+
# When VECTORWAVE_MODE=lite, _flush_batch_core delegates to the
|
|
40
|
+
# configured VectorStore so no Docker / Weaviate connection is needed.
|
|
41
|
+
self._lite_mode = os.environ.get("VECTORWAVE_MODE", "pro").lower() == "lite"
|
|
42
|
+
|
|
43
|
+
# Store dynamic connection params
|
|
44
|
+
self._host = host
|
|
45
|
+
self._port = port
|
|
46
|
+
self._grpc_port = grpc_port
|
|
47
|
+
self._api_key = api_key
|
|
48
|
+
|
|
49
|
+
# Batch Configuration
|
|
50
|
+
self.batch_threshold = self.settings.BATCH_THRESHOLD
|
|
51
|
+
self.flush_interval = self.settings.FLUSH_INTERVAL_SECONDS
|
|
52
|
+
|
|
53
|
+
# Connect to DB
|
|
54
|
+
self._connect_client()
|
|
55
|
+
|
|
56
|
+
if USE_RUST_CORE:
|
|
57
|
+
logger.info(f"🚀 [VectorWave] Rust Core Activated! (Threshold: {self.batch_threshold}, Interval: {self.flush_interval}s)")
|
|
58
|
+
self._rust_manager = RustBatchManager(
|
|
59
|
+
self._flush_batch_core,
|
|
60
|
+
self.batch_threshold,
|
|
61
|
+
int(self.flush_interval * 1000) # ms 단위 변환
|
|
62
|
+
)
|
|
63
|
+
self._worker_thread = None
|
|
64
|
+
else:
|
|
65
|
+
logger.warning("⚠️ [VectorWave] Rust Core not found. Using slower Python implementation.")
|
|
66
|
+
# --- Legacy Python Implementation ---
|
|
67
|
+
self.queue = queue.Queue(maxsize=10000)
|
|
68
|
+
self._stop_event = threading.Event()
|
|
69
|
+
self._start_python_worker()
|
|
70
|
+
|
|
71
|
+
# Register shutdown handler
|
|
72
|
+
atexit.register(self.shutdown)
|
|
73
|
+
|
|
74
|
+
def _connect_client(self):
|
|
75
|
+
"""Attempts to connect to the configured backend (Weaviate or Lite store)."""
|
|
76
|
+
if self._lite_mode:
|
|
77
|
+
try:
|
|
78
|
+
from ..store import get_vector_store
|
|
79
|
+
store = get_vector_store()
|
|
80
|
+
if store.is_ready():
|
|
81
|
+
self._initialized = True
|
|
82
|
+
self.client = None # not used in Lite mode
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.warning(f"Lite store init failed: {e}")
|
|
85
|
+
self._initialized = False
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
if self._host is not None:
|
|
90
|
+
self.client = get_weaviate_client(
|
|
91
|
+
host=self._host, port=self._port,
|
|
92
|
+
grpc_port=self._grpc_port, api_key=self._api_key
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
self.client = get_weaviate_client(self.settings)
|
|
96
|
+
if self.client:
|
|
97
|
+
self._initialized = True
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.warning(f"Initial DB connection failed: {e}")
|
|
100
|
+
self._initialized = False
|
|
101
|
+
|
|
102
|
+
def _start_python_worker(self):
|
|
103
|
+
"""Starts the legacy Python background thread."""
|
|
104
|
+
self._worker_thread = threading.Thread(target=self._python_worker_loop, daemon=True)
|
|
105
|
+
self._worker_thread.start()
|
|
106
|
+
|
|
107
|
+
def add_object(self, collection: str, properties: dict, uuid: str = None, vector: Optional[List[float]] = None):
|
|
108
|
+
"""
|
|
109
|
+
[Public API] Adds an object to the batch queue.
|
|
110
|
+
"""
|
|
111
|
+
if USE_RUST_CORE:
|
|
112
|
+
|
|
113
|
+
self._rust_manager.add_object(collection, properties, uuid, vector)
|
|
114
|
+
else:
|
|
115
|
+
# Python Legacy Queue
|
|
116
|
+
item = {
|
|
117
|
+
"collection": collection,
|
|
118
|
+
"properties": properties,
|
|
119
|
+
"uuid": uuid,
|
|
120
|
+
"vector": vector
|
|
121
|
+
}
|
|
122
|
+
try:
|
|
123
|
+
self.queue.put_nowait(item)
|
|
124
|
+
except queue.Full:
|
|
125
|
+
logger.warning("🚨 VectorWave Log Queue is FULL. Dropping log.")
|
|
126
|
+
|
|
127
|
+
def _flush_batch_core(self, items: List[Dict[str, Any]]):
|
|
128
|
+
"""
|
|
129
|
+
The actual flush logic called by either Rust or Python worker.
|
|
130
|
+
|
|
131
|
+
In Pro mode (default) this uses Weaviate's bulk batch.dynamic() context.
|
|
132
|
+
In Lite mode it groups items by collection and calls
|
|
133
|
+
VectorStore.insert_many — LanceDB has no equivalent of Weaviate's
|
|
134
|
+
single-context bulk write, but per-collection batching is fine for the
|
|
135
|
+
Lite use case.
|
|
136
|
+
"""
|
|
137
|
+
if not items:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# 1. Check/Retry Connection
|
|
141
|
+
if not self._initialized or (not self._lite_mode and not self.client):
|
|
142
|
+
self._connect_client()
|
|
143
|
+
if not self._initialized:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if self._lite_mode:
|
|
147
|
+
self._flush_via_store(items)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# 2. Send Batch via Weaviate Client (Pro mode)
|
|
151
|
+
try:
|
|
152
|
+
# Weaviate v4 batch context
|
|
153
|
+
with self.client.batch.dynamic() as batch:
|
|
154
|
+
for item in items:
|
|
155
|
+
batch.add_object(
|
|
156
|
+
collection=item['collection'],
|
|
157
|
+
properties=item['properties'],
|
|
158
|
+
uuid=item.get('uuid'),
|
|
159
|
+
vector=item.get('vector')
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if len(self.client.batch.failed_objects) > 0:
|
|
163
|
+
for failed in self.client.batch.failed_objects:
|
|
164
|
+
logger.error(f"⚠️ Batch Item Failed: {failed.message}")
|
|
165
|
+
|
|
166
|
+
except RuntimeError:
|
|
167
|
+
return
|
|
168
|
+
except Exception as e:
|
|
169
|
+
msg = str(e).lower()
|
|
170
|
+
if "shutdown" in msg or "closed" in msg:
|
|
171
|
+
return
|
|
172
|
+
logger.error(f"❌ Batch Flush Error: {e}")
|
|
173
|
+
|
|
174
|
+
def _flush_via_store(self, items: List[Dict[str, Any]]):
|
|
175
|
+
"""Lite-mode flush: route items through the VectorStore abstraction."""
|
|
176
|
+
from ..store import get_vector_store
|
|
177
|
+
try:
|
|
178
|
+
store = get_vector_store()
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error(f"❌ Lite store unavailable: {e}")
|
|
181
|
+
return
|
|
182
|
+
by_collection: Dict[str, List[Dict[str, Any]]] = {}
|
|
183
|
+
for item in items:
|
|
184
|
+
by_collection.setdefault(item["collection"], []).append({
|
|
185
|
+
"properties": item["properties"],
|
|
186
|
+
"uuid": item.get("uuid"),
|
|
187
|
+
"vector": item.get("vector"),
|
|
188
|
+
})
|
|
189
|
+
for collection, batch in by_collection.items():
|
|
190
|
+
try:
|
|
191
|
+
# Lite stores create tables lazily, but ensure the schema exists
|
|
192
|
+
# so writes don't fail with "table not found".
|
|
193
|
+
if not store.collection_exists(collection):
|
|
194
|
+
store.ensure_collection(collection, properties=[])
|
|
195
|
+
store.insert_many(collection, batch)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
logger.error(f"❌ Lite batch flush failed for '{collection}': {e}")
|
|
198
|
+
|
|
199
|
+
# --- Legacy Python Worker Methods (Only used if Rust is missing) ---
|
|
200
|
+
def _python_worker_loop(self):
|
|
201
|
+
pending_items = []
|
|
202
|
+
last_flush_time = time.time()
|
|
203
|
+
|
|
204
|
+
while not self._stop_event.is_set():
|
|
205
|
+
try:
|
|
206
|
+
item = self.queue.get(timeout=0.5)
|
|
207
|
+
pending_items.append(item)
|
|
208
|
+
except queue.Empty:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
current_time = time.time()
|
|
212
|
+
if len(pending_items) >= self.batch_threshold or (pending_items and current_time - last_flush_time >= self.flush_interval):
|
|
213
|
+
self._flush_batch_core(pending_items)
|
|
214
|
+
pending_items = []
|
|
215
|
+
last_flush_time = current_time
|
|
216
|
+
|
|
217
|
+
def shutdown(self):
|
|
218
|
+
"""Gracefully shuts down. Idempotent — repeated calls are no-ops, so the
|
|
219
|
+
atexit handler firing after a test-time cache_clear cannot trigger a
|
|
220
|
+
second shutdown on an already-closed Rust worker or Weaviate client."""
|
|
221
|
+
if self._shutdown_done:
|
|
222
|
+
return
|
|
223
|
+
self._shutdown_done = True
|
|
224
|
+
|
|
225
|
+
if USE_RUST_CORE:
|
|
226
|
+
try:
|
|
227
|
+
self._rust_manager.shutdown()
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.debug(f"Rust manager shutdown raised: {e}")
|
|
230
|
+
else:
|
|
231
|
+
if not self._stop_event.is_set():
|
|
232
|
+
self._stop_event.set()
|
|
233
|
+
if self._worker_thread and self._worker_thread.is_alive():
|
|
234
|
+
self._worker_thread.join(timeout=1.0)
|
|
235
|
+
|
|
236
|
+
# Flush remaining items
|
|
237
|
+
remaining = []
|
|
238
|
+
while not self.queue.empty():
|
|
239
|
+
remaining.append(self.queue.get_nowait())
|
|
240
|
+
if remaining:
|
|
241
|
+
self._flush_batch_core(remaining)
|
|
242
|
+
|
|
243
|
+
# Close client
|
|
244
|
+
if self.client:
|
|
245
|
+
try:
|
|
246
|
+
self.client.close()
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
@lru_cache()
|
|
251
|
+
def get_batch_manager(
|
|
252
|
+
host: Optional[str] = None,
|
|
253
|
+
port: Optional[int] = None,
|
|
254
|
+
grpc_port: Optional[int] = None,
|
|
255
|
+
api_key: Optional[str] = None
|
|
256
|
+
) -> WeaviateBatchManager:
|
|
257
|
+
return WeaviateBatchManager(host=host, port=port, grpc_port=grpc_port, api_key=api_key)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""VectorWave Check: pytest plugin + CLI for semantic regression testing.
|
|
2
|
+
|
|
3
|
+
Quick usage (declarative marker):
|
|
4
|
+
|
|
5
|
+
@pytest.mark.vectorwave(target="myapp.summarize", strategy="similarity", threshold=0.85)
|
|
6
|
+
def test_summarize_regression():
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
Quick usage (imperative fixture):
|
|
10
|
+
|
|
11
|
+
def test_summarize_regression(vw_replay):
|
|
12
|
+
result = vw_replay("myapp.summarize", strategy="similarity", threshold=0.85)
|
|
13
|
+
assert result.passed_all, result.report()
|
|
14
|
+
"""
|
|
15
|
+
from .plugin import ReplayResult
|
|
16
|
+
|
|
17
|
+
__all__ = ["ReplayResult"]
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Threshold calibration for the vectorwave-check pytest plugin.
|
|
2
|
+
|
|
3
|
+
Two measurement modes:
|
|
4
|
+
|
|
5
|
+
* **diversity** (default) — pull existing golden outputs for the target,
|
|
6
|
+
compute pairwise cosine similarity. Reports how diverse the function's
|
|
7
|
+
typical outputs are. No function calls, no side effects.
|
|
8
|
+
* **rerun** — sample a few golden inputs, re-execute the function N times
|
|
9
|
+
per input, compute pairwise similarity within each input group. Reports
|
|
10
|
+
the function's intrinsic noise floor. Hits the function (and any APIs it
|
|
11
|
+
calls); skip for functions with side effects.
|
|
12
|
+
|
|
13
|
+
Both modes emit the same shape so the downstream CLI / pyproject snippet
|
|
14
|
+
generator can stay one path.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import concurrent.futures
|
|
20
|
+
import importlib
|
|
21
|
+
import inspect
|
|
22
|
+
import logging
|
|
23
|
+
import math
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
PERCENTILES = (5, 10, 25, 50, 75, 95)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CalibrationResult:
|
|
35
|
+
function: str
|
|
36
|
+
mode: str
|
|
37
|
+
sample_count: int # how many goldens (diversity) / inputs sampled (rerun)
|
|
38
|
+
pair_count: int # how many similarity pairs computed
|
|
39
|
+
percentiles: Dict[int, float] = field(default_factory=dict)
|
|
40
|
+
recommended_threshold: Optional[float] = None
|
|
41
|
+
recommended_strategy: str = "similarity"
|
|
42
|
+
notes: List[str] = field(default_factory=list)
|
|
43
|
+
vectorizer_name: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cosine(v1: Sequence[float], v2: Sequence[float]) -> float:
|
|
47
|
+
dot = 0.0
|
|
48
|
+
n1 = 0.0
|
|
49
|
+
n2 = 0.0
|
|
50
|
+
for a, b in zip(v1, v2):
|
|
51
|
+
dot += a * b
|
|
52
|
+
n1 += a * a
|
|
53
|
+
n2 += b * b
|
|
54
|
+
if n1 == 0 or n2 == 0:
|
|
55
|
+
return 0.0
|
|
56
|
+
return dot / (math.sqrt(n1) * math.sqrt(n2))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _percentile(sorted_values: List[float], p: int) -> float:
|
|
60
|
+
if not sorted_values:
|
|
61
|
+
return 0.0
|
|
62
|
+
if len(sorted_values) == 1:
|
|
63
|
+
return sorted_values[0]
|
|
64
|
+
# linear interpolation between closest ranks
|
|
65
|
+
k = (len(sorted_values) - 1) * p / 100.0
|
|
66
|
+
lo = int(math.floor(k))
|
|
67
|
+
hi = int(math.ceil(k))
|
|
68
|
+
if lo == hi:
|
|
69
|
+
return sorted_values[lo]
|
|
70
|
+
return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * (k - lo)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _pairwise_similarities(embeddings: List[Sequence[float]]) -> List[float]:
|
|
74
|
+
out: List[float] = []
|
|
75
|
+
for i in range(len(embeddings)):
|
|
76
|
+
for j in range(i + 1, len(embeddings)):
|
|
77
|
+
out.append(_cosine(embeddings[i], embeddings[j]))
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _embed_all(vectorizer, texts: List[str]) -> List[Sequence[float]]:
|
|
82
|
+
if hasattr(vectorizer, "embed_batch"):
|
|
83
|
+
try:
|
|
84
|
+
return vectorizer.embed_batch(texts)
|
|
85
|
+
except Exception as e: # noqa: BLE001
|
|
86
|
+
logger.warning("embed_batch failed (%s); falling back to per-text embed", e)
|
|
87
|
+
return [vectorizer.embed(t) for t in texts]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _summarize(
|
|
91
|
+
function: str,
|
|
92
|
+
mode: str,
|
|
93
|
+
sample_count: int,
|
|
94
|
+
similarities: List[float],
|
|
95
|
+
vectorizer_name: Optional[str],
|
|
96
|
+
) -> CalibrationResult:
|
|
97
|
+
sims_sorted = sorted(similarities)
|
|
98
|
+
percentiles = {p: _percentile(sims_sorted, p) for p in PERCENTILES}
|
|
99
|
+
|
|
100
|
+
notes: List[str] = []
|
|
101
|
+
rec_threshold = percentiles.get(5)
|
|
102
|
+
rec_strategy = "similarity"
|
|
103
|
+
|
|
104
|
+
if not sims_sorted:
|
|
105
|
+
notes.append("No similarity pairs were computed — recommendation not available.")
|
|
106
|
+
rec_threshold = None
|
|
107
|
+
else:
|
|
108
|
+
p5 = percentiles[5]
|
|
109
|
+
p95 = percentiles[95]
|
|
110
|
+
if p5 > 0.99 and p95 > 0.99:
|
|
111
|
+
rec_strategy = "exact"
|
|
112
|
+
rec_threshold = None
|
|
113
|
+
notes.append(
|
|
114
|
+
"Function appears deterministic (p5 and p95 both >0.99). "
|
|
115
|
+
"Use `strategy=\"exact\"` instead of similarity."
|
|
116
|
+
)
|
|
117
|
+
elif p5 < 0.6:
|
|
118
|
+
rec_strategy = "llm"
|
|
119
|
+
notes.append(
|
|
120
|
+
f"Function is highly variable (p5={p5:.3f} < 0.6). "
|
|
121
|
+
"Similarity threshold will be noisy; consider `strategy=\"llm\"` "
|
|
122
|
+
"(LLM-as-a-judge) for more robust regression detection."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return CalibrationResult(
|
|
126
|
+
function=function,
|
|
127
|
+
mode=mode,
|
|
128
|
+
sample_count=sample_count,
|
|
129
|
+
pair_count=len(similarities),
|
|
130
|
+
percentiles=percentiles,
|
|
131
|
+
recommended_threshold=rec_threshold,
|
|
132
|
+
recommended_strategy=rec_strategy,
|
|
133
|
+
notes=notes,
|
|
134
|
+
vectorizer_name=vectorizer_name,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _stringify(value: Any) -> str:
|
|
139
|
+
if isinstance(value, str):
|
|
140
|
+
return value
|
|
141
|
+
try:
|
|
142
|
+
import json
|
|
143
|
+
return json.dumps(value, sort_keys=True, default=str)
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
return str(value)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _run_coroutine_safely(coro):
|
|
149
|
+
try:
|
|
150
|
+
asyncio.get_running_loop()
|
|
151
|
+
except RuntimeError:
|
|
152
|
+
return asyncio.run(coro)
|
|
153
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
154
|
+
return executor.submit(asyncio.run, coro).result()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _vectorizer_name(vectorizer) -> Optional[str]:
|
|
158
|
+
if vectorizer is None:
|
|
159
|
+
return None
|
|
160
|
+
return type(vectorizer).__name__
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Mode 1: diversity (cheap, default)
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def _calibrate_diversity(function_full_name: str, samples: int) -> CalibrationResult:
|
|
168
|
+
from ..models.db_config import get_weaviate_settings
|
|
169
|
+
from ..store import get_vector_store
|
|
170
|
+
from ..utils.serialization import deserialize_return_value
|
|
171
|
+
from ..vectorizer.factory import get_vectorizer
|
|
172
|
+
|
|
173
|
+
func_short_name = function_full_name.rsplit(".", 1)[-1]
|
|
174
|
+
settings = get_weaviate_settings()
|
|
175
|
+
store = get_vector_store()
|
|
176
|
+
vectorizer = get_vectorizer()
|
|
177
|
+
if vectorizer is None:
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
"Calibration requires a local vectorizer (VECTORIZER=huggingface or openai_client). "
|
|
180
|
+
"Server-side vectorization (weaviate_module) cannot embed offline."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
records = store.query(
|
|
184
|
+
collection=settings.GOLDEN_COLLECTION_NAME,
|
|
185
|
+
filters={"function_name": func_short_name},
|
|
186
|
+
limit=samples,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
outputs: List[str] = []
|
|
190
|
+
for rec in records:
|
|
191
|
+
raw = rec.properties.get("return_value")
|
|
192
|
+
deserialized = deserialize_return_value(raw)
|
|
193
|
+
outputs.append(_stringify(deserialized))
|
|
194
|
+
|
|
195
|
+
if len(outputs) < 2:
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
f"Need at least 2 golden samples for '{function_full_name}', found {len(outputs)}. "
|
|
198
|
+
"Run the function a few times in production / replay-capture mode first, "
|
|
199
|
+
"or mark known-good executions as Golden."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
embeddings = _embed_all(vectorizer, outputs)
|
|
203
|
+
similarities = _pairwise_similarities(embeddings)
|
|
204
|
+
|
|
205
|
+
return _summarize(
|
|
206
|
+
function=function_full_name,
|
|
207
|
+
mode="diversity",
|
|
208
|
+
sample_count=len(outputs),
|
|
209
|
+
similarities=similarities,
|
|
210
|
+
vectorizer_name=_vectorizer_name(vectorizer),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# Mode 2: rerun (honest noise floor, opt-in)
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def _calibrate_rerun(
|
|
219
|
+
function_full_name: str,
|
|
220
|
+
samples: int,
|
|
221
|
+
runs: int,
|
|
222
|
+
) -> CalibrationResult:
|
|
223
|
+
from ..models.db_config import get_weaviate_settings
|
|
224
|
+
from ..utils.replayer import VectorWaveReplayer
|
|
225
|
+
from ..vectorizer.factory import get_vectorizer
|
|
226
|
+
|
|
227
|
+
module_name, func_short_name = function_full_name.rsplit(".", 1)
|
|
228
|
+
module = importlib.import_module(module_name)
|
|
229
|
+
target_func = getattr(module, func_short_name)
|
|
230
|
+
is_async = inspect.iscoroutinefunction(target_func)
|
|
231
|
+
|
|
232
|
+
get_weaviate_settings() # surface config errors early
|
|
233
|
+
vectorizer = get_vectorizer()
|
|
234
|
+
if vectorizer is None:
|
|
235
|
+
raise RuntimeError(
|
|
236
|
+
"Calibration requires a local vectorizer (VECTORIZER=huggingface or openai_client)."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
helper = VectorWaveReplayer()
|
|
240
|
+
candidates = helper._fetch_test_candidates(func_short_name, limit=samples)
|
|
241
|
+
if not candidates:
|
|
242
|
+
raise RuntimeError(
|
|
243
|
+
f"No golden samples or execution logs found for '{function_full_name}'. "
|
|
244
|
+
"Rerun calibration needs at least one captured input."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
all_similarities: List[float] = []
|
|
248
|
+
sampled = candidates[:samples]
|
|
249
|
+
for cand in sampled:
|
|
250
|
+
raw_inputs = cand["inputs"]
|
|
251
|
+
inputs = helper._extract_inputs(raw_inputs, target_func)
|
|
252
|
+
outputs: List[str] = []
|
|
253
|
+
for _ in range(runs):
|
|
254
|
+
try:
|
|
255
|
+
if is_async:
|
|
256
|
+
out = _run_coroutine_safely(target_func(**inputs))
|
|
257
|
+
else:
|
|
258
|
+
out = target_func(**inputs)
|
|
259
|
+
except Exception as e: # noqa: BLE001
|
|
260
|
+
logger.warning("Calibration run raised %s; skipping this run", e)
|
|
261
|
+
continue
|
|
262
|
+
outputs.append(_stringify(out))
|
|
263
|
+
if len(outputs) >= 2:
|
|
264
|
+
embeddings = _embed_all(vectorizer, outputs)
|
|
265
|
+
all_similarities.extend(_pairwise_similarities(embeddings))
|
|
266
|
+
|
|
267
|
+
if not all_similarities:
|
|
268
|
+
raise RuntimeError(
|
|
269
|
+
"Rerun calibration produced no usable comparisons "
|
|
270
|
+
"(function raised every time, or only one successful run per input). "
|
|
271
|
+
"Inspect the function's behavior manually."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return _summarize(
|
|
275
|
+
function=function_full_name,
|
|
276
|
+
mode="rerun",
|
|
277
|
+
sample_count=len(sampled),
|
|
278
|
+
similarities=all_similarities,
|
|
279
|
+
vectorizer_name=_vectorizer_name(vectorizer),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# Public API
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def calibrate(
|
|
288
|
+
function_full_name: str,
|
|
289
|
+
*,
|
|
290
|
+
rerun: bool = False,
|
|
291
|
+
samples: Optional[int] = None,
|
|
292
|
+
runs: int = 10,
|
|
293
|
+
) -> CalibrationResult:
|
|
294
|
+
"""Compute a threshold recommendation for `function_full_name`.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
function_full_name: Fully-qualified target, e.g. `myapp.summarize`.
|
|
298
|
+
rerun: If True, sample inputs and re-execute the function. Hits APIs
|
|
299
|
+
and triggers any side effects. If False (default), only pull
|
|
300
|
+
existing golden outputs and measure diversity — no side effects.
|
|
301
|
+
samples: Number of goldens to pull (diversity) or inputs to sample
|
|
302
|
+
(rerun). Defaults to 30 for diversity, 3 for rerun.
|
|
303
|
+
runs: Re-executions per sampled input. Only used in rerun mode.
|
|
304
|
+
"""
|
|
305
|
+
if rerun:
|
|
306
|
+
effective_samples = samples if samples is not None else 3
|
|
307
|
+
return _calibrate_rerun(function_full_name, samples=effective_samples, runs=runs)
|
|
308
|
+
effective_samples = samples if samples is not None else 30
|
|
309
|
+
return _calibrate_diversity(function_full_name, samples=effective_samples)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# Rendering for CLI
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def format_pyproject_snippet(result: CalibrationResult) -> str:
|
|
317
|
+
if result.recommended_strategy == "exact":
|
|
318
|
+
return (
|
|
319
|
+
f'[tool.vectorwave.check."{result.function}"]\n'
|
|
320
|
+
f'strategy = "exact"\n'
|
|
321
|
+
)
|
|
322
|
+
threshold = result.recommended_threshold or 0.85
|
|
323
|
+
return (
|
|
324
|
+
f'[tool.vectorwave.check."{result.function}"]\n'
|
|
325
|
+
f'strategy = "{result.recommended_strategy}"\n'
|
|
326
|
+
f"threshold = {threshold:.3f}\n"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def format_report(result: CalibrationResult) -> str:
|
|
331
|
+
lines = [
|
|
332
|
+
f"Calibration for '{result.function}' "
|
|
333
|
+
f"(mode={result.mode}, vectorizer={result.vectorizer_name or 'n/a'})",
|
|
334
|
+
f" samples={result.sample_count}, pairs={result.pair_count}",
|
|
335
|
+
"",
|
|
336
|
+
]
|
|
337
|
+
for p in PERCENTILES:
|
|
338
|
+
v = result.percentiles.get(p)
|
|
339
|
+
if v is not None:
|
|
340
|
+
lines.append(f" p{p:<3} {v:.4f}")
|
|
341
|
+
lines.append("")
|
|
342
|
+
if result.recommended_threshold is None and result.recommended_strategy == "exact":
|
|
343
|
+
lines.append("Recommended strategy: exact (no threshold needed)")
|
|
344
|
+
else:
|
|
345
|
+
lines.append(
|
|
346
|
+
f"Recommended: strategy={result.recommended_strategy}, "
|
|
347
|
+
f"threshold={result.recommended_threshold:.4f}"
|
|
348
|
+
)
|
|
349
|
+
if result.notes:
|
|
350
|
+
lines.append("")
|
|
351
|
+
for note in result.notes:
|
|
352
|
+
lines.append(f" note: {note}")
|
|
353
|
+
lines.append("")
|
|
354
|
+
lines.append("Add to pyproject.toml:")
|
|
355
|
+
lines.append("")
|
|
356
|
+
for ln in format_pyproject_snippet(result).splitlines():
|
|
357
|
+
lines.append(f" {ln}")
|
|
358
|
+
return "\n".join(lines)
|