feat: Intelligence Pipeline v3 — full implementation

Multi-stage evidence-grounded inference architecture replacing the
monolithic 9B model extraction pipeline. CPU-first specialist services
handle routine extraction while the 9B vLLM model is preserved for
semantic adjudication of ambiguous cases.

Key components:
- Capability-aware inference gateway (OpenAI-compatible + Ollama)
- Endpoint registry with DB migrations and REST API
- Sentence-aware document segmenter (property tests)
- Deterministic financial parsing with offset integrity
- Symbol resolution with ambiguity detection
- Specialist service (GLiNER2, dynamic batching, K8s deployment)
- Company-specific sentiment (FinBERT, calibration)
- Retrieval-based novelty and duplicate detection
- Confidence calibration pipeline
- Deterministic routing engine (property tests)
- 9B adjudication layer with VRAM gating
- Stock-specific impact model (features, labels, baseline, trained)
- Pipeline orchestrator (state machine, queues, leases, feature flags)
- Bounded parallelism (async workers, semaphore, load shedding)
- Observability (tracing, metrics, alerts)
- Compatibility adapter (v3→v2 golden mapping tests)
- Shadow/canary promotion framework
- Active learning and fine-tuning pipeline

Test results: 1,161 tests pass, ruff lint clean.
All 282 spec tasks completed.
This commit is contained in:
Celes Renata
2026-07-13 02:14:59 +00:00
parent 84634a365e
commit a72f336ad1
227 changed files with 50403 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
"""Dynamic batcher — collects requests up to max_batch_size or max_wait_ms.
Bounded: rejects requests when the queue exceeds max_queue_size to prevent
unbounded memory growth under sustained load.
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable, TypeVar
logger = logging.getLogger(__name__)
T = TypeVar("T")
class QueueFullError(Exception):
"""Raised when the batcher queue is at capacity."""
pass
@dataclass
class _PendingRequest:
"""A single request waiting in the batch queue."""
payload: Any
future: asyncio.Future[Any] = field(default_factory=lambda: asyncio.get_event_loop().create_future())
enqueued_at: float = field(default_factory=time.monotonic)
class DynamicBatcher:
"""Bounded dynamic batcher that collects incoming requests and processes
them together when either max_batch_size is reached or max_wait_ms elapses.
Bounded: if the internal queue exceeds max_queue_size, new submissions
are rejected with QueueFullError.
Usage:
batcher = DynamicBatcher(max_batch_size=32, max_wait_ms=50.0, max_queue_size=256)
batcher.start(process_batch_fn)
result = await batcher.submit(payload)
await batcher.stop()
"""
def __init__(
self,
max_batch_size: int = 32,
max_wait_ms: float = 50.0,
max_queue_size: int = 256,
) -> None:
self.max_batch_size = max_batch_size
self.max_wait_ms = max_wait_ms
self.max_queue_size = max_queue_size
self._queue: asyncio.Queue[_PendingRequest] = asyncio.Queue(
maxsize=max_queue_size
)
self._process_fn: Callable[..., Any] | None = None
self._task: asyncio.Task[None] | None = None
self._running = False
# Metrics
self.total_batches_processed: int = 0
self.total_items_processed: int = 0
self.total_rejections: int = 0
@property
def is_running(self) -> bool:
return self._running
@property
def queue_size(self) -> int:
"""Current number of pending items in the queue."""
return self._queue.qsize()
def start(self, process_fn: Callable[..., Any]) -> None:
"""Start the batcher background loop.
Args:
process_fn: Callable that accepts a list of payloads and returns
a list of results (same length, same order).
"""
self._process_fn = process_fn
self._running = True
self._task = asyncio.ensure_future(self._loop())
async def stop(self) -> None:
"""Stop the batcher and drain remaining requests."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
# Drain anything left
await self._drain()
async def submit(self, payload: Any) -> Any:
"""Submit a single request and await its result.
The request will be batched with other concurrent requests.
Raises:
QueueFullError: If the queue has reached max_queue_size.
"""
if self._queue.full():
self.total_rejections += 1
raise QueueFullError(
f"Batcher queue is full ({self.max_queue_size} items). "
"Request rejected — try again later."
)
loop = asyncio.get_running_loop()
pending = _PendingRequest(
payload=payload,
future=loop.create_future(),
enqueued_at=time.monotonic(),
)
try:
self._queue.put_nowait(pending)
except asyncio.QueueFull:
self.total_rejections += 1
raise QueueFullError(
f"Batcher queue is full ({self.max_queue_size} items). "
"Request rejected — try again later."
)
return await pending.future
async def _loop(self) -> None:
"""Background loop that collects and dispatches batches."""
while self._running:
batch: list[_PendingRequest] = []
try:
# Wait for the first item
first = await asyncio.wait_for(
self._queue.get(), timeout=0.1
)
batch.append(first)
except asyncio.TimeoutError:
continue
# Collect more items up to batch size or wait timeout
deadline = time.monotonic() + (self.max_wait_ms / 1000.0)
while len(batch) < self.max_batch_size:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
try:
item = await asyncio.wait_for(
self._queue.get(), timeout=remaining
)
batch.append(item)
except asyncio.TimeoutError:
break
# Process the batch
await self._process_batch(batch)
async def _drain(self) -> None:
"""Drain and process remaining queue items."""
batch: list[_PendingRequest] = []
while not self._queue.empty():
try:
item = self._queue.get_nowait()
batch.append(item)
except asyncio.QueueEmpty:
break
if batch:
await self._process_batch(batch)
async def _process_batch(self, batch: list[_PendingRequest]) -> None:
"""Invoke the process function and resolve futures."""
if not batch or not self._process_fn:
return
payloads = [req.payload for req in batch]
try:
results = self._process_fn(payloads)
if len(results) != len(batch):
raise ValueError(
f"Process function returned {len(results)} results "
f"for {len(batch)} inputs"
)
for req, result in zip(batch, results):
if not req.future.done():
req.future.set_result(result)
self.total_batches_processed += 1
self.total_items_processed += len(batch)
except Exception as exc:
logger.exception("Batch processing failed for %d items", len(batch))
for req in batch:
if not req.future.done():
req.future.set_exception(exc)